feat: initial commit — WhatsApp chat viewer

Go web server that parses WhatsApp exported ZIP files and renders
conversations in a clean browser UI. Includes bundled chat exports.
This commit is contained in:
James 2026-03-23 18:04:30 -04:00
commit 35ac095684
47 changed files with 1182 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
._WhatsApp Chat with Ma Normal file

Binary file not shown.

BIN
._WhatsApp Chat with Mila Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
._WhatsApp Chat with Scott Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
whatsapp-viewer

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
WhatsApp Chat with Ma Normal file

Binary file not shown.

BIN
WhatsApp Chat with Mila Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
WhatsApp Chat with Omegaji Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
WhatsApp Chat with Scott Normal file

Binary file not shown.

BIN
WhatsApp Chat with Shokunbi Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

31
conversation.go Normal file
View File

@ -0,0 +1,31 @@
package main
import "time"
type Attachment struct {
Filename string `json:"filename"`
Type string `json:"type"` // "image", "video", "audio", "sticker", "document"
}
type Message struct {
Timestamp time.Time `json:"timestamp"`
Sender string `json:"sender"` // empty for system messages
Text string `json:"text"`
Attachments []Attachment `json:"attachments"`
IsSystem bool `json:"isSystem"`
}
type Conversation struct {
ID string `json:"id"`
Name string `json:"name"`
Messages []Message `json:"messages"`
MessageCount int `json:"messageCount"`
LastMessageDate time.Time `json:"lastMessageDate"`
}
type ConversationSummary struct {
ID string `json:"id"`
Name string `json:"name"`
MessageCount int `json:"messageCount"`
LastMessageDate time.Time `json:"lastMessageDate"`
}

292
frontend/app.js Normal file
View File

@ -0,0 +1,292 @@
let conversations = [];
let currentConv = null;
let currentMessages = [];
let selfName = "";
let searchTerm = "";
let searchTimer = null;
let matchIndices = [];
let currentMatchPos = -1;
// Color palette for group chat senders
const senderColors = [
"#075e54", "#25d366", "#128c7e", "#00a884",
"#6b3fa0", "#d4653f", "#1f97c7", "#c74375",
"#e68a2e", "#7b5ea7", "#3d8c5c", "#c44d58"
];
const senderColorMap = {};
let colorIdx = 0;
function getSenderColor(name) {
if (!senderColorMap[name]) {
senderColorMap[name] = senderColors[colorIdx % senderColors.length];
colorIdx++;
}
return senderColorMap[name];
}
async function init() {
await fetchConversations();
renderConversationList();
document.getElementById("search-input").addEventListener("input", onSearch);
document.getElementById("self-name").addEventListener("change", onSelfChange);
}
async function fetchConversations(query) {
const url = query ? `/api/conversations?q=${encodeURIComponent(query)}` : "/api/conversations";
const resp = await fetch(url);
conversations = await resp.json() || [];
}
function renderConversationList() {
const list = document.getElementById("conversation-list");
list.innerHTML = "";
for (const c of conversations) {
const div = document.createElement("div");
div.className = "conv-item";
if (currentConv && c.id === currentConv.id) div.className += " active";
div.dataset.id = c.id;
const date = c.lastMessageDate ? formatDate(new Date(c.lastMessageDate)) : "";
div.innerHTML = `
<div class="conv-name">${esc(c.name)}</div>
<div class="conv-meta">${c.messageCount} messages &middot; ${date}</div>
`;
div.onclick = () => loadConversation(c.id);
list.appendChild(div);
}
}
async function loadConversation(id) {
// Update active state
document.querySelectorAll(".conv-item").forEach(el => {
el.classList.toggle("active", el.dataset.id === id);
});
const resp = await fetch(`/api/conversation/${id}`);
currentConv = await resp.json();
currentMessages = currentConv.messages || [];
document.getElementById("chat-name").textContent = currentConv.name;
// Detect participants and set up self selector
const participants = new Set();
for (const m of currentMessages) {
if (m.sender) participants.add(m.sender);
}
const sel = document.getElementById("self-name");
sel.innerHTML = "";
for (const p of participants) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
sel.appendChild(opt);
}
// Auto-detect: first sender is likely "self"
if (participants.size > 0) {
const firstSender = currentMessages.find(m => m.sender)?.sender || "";
selfName = firstSender;
sel.value = selfName;
}
document.getElementById("self-selector").style.display = participants.size > 1 ? "" : "none";
renderMessages();
}
function onSelfChange() {
selfName = document.getElementById("self-name").value;
renderMessages();
}
function onSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
searchTerm = document.getElementById("search-input").value.toLowerCase().trim();
await fetchConversations(searchTerm);
renderConversationList();
if (currentConv) {
renderMessages();
}
}, 200);
}
function renderMessages() {
const container = document.getElementById("chat-messages");
let msgs = currentMessages;
let firstMatchIdx = -1;
// Detect if group chat (>2 participants)
const participants = new Set();
for (const m of currentMessages) {
if (m.sender) participants.add(m.sender);
}
const isGroup = participants.size > 2;
let html = "";
let lastDate = "";
let msgIdx = 0;
for (const m of msgs) {
const d = new Date(m.timestamp);
const dateStr = d.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
if (dateStr !== lastDate) {
html += `<div class="date-header"><span>${dateStr}</span></div>`;
lastDate = dateStr;
}
const isMatch = searchTerm && (m.text || "").toLowerCase().includes(searchTerm);
if (isMatch && firstMatchIdx === -1) firstMatchIdx = msgIdx;
if (m.isSystem) {
html += `<div class="message system" data-idx="${msgIdx}"><div class="msg-text">${highlight(esc(m.text), searchTerm)}</div></div>`;
msgIdx++;
continue;
}
const isSelf = m.sender === selfName;
const cls = isSelf ? "self" : "other";
const timeStr = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
let senderHtml = "";
if (!isSelf && isGroup) {
const color = getSenderColor(m.sender);
senderHtml = `<div class="sender-name" style="color:${color}">${esc(m.sender)}</div>`;
}
let attachHtml = "";
if (m.attachments) {
for (const a of m.attachments) {
const url = `/api/media/${currentConv.id}/${encodeURIComponent(a.filename)}`;
switch (a.type) {
case "image":
attachHtml += `<img class="msg-image" src="${url}" alt="${esc(a.filename)}" loading="lazy" onclick="openLightbox(event, '${url}')">`;
break;
case "sticker":
attachHtml += `<img class="msg-sticker" src="${url}" alt="sticker" loading="lazy">`;
break;
case "video":
attachHtml += `<video class="msg-video" controls preload="none"><source src="${url}"></video>`;
break;
case "audio":
attachHtml += `<audio class="msg-audio" controls preload="none"><source src="${url}"></audio>`;
break;
default:
attachHtml += `<a class="msg-document" href="${url}" download="${esc(a.filename)}">${esc(a.filename)}</a>`;
}
}
}
// Remove attachment filenames from displayed text
let displayText = m.text || "";
if (m.attachments) {
for (const a of m.attachments) {
displayText = displayText.replace(a.filename, "").trim();
}
}
let textHtml = "";
if (displayText) {
textHtml = `<div class="msg-text">${highlight(esc(displayText), searchTerm)}</div>`;
}
html += `<div class="message ${cls}${isMatch ? " search-match" : ""}" data-idx="${msgIdx}">
${senderHtml}${attachHtml}${textHtml}
<div class="msg-time">${timeStr}</div>
</div>`;
msgIdx++;
}
container.innerHTML = html;
// Build match index list
matchIndices = [];
container.querySelectorAll(".search-match").forEach(el => {
matchIndices.push(parseInt(el.dataset.idx));
});
const nav = document.getElementById("search-nav");
if (searchTerm && matchIndices.length > 0) {
nav.classList.remove("hidden");
currentMatchPos = 0;
updateSearchNav();
scrollToMatch(currentMatchPos);
} else if (searchTerm) {
nav.classList.remove("hidden");
currentMatchPos = -1;
document.getElementById("search-pos").textContent = "0/0";
} else {
nav.classList.add("hidden");
currentMatchPos = -1;
container.scrollTop = container.scrollHeight;
}
}
function updateSearchNav() {
document.getElementById("search-pos").textContent =
`${currentMatchPos + 1}/${matchIndices.length}`;
// Remove highlight from all, add to current
document.querySelectorAll(".search-current").forEach(el => el.classList.remove("search-current"));
if (currentMatchPos >= 0) {
const el = document.querySelector(`[data-idx="${matchIndices[currentMatchPos]}"]`);
if (el) el.classList.add("search-current");
}
}
function scrollToMatch(pos) {
if (pos < 0 || pos >= matchIndices.length) return;
const el = document.querySelector(`[data-idx="${matchIndices[pos]}"]`);
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
}
function searchNext() {
if (matchIndices.length === 0) return;
currentMatchPos = (currentMatchPos + 1) % matchIndices.length;
updateSearchNav();
scrollToMatch(currentMatchPos);
}
function searchPrev() {
if (matchIndices.length === 0) return;
currentMatchPos = (currentMatchPos - 1 + matchIndices.length) % matchIndices.length;
updateSearchNav();
scrollToMatch(currentMatchPos);
}
function esc(s) {
if (!s) return "";
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
function highlight(html, term) {
if (!term) return html;
const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
return html.replace(re, "<mark>$1</mark>");
}
function formatDate(d) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function openLightbox(e, url) {
e.stopPropagation();
const lb = document.getElementById("lightbox");
document.getElementById("lightbox-img").src = url;
lb.classList.remove("hidden");
}
function closeLightbox() {
document.getElementById("lightbox").classList.add("hidden");
document.getElementById("lightbox-img").src = "";
}
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeLightbox();
});
init();

40
frontend/index.html Normal file
View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhatsApp Viewer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<aside id="sidebar">
<div id="sidebar-header">
<h1>WhatsApp Viewer</h1>
<div id="search-box">
<input type="text" id="search-input" placeholder="Search all conversations..." />
</div>
</div>
<div id="conversation-list"></div>
</aside>
<main id="chat-pane">
<div id="chat-header">
<span id="chat-name">Select a conversation</span>
<div id="search-nav" class="hidden">
<button id="search-prev" onclick="searchPrev()" title="Previous match">&#9650;</button>
<span id="search-pos">0/0</span>
<button id="search-next" onclick="searchNext()" title="Next match">&#9660;</button>
</div>
</div>
<div id="chat-messages"></div>
<div id="self-selector" style="display:none">
<label>Your name: <select id="self-name"></select></label>
</div>
</main>
</div>
<div id="lightbox" class="lightbox hidden" onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="">
</div>
<script src="app.js"></script>
</body>
</html>

369
frontend/style.css Normal file
View File

@ -0,0 +1,369 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #e5ddd5;
--sidebar-bg: #fff;
--sidebar-border: #ddd;
--bubble-self: #dcf8c6;
--bubble-other: #fff;
--bubble-system: #f0f0f0;
--text: #111;
--text-secondary: #667;
--header-bg: #075e54;
--header-text: #fff;
--accent: #25d366;
--hover: #f5f5f5;
--active: #e8f5e9;
--shadow: rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b141a;
--sidebar-bg: #111b21;
--sidebar-border: #2a3942;
--bubble-self: #005c4b;
--bubble-other: #202c33;
--bubble-system: #182229;
--text: #e9edef;
--text-secondary: #8696a0;
--header-bg: #1f2c34;
--header-text: #e9edef;
--hover: #2a3942;
--active: #2a3942;
--shadow: rgba(0,0,0,0.3);
}
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
color: var(--text);
background: var(--bg);
}
#app {
display: flex;
height: 100vh;
}
/* Sidebar */
#sidebar {
width: 320px;
min-width: 260px;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
#sidebar-header {
background: var(--header-bg);
color: var(--header-text);
padding: 14px 16px;
}
#sidebar-header h1 {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
#search-box { margin-top: 0; }
#search-input {
width: 100%;
padding: 6px 12px;
border: none;
border-radius: 18px;
font-size: 13px;
outline: none;
background: rgba(255,255,255,0.2);
color: var(--header-text);
}
#search-input::placeholder {
color: rgba(255,255,255,0.6);
}
#conversation-list {
overflow-y: auto;
flex: 1;
}
.conv-item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid var(--sidebar-border);
transition: background 0.15s;
}
.conv-item:hover { background: var(--hover); }
.conv-item.active { background: var(--active); }
.conv-item .conv-name {
font-weight: 600;
font-size: 15px;
margin-bottom: 2px;
}
.conv-item .conv-meta {
font-size: 12px;
color: var(--text-secondary);
}
/* Chat pane */
#chat-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
#chat-header {
background: var(--header-bg);
color: var(--header-text);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
#chat-name {
font-weight: 600;
font-size: 16px;
white-space: nowrap;
}
#search-nav {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
#search-nav.hidden { display: none; }
#search-nav button {
background: rgba(255,255,255,0.2);
border: none;
color: var(--header-text);
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
#search-nav button:hover { background: rgba(255,255,255,0.3); }
#search-pos {
font-size: 13px;
min-width: 40px;
text-align: center;
}
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--bg);
}
.search-match {
outline: 2px solid #ffeb3b88;
outline-offset: 2px;
}
.search-match.search-current {
outline: 2px solid #ff9800;
outline-offset: 2px;
}
@media (prefers-color-scheme: dark) {
.search-match { outline-color: #7c6f0088; }
.search-match.search-current { outline-color: #ff9800; }
}
/* Date headers */
.date-header {
text-align: center;
margin: 16px 0 8px;
}
.date-header span {
background: var(--bubble-system);
color: var(--text-secondary);
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
display: inline-block;
box-shadow: 0 1px 1px var(--shadow);
}
/* Message bubbles */
.message {
max-width: 65%;
margin: 2px 0;
padding: 6px 10px;
border-radius: 8px;
position: relative;
word-wrap: break-word;
overflow-wrap: break-word;
box-shadow: 0 1px 1px var(--shadow);
clear: both;
}
.message.self {
background: var(--bubble-self);
margin-left: auto;
border-top-right-radius: 2px;
}
.message.other {
background: var(--bubble-other);
margin-right: auto;
border-top-left-radius: 2px;
}
.message.system {
background: var(--bubble-system);
color: var(--text-secondary);
font-style: italic;
font-size: 12px;
text-align: center;
max-width: 80%;
margin: 8px auto;
border-radius: 8px;
}
.message .sender-name {
font-weight: 600;
font-size: 12px;
margin-bottom: 2px;
color: var(--accent);
}
.message .msg-text {
white-space: pre-wrap;
line-height: 1.4;
}
.message .msg-time {
font-size: 11px;
color: var(--text-secondary);
text-align: right;
margin-top: 2px;
}
/* Media */
.msg-image {
max-width: 300px;
max-height: 300px;
border-radius: 6px;
cursor: pointer;
display: block;
margin: 4px 0;
}
.msg-sticker {
width: 128px;
height: 128px;
object-fit: contain;
display: block;
margin: 4px 0;
}
.msg-video, .msg-audio {
max-width: 300px;
display: block;
margin: 4px 0;
}
.msg-audio { width: 260px; }
.msg-document {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0,0,0,0.05);
border-radius: 8px;
text-decoration: none;
color: var(--text);
margin: 4px 0;
font-size: 13px;
}
.msg-document:hover { background: rgba(0,0,0,0.1); }
.msg-document::before {
content: "\1F4CE";
font-size: 18px;
}
/* Self selector */
#self-selector {
padding: 8px 16px;
background: var(--sidebar-bg);
border-top: 1px solid var(--sidebar-border);
font-size: 13px;
}
#self-name {
padding: 4px 8px;
font-size: 13px;
border-radius: 4px;
border: 1px solid var(--sidebar-border);
background: var(--sidebar-bg);
color: var(--text);
}
/* Lightbox */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: zoom-out;
}
.lightbox.hidden { display: none; }
.lightbox img {
max-width: 95vw;
max-height: 95vh;
object-fit: contain;
}
/* Search highlight */
mark {
background: #ffeb3b;
color: #000;
border-radius: 2px;
padding: 0 1px;
}
@media (prefers-color-scheme: dark) {
mark {
background: #7c6f00;
color: #fff;
}
}
/* Responsive */
@media (max-width: 600px) {
#sidebar { width: 100%; position: absolute; z-index: 10; height: 100%; }
#chat-pane { width: 100%; }
.message { max-width: 85%; }
#sidebar.hidden-mobile { display: none; }
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module whatsapp-viewer
go 1.21

164
main.go Normal file
View File

@ -0,0 +1,164 @@
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)
}

159
parser.go Normal file
View File

@ -0,0 +1,159 @@
package main
import (
"regexp"
"strings"
"time"
)
// Match date/time prefix: M/DD/YY, H:MM[narrow-nbsp]AM/PM -
// The narrow no-break space (\u202f) appears before AM/PM in WhatsApp exports.
var messageStartRe = regexp.MustCompile(`^(\d{1,2}/\d{1,2}/\d{2}, \d{1,2}:\d{2}[\s\x{202f}]*[APap][Mm]) - (.*)`)
// Time layouts to try parsing (with both regular space and narrow no-break space)
var timeLayouts = []string{
"1/2/06, 3:04\u202fPM",
"1/2/06, 3:04 PM",
"1/2/06, 3:04\u202fam",
"1/2/06, 3:04 am",
}
func parseTimestamp(s string) (time.Time, bool) {
// Normalize: replace narrow no-break space with regular space for parsing
normalized := strings.ReplaceAll(s, "\u202f", " ")
// Also try with the original
for _, layout := range []string{"1/2/06, 3:04 PM", "1/2/06, 3:04 pm"} {
t, err := time.Parse(layout, normalized)
if err == nil {
return t, true
}
// Try uppercase
t, err = time.Parse(layout, strings.ToUpper(normalized))
if err == nil {
return t, true
}
}
return time.Time{}, false
}
func classifyAttachment(filename string) string {
lower := strings.ToLower(filename)
if strings.HasPrefix(lower, "stk-") && strings.HasSuffix(lower, ".webp") {
return "sticker"
}
if strings.HasPrefix(lower, "ptt-") {
return "audio"
}
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif"} {
if strings.HasSuffix(lower, ext) {
return "image"
}
}
for _, ext := range []string{".mp4", ".3gp", ".mov"} {
if strings.HasSuffix(lower, ext) {
return "video"
}
}
for _, ext := range []string{".opus", ".ogg", ".m4a", ".mp3", ".aac"} {
if strings.HasSuffix(lower, ext) {
return "audio"
}
}
return "document"
}
func parseChat(content string) []Message {
// Strip UTF-8 BOM
content = strings.TrimPrefix(content, "\xef\xbb\xbf")
// Normalize line endings
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
lines := strings.Split(content, "\n")
var messages []Message
for i := 0; i < len(lines); i++ {
line := lines[i]
if line == "" {
continue
}
m := messageStartRe.FindStringSubmatch(line)
if m == nil {
// Continuation line — append to previous message
if len(messages) > 0 {
prev := &messages[len(messages)-1]
if prev.Text != "" {
prev.Text += "\n"
}
prev.Text += line
// Check if continuation line has attachment
checkAttachment(prev, line)
}
continue
}
timestampStr := m[1]
rest := m[2]
ts, ok := parseTimestamp(timestampStr)
if !ok {
// If we can't parse timestamp, treat as continuation
if len(messages) > 0 {
prev := &messages[len(messages)-1]
if prev.Text != "" {
prev.Text += "\n"
}
prev.Text += line
}
continue
}
msg := Message{
Timestamp: ts,
}
// Check if it's a system message (no colon after sender) or a user message
colonIdx := strings.Index(rest, ": ")
if colonIdx == -1 {
// System message
msg.IsSystem = true
msg.Text = rest
} else {
msg.Sender = rest[:colonIdx]
msg.Text = rest[colonIdx+2:]
}
checkAttachment(&msg, msg.Text)
messages = append(messages, msg)
}
return messages
}
func checkAttachment(msg *Message, text string) {
const suffix = " (file attached)"
// Check each line of text for attachments
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if strings.HasSuffix(line, suffix) {
filename := strings.TrimSuffix(line, suffix)
// Don't add duplicate attachments
found := false
for _, a := range msg.Attachments {
if a.Filename == filename {
found = true
break
}
}
if !found {
msg.Attachments = append(msg.Attachments, Attachment{
Filename: filename,
Type: classifyAttachment(filename),
})
}
// Remove the "(file attached)" text from displayed text
msg.Text = strings.Replace(msg.Text, line, filename, 1)
}
}
}

123
zip.go Normal file
View File

@ -0,0 +1,123 @@
package main
import (
"archive/zip"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func scanDirectory(dir string) (map[string]*Conversation, error) {
conversations := make(map[string]*Conversation)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("reading directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip macOS resource fork files
if strings.HasPrefix(name, "._") {
continue
}
// Accept .zip files or files named "WhatsApp Chat with *" (no extension)
isZip := strings.HasSuffix(strings.ToLower(name), ".zip")
isWhatsApp := strings.HasPrefix(name, "WhatsApp Chat with ")
if !isZip && !isWhatsApp {
continue
}
fullPath := filepath.Join(dir, name)
conv, err := loadConversation(fullPath)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", name, err)
continue
}
conversations[conv.ID] = conv
}
return conversations, nil
}
func makeID(path string) string {
h := sha256.Sum256([]byte(path))
return fmt.Sprintf("%x", h[:8])
}
func loadConversation(zipPath string) (*Conversation, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, fmt.Errorf("opening zip: %w", err)
}
defer r.Close()
var chatContent string
for _, f := range r.File {
if strings.HasSuffix(f.Name, ".txt") {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("opening txt: %w", err)
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("reading txt: %w", err)
}
chatContent = string(data)
break
}
}
if chatContent == "" {
return nil, fmt.Errorf("no .txt file found in zip")
}
// Derive contact name from zip filename
base := filepath.Base(zipPath)
base = strings.TrimSuffix(base, ".zip")
contactName := strings.TrimPrefix(base, "WhatsApp Chat with ")
messages := parseChat(chatContent)
conv := &Conversation{
ID: makeID(zipPath),
Name: contactName,
Messages: messages,
MessageCount: len(messages),
}
if len(messages) > 0 {
conv.LastMessageDate = messages[len(messages)-1].Timestamp
}
return conv, nil
}
func getMediaFromZip(zipPath string, filename string) ([]byte, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer r.Close()
// Case-insensitive matching
lowerFilename := strings.ToLower(filename)
for _, f := range r.File {
if strings.ToLower(f.Name) == lowerFilename {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("file not found in zip: %s", filename)
}