commit 35ac095684948f3db9d2e561b925f2fe5f15ba47 Author: James Date: Mon Mar 23 18:04:30 2026 -0400 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. diff --git a/._WhatsApp Chat with Abraham Pollack b/._WhatsApp Chat with Abraham Pollack new file mode 100644 index 0000000..c658087 Binary files /dev/null and b/._WhatsApp Chat with Abraham Pollack differ diff --git a/._WhatsApp Chat with Alona Dishy b/._WhatsApp Chat with Alona Dishy new file mode 100644 index 0000000..ed4dfc3 Binary files /dev/null and b/._WhatsApp Chat with Alona Dishy differ diff --git a/._WhatsApp Chat with Dr Ibrahim b/._WhatsApp Chat with Dr Ibrahim new file mode 100644 index 0000000..c67b903 Binary files /dev/null and b/._WhatsApp Chat with Dr Ibrahim differ diff --git a/._WhatsApp Chat with Katarzyna NowakWróblewska b/._WhatsApp Chat with Katarzyna NowakWróblewska new file mode 100644 index 0000000..a38b3f7 Binary files /dev/null and b/._WhatsApp Chat with Katarzyna NowakWróblewska differ diff --git a/._WhatsApp Chat with Ma b/._WhatsApp Chat with Ma new file mode 100644 index 0000000..9f92dae Binary files /dev/null and b/._WhatsApp Chat with Ma differ diff --git a/._WhatsApp Chat with Mila b/._WhatsApp Chat with Mila new file mode 100644 index 0000000..0a8c172 Binary files /dev/null and b/._WhatsApp Chat with Mila differ diff --git a/._WhatsApp Chat with Mila & Tanya b/._WhatsApp Chat with Mila & Tanya new file mode 100644 index 0000000..e4f2e29 Binary files /dev/null and b/._WhatsApp Chat with Mila & Tanya differ diff --git a/._WhatsApp Chat with Monica Sanchez b/._WhatsApp Chat with Monica Sanchez new file mode 100644 index 0000000..13c0879 Binary files /dev/null and b/._WhatsApp Chat with Monica Sanchez differ diff --git a/._WhatsApp Chat with Nora Keller b/._WhatsApp Chat with Nora Keller new file mode 100644 index 0000000..189e3e9 Binary files /dev/null and b/._WhatsApp Chat with Nora Keller differ diff --git a/._WhatsApp Chat with Omegaji b/._WhatsApp Chat with Omegaji new file mode 100644 index 0000000..3ea085c Binary files /dev/null and b/._WhatsApp Chat with Omegaji differ diff --git a/._WhatsApp Chat with Oscar Hofman b/._WhatsApp Chat with Oscar Hofman new file mode 100644 index 0000000..bb9cb72 Binary files /dev/null and b/._WhatsApp Chat with Oscar Hofman differ diff --git a/._WhatsApp Chat with Rajat Bishnoi b/._WhatsApp Chat with Rajat Bishnoi new file mode 100644 index 0000000..7a16f47 Binary files /dev/null and b/._WhatsApp Chat with Rajat Bishnoi differ diff --git a/._WhatsApp Chat with Scott b/._WhatsApp Chat with Scott new file mode 100644 index 0000000..86009cf Binary files /dev/null and b/._WhatsApp Chat with Scott differ diff --git a/._WhatsApp Chat with Shokunbi b/._WhatsApp Chat with Shokunbi new file mode 100644 index 0000000..2a6b3e2 Binary files /dev/null and b/._WhatsApp Chat with Shokunbi differ diff --git a/._WhatsApp Chat with Tanya Jongsma b/._WhatsApp Chat with Tanya Jongsma new file mode 100644 index 0000000..db64091 Binary files /dev/null and b/._WhatsApp Chat with Tanya Jongsma differ diff --git a/._WhatsApp Chat with Tanya Moscow b/._WhatsApp Chat with Tanya Moscow new file mode 100644 index 0000000..435c587 Binary files /dev/null and b/._WhatsApp Chat with Tanya Moscow differ diff --git a/._WhatsApp Chat with Valeria Bugaeva b/._WhatsApp Chat with Valeria Bugaeva new file mode 100644 index 0000000..57611f2 Binary files /dev/null and b/._WhatsApp Chat with Valeria Bugaeva differ diff --git a/._WhatsApp Chat with Veronika Busel b/._WhatsApp Chat with Veronika Busel new file mode 100644 index 0000000..6bd4118 Binary files /dev/null and b/._WhatsApp Chat with Veronika Busel differ diff --git a/._WhatsApp Chat with Walter Scott b/._WhatsApp Chat with Walter Scott new file mode 100644 index 0000000..eb49cb4 Binary files /dev/null and b/._WhatsApp Chat with Walter Scott differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..905f0ed --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +whatsapp-viewer diff --git a/WhatsApp Chat with Abraham Pollack b/WhatsApp Chat with Abraham Pollack new file mode 100644 index 0000000..a896d92 Binary files /dev/null and b/WhatsApp Chat with Abraham Pollack differ diff --git a/WhatsApp Chat with Alona Dishy b/WhatsApp Chat with Alona Dishy new file mode 100644 index 0000000..c112b5e Binary files /dev/null and b/WhatsApp Chat with Alona Dishy differ diff --git a/WhatsApp Chat with Dr Ibrahim b/WhatsApp Chat with Dr Ibrahim new file mode 100644 index 0000000..36e98b4 Binary files /dev/null and b/WhatsApp Chat with Dr Ibrahim differ diff --git a/WhatsApp Chat with Katarzyna NowakWróblewska b/WhatsApp Chat with Katarzyna NowakWróblewska new file mode 100644 index 0000000..9233f08 Binary files /dev/null and b/WhatsApp Chat with Katarzyna NowakWróblewska differ diff --git a/WhatsApp Chat with Ma b/WhatsApp Chat with Ma new file mode 100644 index 0000000..b47837c Binary files /dev/null and b/WhatsApp Chat with Ma differ diff --git a/WhatsApp Chat with Mila b/WhatsApp Chat with Mila new file mode 100644 index 0000000..668fe67 Binary files /dev/null and b/WhatsApp Chat with Mila differ diff --git a/WhatsApp Chat with Mila & Tanya b/WhatsApp Chat with Mila & Tanya new file mode 100644 index 0000000..91dc3c8 Binary files /dev/null and b/WhatsApp Chat with Mila & Tanya differ diff --git a/WhatsApp Chat with Monica Sanchez b/WhatsApp Chat with Monica Sanchez new file mode 100644 index 0000000..2277fa6 Binary files /dev/null and b/WhatsApp Chat with Monica Sanchez differ diff --git a/WhatsApp Chat with Nora Keller b/WhatsApp Chat with Nora Keller new file mode 100644 index 0000000..990cd2a Binary files /dev/null and b/WhatsApp Chat with Nora Keller differ diff --git a/WhatsApp Chat with Omegaji b/WhatsApp Chat with Omegaji new file mode 100644 index 0000000..0b4af85 Binary files /dev/null and b/WhatsApp Chat with Omegaji differ diff --git a/WhatsApp Chat with Oscar Hofman b/WhatsApp Chat with Oscar Hofman new file mode 100644 index 0000000..a76aa5a Binary files /dev/null and b/WhatsApp Chat with Oscar Hofman differ diff --git a/WhatsApp Chat with Rajat Bishnoi b/WhatsApp Chat with Rajat Bishnoi new file mode 100644 index 0000000..f1f1591 Binary files /dev/null and b/WhatsApp Chat with Rajat Bishnoi differ diff --git a/WhatsApp Chat with Scott b/WhatsApp Chat with Scott new file mode 100644 index 0000000..0fab644 Binary files /dev/null and b/WhatsApp Chat with Scott differ diff --git a/WhatsApp Chat with Shokunbi b/WhatsApp Chat with Shokunbi new file mode 100644 index 0000000..bc5a5d3 Binary files /dev/null and b/WhatsApp Chat with Shokunbi differ diff --git a/WhatsApp Chat with Tanya Jongsma b/WhatsApp Chat with Tanya Jongsma new file mode 100644 index 0000000..02e8d45 Binary files /dev/null and b/WhatsApp Chat with Tanya Jongsma differ diff --git a/WhatsApp Chat with Tanya Moscow b/WhatsApp Chat with Tanya Moscow new file mode 100644 index 0000000..9495cc6 Binary files /dev/null and b/WhatsApp Chat with Tanya Moscow differ diff --git a/WhatsApp Chat with Valeria Bugaeva b/WhatsApp Chat with Valeria Bugaeva new file mode 100644 index 0000000..697a9e0 Binary files /dev/null and b/WhatsApp Chat with Valeria Bugaeva differ diff --git a/WhatsApp Chat with Veronika Busel b/WhatsApp Chat with Veronika Busel new file mode 100644 index 0000000..27591c5 Binary files /dev/null and b/WhatsApp Chat with Veronika Busel differ diff --git a/WhatsApp Chat with Walter Scott b/WhatsApp Chat with Walter Scott new file mode 100644 index 0000000..1346f0b Binary files /dev/null and b/WhatsApp Chat with Walter Scott differ diff --git a/conversation.go b/conversation.go new file mode 100644 index 0000000..c20390c --- /dev/null +++ b/conversation.go @@ -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"` +} diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..5e6b82d --- /dev/null +++ b/frontend/app.js @@ -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 = ` +
${esc(c.name)}
+
${c.messageCount} messages · ${date}
+ `; + 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 += `
${dateStr}
`; + lastDate = dateStr; + } + + const isMatch = searchTerm && (m.text || "").toLowerCase().includes(searchTerm); + if (isMatch && firstMatchIdx === -1) firstMatchIdx = msgIdx; + + if (m.isSystem) { + html += `
${highlight(esc(m.text), searchTerm)}
`; + 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 = `
${esc(m.sender)}
`; + } + + 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 += `${esc(a.filename)}`; + break; + case "sticker": + attachHtml += `sticker`; + break; + case "video": + attachHtml += ``; + break; + case "audio": + attachHtml += ``; + break; + default: + attachHtml += `${esc(a.filename)}`; + } + } + } + + // 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 = `
${highlight(esc(displayText), searchTerm)}
`; + } + + html += `
+ ${senderHtml}${attachHtml}${textHtml} +
${timeStr}
+
`; + 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, "$1"); +} + +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(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a781017 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,40 @@ + + + + + + WhatsApp Viewer + + + +
+ +
+
+ Select a conversation + +
+
+ +
+
+ + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..bb187c3 --- /dev/null +++ b/frontend/style.css @@ -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; } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba1434d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module whatsapp-viewer + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 0000000..9c56bcd --- /dev/null +++ b/main.go @@ -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] \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) +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..e87f0dd --- /dev/null +++ b/parser.go @@ -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) + } + } +} diff --git a/zip.go b/zip.go new file mode 100644 index 0000000..15fd176 --- /dev/null +++ b/zip.go @@ -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) +}