165 lines
3.7 KiB
Go
165 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed frontend
|
|
var frontendFS embed.FS
|
|
|
|
var (
|
|
conversations map[string]*Conversation
|
|
zipPaths map[string]string // conversation ID -> zip file path
|
|
)
|
|
|
|
func main() {
|
|
port := flag.Int("port", 8080, "HTTP server port")
|
|
flag.Parse()
|
|
|
|
if flag.NArg() < 1 {
|
|
fmt.Fprintf(os.Stderr, "Usage: whatsapp-viewer [flags] <directory>\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
dir := flag.Arg(0)
|
|
|
|
// Resolve to absolute path
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
log.Fatalf("Error resolving path: %v", err)
|
|
}
|
|
|
|
fmt.Printf("Scanning %s for WhatsApp exports...\n", absDir)
|
|
|
|
conversations, err = scanDirectory(absDir)
|
|
if err != nil {
|
|
log.Fatalf("Error scanning directory: %v", err)
|
|
}
|
|
|
|
// Build zip path lookup
|
|
zipPaths = make(map[string]string)
|
|
entries, _ := os.ReadDir(absDir)
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), "._") {
|
|
continue
|
|
}
|
|
fullPath := filepath.Join(absDir, entry.Name())
|
|
id := makeID(fullPath)
|
|
zipPaths[id] = fullPath
|
|
}
|
|
|
|
fmt.Printf("Loaded %d conversations\n", len(conversations))
|
|
for _, c := range conversations {
|
|
fmt.Printf(" - %s (%d messages)\n", c.Name, c.MessageCount)
|
|
}
|
|
|
|
// API routes
|
|
http.HandleFunc("/api/conversations", handleConversations)
|
|
http.HandleFunc("/api/conversation/", handleConversation)
|
|
http.HandleFunc("/api/media/", handleMedia)
|
|
|
|
// Frontend
|
|
frontendSub, err := fs.Sub(frontendFS, "frontend")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
http.Handle("/", http.FileServer(http.FS(frontendSub)))
|
|
|
|
addr := fmt.Sprintf(":%d", *port)
|
|
fmt.Printf("Server running at http://localhost:%d\n", *port)
|
|
log.Fatal(http.ListenAndServe(addr, nil))
|
|
}
|
|
|
|
func handleConversations(w http.ResponseWriter, r *http.Request) {
|
|
query := strings.ToLower(r.URL.Query().Get("q"))
|
|
var summaries []ConversationSummary
|
|
for _, c := range conversations {
|
|
if query != "" {
|
|
// Check if any message in this conversation matches
|
|
found := false
|
|
for _, m := range c.Messages {
|
|
if strings.Contains(strings.ToLower(m.Text), query) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
}
|
|
summaries = append(summaries, ConversationSummary{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
MessageCount: c.MessageCount,
|
|
LastMessageDate: c.LastMessageDate,
|
|
})
|
|
}
|
|
sort.Slice(summaries, func(i, j int) bool {
|
|
return summaries[i].Name < summaries[j].Name
|
|
})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(summaries)
|
|
}
|
|
|
|
func handleConversation(w http.ResponseWriter, r *http.Request) {
|
|
// Path: /api/conversation/{id}
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/conversation/")
|
|
conv, ok := conversations[id]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(conv)
|
|
}
|
|
|
|
func handleMedia(w http.ResponseWriter, r *http.Request) {
|
|
// Path: /api/media/{conversationId}/{filename}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/media/")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) != 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
convID := parts[0]
|
|
filename := parts[1]
|
|
|
|
zipPath, ok := zipPaths[convID]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
data, err := getMediaFromZip(zipPath, filename)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Set content type based on extension
|
|
ext := filepath.Ext(filename)
|
|
ct := mime.TypeByExtension(ext)
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
// Special handling for opus
|
|
if strings.ToLower(ext) == ".opus" {
|
|
ct = "audio/ogg"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Write(data)
|
|
}
|