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] \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) }