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:
commit
35ac095684
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
whatsapp-viewer
|
||||||
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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 · ${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();
|
||||||
|
|
@ -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">▲</button>
|
||||||
|
<span id="search-pos">0/0</span>
|
||||||
|
<button id="search-next" onclick="searchNext()" title="Next match">▼</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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue