whatsviewer/main.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)
}