/* global fetch */ const SYSTEM_SUBTYPES = new Set([ "channel_join", "channel_leave", "channel_topic", "channel_purpose", "channel_name", "channel_archive", "channel_unarchive", "group_join", "group_leave", ]); const state = { tab: "channels", channels: [], users: [], selectedChannel: null, selectedUser: null, selectedMessageId: null, focusMessageId: null, channelManifest: {}, userManifest: {}, globalNotes: "", allMessages: [], messageCache: {}, saveTimer: null, globalSearchTimer: null, navCooldown: false, }; const els = { stats: document.getElementById("stats"), globalSearch: document.getElementById("global-search"), globalSearchMode: document.getElementById("global-search-mode"), globalFileType: document.getElementById("global-file-type"), globalSearchResults: document.getElementById("global-search-results"), sidebarSearch: document.getElementById("sidebar-search"), filterStatus: document.getElementById("filter-status"), filterHideArchived: document.getElementById("filter-hide-archived"), reviewProgress: document.getElementById("review-progress"), sidebarList: document.getElementById("sidebar-list"), channelTitle: document.getElementById("channel-title"), channelMeta: document.getElementById("channel-meta"), importModePick: document.getElementById("import-mode-pick"), messageSearch: document.getElementById("message-search"), filterAttachmentType: document.getElementById("filter-attachment-type"), btnSelectImages: document.getElementById("btn-select-images"), btnSelectFiles: document.getElementById("btn-select-files"), filterHideBots: document.getElementById("filter-hide-bots"), filterHideSystem: document.getElementById("filter-hide-system"), filterOnlySelected: document.getElementById("filter-only-selected"), filterCount: document.getElementById("filter-count"), messagesWrap: document.querySelector(".messages-wrap"), messages: document.getElementById("messages"), selectionSummary: document.getElementById("selection-summary"), messageDetail: document.getElementById("message-detail"), msgPreview: document.getElementById("msg-preview"), msgAction: document.getElementById("msg-action"), msgMergeTarget: document.getElementById("msg-merge-target"), msgNotes: document.getElementById("msg-notes"), fileToggles: document.getElementById("file-toggles"), btnThreadAll: document.getElementById("btn-thread-all"), mattermostChannel: document.getElementById("mattermost-channel"), mattermostTeam: document.getElementById("mattermost-team"), itemNotes: document.getElementById("item-notes"), globalNotes: document.getElementById("global-notes"), saveStatus: document.getElementById("save-status"), btnSave: document.getElementById("btn-save"), btnImport: document.getElementById("btn-import"), btnSkip: document.getElementById("btn-skip"), btnNext: document.getElementById("btn-next"), btnPrev: document.getElementById("btn-prev"), btnMsgNext: document.getElementById("btn-msg-next"), btnMsgPrev: document.getElementById("btn-msg-prev"), msgNavStatus: document.getElementById("msg-nav-status"), }; async function api(path, options = {}) { const res = await fetch(path, options); if (!res.ok) { const text = await res.text(); const brief = text.length > 200 || text.includes("", ">"); } function highlightHtml(text, query) { const safe = escapeHtml(text); if (!query) return safe; const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); return safe.replace(re, "$1"); } function fileUploadUrl(file) { if (!file?.local_relpath) return null; return `/uploads/${encodeURI(file.local_relpath)}`; } function isImageFile(file) { const mt = (file.mimetype || "").toLowerCase(); if (mt.startsWith("image/")) return true; const ext = (file.name || "").split(".").pop()?.toLowerCase() || ""; return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"].includes(ext); } function fileKind(file) { const mt = (file.mimetype || "").toLowerCase(); const name = (file.name || "").toLowerCase(); const ext = name.includes(".") ? name.split(".").pop() : ""; if (isImageFile(file)) return "image"; if (ext === "pdf" || mt === "application/pdf") return "pdf"; if (["doc", "docx"].includes(ext) || mt.includes("word")) return "doc"; if (["xls", "xlsx", "csv"].includes(ext) || mt.includes("spreadsheet") || mt.includes("excel")) return "spreadsheet"; return "other"; } function messageKinds(msg) { return new Set((msg.files || []).map(fileKind)); } function fileExtLabel(name) { const ext = (name || "").split(".").pop()?.toUpperCase() || ""; if (!ext || ext.length > 6) return "FILE"; return ext; } function isZipBuffer(bytes) { return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b; } function looksLikeHtml(text) { const t = text.trim().slice(0, 800).toLowerCase(); return ( t.includes("quip-canvas-content") || t.startsWith(" { const url = fileUploadUrl(f); const dl = fileDownloadUrl(f); const name = escapeHtml(f.name || "file"); const fid = f.id ? escapeHtml(f.id) : ""; const previewAttrs = fid ? `data-preview-file-id="${fid}"` : url ? `data-preview-url="${escapeHtml(url)}"` : ""; if (isImageFile(f) && (url || fid)) { const imgSrc = f.local_exists && url ? escapeHtml(url) : dl; return ``; } if (fid || url) { const cloud = f.local_exists === false ? '☁' : ""; return ``; } return `${name}`; }); return `
${items.join("")}
`; } function ensurePreviewModal() { let modal = document.getElementById("preview-modal"); if (modal) return modal; modal = document.createElement("div"); modal.id = "preview-modal"; modal.className = "preview-modal hidden"; modal.innerHTML = `
Download original
`; document.body.appendChild(modal); modal.addEventListener("click", (e) => { if (e.target.closest("[data-close]")) closePreview(); }); return modal; } function setPreviewMode(modal, mode) { modal.querySelector(".preview-loading").classList.toggle("hidden", mode !== "loading"); modal.querySelector(".preview-error").classList.toggle("hidden", mode !== "error"); modal.querySelector(".preview-image-wrap").classList.toggle("hidden", mode !== "image"); modal.querySelector(".preview-doc-wrap").classList.toggle("hidden", mode !== "doc"); } function previewRelFromUploadUrl(url) { try { const u = new URL(url, window.location.origin); return decodeURIComponent(u.pathname.replace(/^\/uploads\//, "")); } catch { return null; } } async function fetchServerPreview({ fileId, rel }) { const q = fileId ? `file_id=${encodeURIComponent(fileId)}` : `rel=${encodeURIComponent(rel)}`; const data = await api(`/api/file-preview?${q}`); if (!data.ok) throw new Error(data.error || "Preview failed"); return data.html || ""; } async function renderDocPreview(url, name, fileId) { const modal = ensurePreviewModal(); setPreviewMode(modal, "loading"); modal.querySelector(".preview-error").textContent = ""; modal.querySelector(".preview-doc-scroll").innerHTML = ""; if (fileId) { try { const serverHtml = await fetchServerPreview({ fileId }); modal.querySelector(".preview-doc-scroll").innerHTML = serverHtml; return; } catch { url = `/api/file-download?file_id=${encodeURIComponent(fileId)}`; } } const rel = previewRelFromUploadUrl(url); if (rel) { try { const serverHtml = await fetchServerPreview({ rel }); modal.querySelector(".preview-doc-scroll").innerHTML = serverHtml; return; } catch { /* fall through to direct file fetch */ } } const res = await fetch(url); if (!res.ok) throw new Error(`Could not load file (${res.status})`); const buf = await res.arrayBuffer(); const bytes = new Uint8Array(buf); if (isZipBuffer(bytes)) { if (typeof mammoth !== "undefined") { const result = await mammoth.convertToHtml({ arrayBuffer: buf }); const docHtml = result.value || "

Empty document

"; const warnings = result.messages?.length ? `

${escapeHtml( result.messages.map((m) => m.message).join("; ") )}

` : ""; modal.querySelector(".preview-doc-scroll").innerHTML = `
${docHtml}
${warnings}`; return; } if (rel) { const serverHtml = await fetchServerPreview({ rel }); modal.querySelector(".preview-doc-scroll").innerHTML = serverHtml; return; } throw new Error("DOCX preview unavailable."); } const text = new TextDecoder("utf-8", { fatal: false }).decode(buf); if (looksLikeHtml(text)) { modal.querySelector(".preview-doc-scroll").innerHTML = extractPreviewHtml(text); return; } const trimmed = text.trim(); if (trimmed && /^[\x09\x0a\x0d\x20-\x7e\u00a0-\ufffd]*$/.test(trimmed.slice(0, 2000))) { modal.querySelector(".preview-doc-scroll").innerHTML = `
${escapeHtml(
      text
    )}
`; return; } throw new Error("Binary file — use Download original."); } function openFilePreview(url, name, { asImage = false, fileId = "", downloadUrl = "" } = {}) { const modal = ensurePreviewModal(); modal.querySelector(".preview-caption").textContent = name || ""; modal.querySelector(".preview-download").href = downloadUrl || (fileId ? `/api/file-download?file_id=${encodeURIComponent(fileId)}` : url); modal.classList.remove("hidden"); document.body.classList.add("preview-open"); if (asImage) { setPreviewMode(modal, "image"); const img = modal.querySelector(".preview-image"); const imgUrl = downloadUrl || (fileId ? `/api/file-download?file_id=${encodeURIComponent(fileId)}` : url); img.src = imgUrl; img.alt = name || ""; return; } renderDocPreview(url, name, fileId) .then(() => setPreviewMode(modal, "doc")) .catch((err) => { setPreviewMode(modal, "error"); modal.querySelector(".preview-error").textContent = err.message || String(err); }); } function closePreview() { const modal = document.getElementById("preview-modal"); if (!modal) return; modal.classList.add("hidden"); modal.querySelector(".preview-image").src = ""; modal.querySelector(".preview-doc-scroll").innerHTML = ""; document.body.classList.remove("preview-open"); } function channelEntry(name) { if (!state.channelManifest[name]) { state.channelManifest[name] = { import: false, status: "undecided", reviewed: false, import_mode: "all", notes: "", mattermost_channel: "", mattermost_team: "", messages: {}, }; } if (!state.channelManifest[name].messages) { state.channelManifest[name].messages = {}; } return state.channelManifest[name]; } function msgEntry(channelName, msgId) { const ch = channelEntry(channelName); if (!ch.messages[msgId]) { ch.messages[msgId] = { action: "inherit", merge_target: "", notes: "", files: {} }; } if (!ch.messages[msgId].files) ch.messages[msgId].files = {}; return ch.messages[msgId]; } function userEntry(id) { if (!state.userManifest[id]) { state.userManifest[id] = { import: false, notes: "", mattermost_username: "" }; } return state.userManifest[id]; } function channelUsesCherryPick(ch) { return ch.import_mode === "pick" || ch.status === "partial"; } function effectiveMsgAction(channelName, msg) { const ch = channelEntry(channelName); const sel = ch.messages[msg.id]; const explicit = sel?.action; if (explicit === "import" || explicit === "merge" || explicit === "skip") { return explicit; } if (channelUsesCherryPick(ch)) { return "skip"; } if (ch.status === "import") return "import"; if (ch.status === "skip") return "skip"; return "skip"; } function clearMsgSelection(channelName, msgId) { const ch = channelEntry(channelName); if (ch.messages?.[msgId]) { delete ch.messages[msgId]; } } function setMsgImport(channelName, msg, on) { if (!on) { clearMsgSelection(channelName, msg.id); return; } ensureCherryPick(); const sel = msgEntry(channelName, msg.id); sel.action = "import"; sel.checked = true; sel.merge_target = ""; } function isMsgSelected(channelName, msg) { const action = effectiveMsgAction(channelName, msg); return action === "import" || action === "merge"; } function setNavCooldown(ms = 550) { state.navCooldown = true; els.btnImport.classList.add("cooldown"); els.btnSkip.classList.add("cooldown"); setTimeout(() => { state.navCooldown = false; els.btnImport.classList.remove("cooldown"); els.btnSkip.classList.remove("cooldown"); updateTriageBar(); }, ms); } function setDecideButtonsEnabled(on) { els.btnImport.disabled = !on || state.navCooldown; els.btnSkip.disabled = !on || state.navCooldown; } function reviewedCount() { return state.channels.filter((c) => channelEntry(c.name).reviewed).length; } function channelPickCount(name) { const msgs = channelEntry(name).messages || {}; return Object.values(msgs).filter( (m) => m.action === "import" || m.action === "merge" ).length; } function channelHasPicks(name) { const entry = channelEntry(name); return ( entry.status === "partial" || entry.import_mode === "pick" || channelPickCount(name) > 0 ); } function visibleChannelList() { const q = els.sidebarSearch.value.trim().toLowerCase(); const statusFilter = els.filterStatus.value; const hideArchived = els.filterHideArchived.checked; return state.channels.filter((ch) => { const entry = channelEntry(ch.name); const status = entry.status === "partial" ? "partial" : entry.status; if (hideArchived && ch.is_archived) return false; if (statusFilter === "partial") { if (!channelHasPicks(ch.name)) return false; } else if (statusFilter !== "all" && status !== statusFilter) return false; if (!q) return true; return ( ch.name.toLowerCase().includes(q) || (ch.purpose || "").toLowerCase().includes(q) ); }); } function channelIndex(name) { return visibleChannelList().findIndex((c) => c.name === name); } function countSelections(channelName) { const ch = channelEntry(channelName); let imp = 0; let merge = 0; let skip = 0; for (const msg of state.allMessages) { const a = effectiveMsgAction(channelName, msg); if (a === "import") imp += 1; else if (a === "merge") merge += 1; else skip += 1; } return { imp, merge, skip }; } function renderProgress() { const reviewed = reviewedCount(); const total = state.channels.length; const imported = state.channels.filter((c) => { const e = channelEntry(c.name); return e.status === "import" || e.status === "partial"; }).length; els.reviewProgress.innerHTML = ` ${reviewed}/${total} reviewed · ${imported} import/pick · ${total - reviewed} left `; } function renderStats(summary) { els.stats.innerHTML = ` ${formatNumber(summary.channel_count)} ch ${formatNumber(summary.total_messages)} msgs ${reviewedCount()} done `; } function renderSidebar() { els.sidebarList.innerHTML = ""; renderProgress(); if (state.tab === "channels") { const items = visibleChannelList(); if (!items.length) { els.sidebarList.innerHTML = '
Nothing matches filters.
'; return; } for (const ch of items) { const entry = channelEntry(ch.name); const dotStatus = entry.status === "partial" ? "partial" : entry.status || "undecided"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "list-item" + (state.selectedChannel === ch.name ? " active" : ""); const picks = channelPickCount(ch.name); const mode = entry.import_mode === "pick" || picks > 0 ? picks > 0 ? ` · ${formatNumber(picks)} picked` : " · cherry-pick" : ""; btn.innerHTML = `
#${escapeHtml(ch.name)}
${formatNumber(ch.message_count)} msgs${mode}
`; btn.addEventListener("click", () => selectChannel(ch.name)); els.sidebarList.appendChild(btn); } return; } const q = els.sidebarSearch.value.trim().toLowerCase(); for (const user of state.users.filter((u) => { if (!q) return true; return ( (u.name || "").toLowerCase().includes(q) || (u.real_name || "").toLowerCase().includes(q) ); })) { const entry = userEntry(user.id); const btn = document.createElement("button"); btn.type = "button"; btn.className = "list-item" + (state.selectedUser === user.id ? " active" : ""); btn.innerHTML = `
${escapeHtml(user.real_name || user.name)}
@${escapeHtml(user.name || "?")}
`; btn.addEventListener("click", () => selectUser(user)); els.sidebarList.appendChild(btn); } } function renderSelectionSummary() { if (!state.selectedChannel) { els.selectionSummary.innerHTML = '

Cherry-pick mode: check messages or use the panel to merge individual posts, threads, and files into another channel.

'; return; } const ch = channelEntry(state.selectedChannel); const { imp, merge, skip } = countSelections(state.selectedChannel); const mode = ch.import_mode === "pick" ? "Cherry-pick ON — only checked/merged messages import." : "Whole channel — Import/Skip applies to everything unless you cherry-pick."; els.selectionSummary.innerHTML = `

${imp} import · ${merge} merge · ${skip} skip

${mode}

`; } function syncNotesPanel() { if (!state.selectedChannel) return; const entry = channelEntry(state.selectedChannel); els.mattermostChannel.value = entry.mattermost_channel || ""; els.mattermostTeam.value = entry.mattermost_team || ""; els.itemNotes.value = entry.notes || ""; els.importModePick.checked = entry.import_mode === "pick"; } function persistNotes() { if (!state.selectedChannel) return; const entry = channelEntry(state.selectedChannel); entry.mattermost_channel = els.mattermostChannel.value.trim(); entry.mattermost_team = els.mattermostTeam.value.trim(); entry.notes = els.itemNotes.value; scheduleAutosave(); } function setChannelDecision(name, status) { const entry = channelEntry(name); entry.status = status; entry.import = status === "import" || status === "partial"; entry.reviewed = true; if (status === "import" && entry.import_mode === "pick") { entry.status = "partial"; } const ch = state.channels.find((c) => c.name === name); if (ch) { ch.status = entry.status; ch.reviewed = true; } renderSidebar(); renderSelectionSummary(); updateTriageBar(); scheduleAutosave(); } function visibleMessages() { return state.allMessages.filter(messagePassesFilters); } function ensureCherryPick() { if (!state.selectedChannel) return; const entry = channelEntry(state.selectedChannel); entry.import_mode = "pick"; entry.status = "partial"; els.importModePick.checked = true; } function setMessageAction(msg, action, { advance = false } = {}) { if (!state.selectedChannel || !msg) return; if (action === "import" || action === "merge") { ensureCherryPick(); const sel = msgEntry(state.selectedChannel, msg.id); sel.action = action; sel.checked = true; if (action !== "merge") sel.merge_target = ""; } else { clearMsgSelection(state.selectedChannel, msg.id); } renderAllMessages(); renderSidebar(); updateTriageBar(); scheduleAutosave(); if (advance) { const vis = visibleMessages(); const idx = vis.findIndex((m) => m.id === msg.id); if (idx >= 0 && idx < vis.length - 1) { focusMessage(vis[idx + 1].id); } else { updateMessageNavStatus(); } } } function toggleFocusedImport() { const msg = state.allMessages.find((m) => m.id === state.focusMessageId); if (!msg || !state.selectedChannel) return; const sel = msgEntry(state.selectedChannel, msg.id); const currentlyOn = isMsgSelected(state.selectedChannel, msg); setMessageAction(msg, currentlyOn ? "skip" : "import", { advance: false }); } function focusMessage(msgId, { scroll = true } = {}) { if (!msgId) return; state.focusMessageId = msgId; state.selectedMessageId = msgId; document.querySelectorAll(".message.focused").forEach((el) => { el.classList.remove("focused"); }); const row = els.messages.querySelector( `[data-id="${msgId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]` ); if (row) { row.classList.add("focused"); if (scroll) row.scrollIntoView({ block: "nearest", behavior: "smooth" }); } updateMessageNavStatus(); const msg = state.allMessages.find((m) => m.id === msgId); if (msg) { els.msgPreview.textContent = `${msg.user_name}: ${(msg.text || "[no text]").slice(0, 120)}`; els.messageDetail.classList.remove("hidden"); const sel = msgEntry(state.selectedChannel, msg.id); els.msgAction.value = sel.action && sel.action !== "inherit" ? sel.action : "inherit"; els.msgMergeTarget.value = sel.merge_target || ""; els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge"); els.msgNotes.value = sel.notes || ""; } } function updateMessageNavStatus() { const vis = visibleMessages(); if (!vis.length) { els.msgNavStatus.textContent = "—"; return; } const idx = vis.findIndex((m) => m.id === state.focusMessageId); const pos = idx >= 0 ? idx + 1 : 0; els.msgNavStatus.textContent = pos ? `${pos} / ${vis.length}` : `— / ${vis.length}`; } function goNextMessage() { const vis = visibleMessages(); if (!vis.length) return; let idx = vis.findIndex((m) => m.id === state.focusMessageId); if (idx < 0) idx = -1; const next = vis[Math.min(idx + 1, vis.length - 1)]; focusMessage(next.id); } function goPrevMessage() { const vis = visibleMessages(); if (!vis.length) return; let idx = vis.findIndex((m) => m.id === state.focusMessageId); if (idx < 0) idx = vis.length; const prev = vis[Math.max(idx - 1, 0)]; focusMessage(prev.id); } function focusFirstMessage() { const vis = visibleMessages(); if (vis.length) focusMessage(vis[0].id, { scroll: false }); else state.focusMessageId = null; } function messagePassesFilters(msg) { if (els.filterHideBots.checked && msg.is_bot) return false; if (els.filterHideSystem.checked) { if (msg.subtype && SYSTEM_SUBTYPES.has(msg.subtype)) return false; if (!msg.text && !msg.files?.length) return false; } const q = els.messageSearch.value.trim().toLowerCase(); if (q) { const hay = `${msg.user_name} ${msg.text}`.toLowerCase(); if (!hay.includes(q)) return false; } if (els.filterOnlySelected.checked && state.selectedChannel) { if (!isMsgSelected(state.selectedChannel, msg)) return false; } const att = els.filterAttachmentType?.value || "all"; if (att !== "all") { const kinds = messageKinds(msg); if (att === "any") { if (!msg.files?.length) return false; } else if (!kinds.has(att)) { return false; } } return true; } function messageRowHtml(msg, q) { const ch = state.selectedChannel; const action = effectiveMsgAction(ch, msg); const sel = msgEntry(ch, msg.id); const checked = action === "import" || action === "merge"; const classes = ["message"]; if (msg.is_thread_reply) classes.push("thread-reply"); if (msg.is_bot) classes.push("bot"); if (msg.subtype && SYSTEM_SUBTYPES.has(msg.subtype)) classes.push("system"); if (msg.id === state.selectedMessageId) classes.push("selected"); if (msg.id === state.focusMessageId) classes.push("focused"); if (action === "import") classes.push("sel-import"); if (action === "skip") classes.push("sel-skip"); if (action === "merge") classes.push("sel-merge"); const badges = []; if (action === "merge" && sel.merge_target) { badges.push(`→ ${escapeHtml(sel.merge_target)}`); } if (msg.is_thread_parent || msg.thread_reply_count > 0) { badges.push( `${msg.thread_reply_count || msg.reply_count || 0} replies` ); } if (msg.files?.length) { badges.push(`${msg.files.length} file(s)`); } const files = renderMessageFiles(msg); return `
${escapeHtml(msg.user_name)} ${escapeHtml((msg.time || "").slice(0, 16).replace("T", " "))}
${highlightHtml(msg.text || "", q)}
${files} ${badges.length ? `
${badges.join("")}
` : ""}
`; } function renderAllMessages() { const q = els.messageSearch.value.trim(); let shown = 0; let lastDate = ""; const parts = []; for (const msg of state.allMessages) { if (!messagePassesFilters(msg)) continue; shown += 1; const day = (msg.time || "").slice(0, 10); if (day && day !== lastDate) { lastDate = day; parts.push(`
${escapeHtml(day)}
`); } parts.push(messageRowHtml(msg, q)); } if (!state.allMessages.length) { els.messages.innerHTML = '
No messages.
'; } else if (!shown) { els.messages.innerHTML = '
No messages match filters.
'; } else { els.messages.innerHTML = parts.join(""); } els.filterCount.textContent = `${shown} / ${state.allMessages.length} shown`; renderSelectionSummary(); updateMessageNavStatus(); updateBulkSelectButtons(); if (state.focusMessageId) { const row = els.messages.querySelector( `[data-id="${state.focusMessageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]` ); row?.classList.add("focused"); } } function openMessageDetail(msg) { state.selectedMessageId = msg.id; const ch = state.selectedChannel; const saved = channelEntry(ch).messages[msg.id]; const isOn = isMsgSelected(ch, msg); els.messageDetail.classList.remove("hidden"); els.msgPreview.textContent = `${msg.user_name}: ${(msg.text || "[no text]").slice(0, 200)}`; els.msgAction.value = saved?.action && saved.action !== "inherit" ? saved.action : isOn ? "import" : "inherit"; els.msgMergeTarget.value = saved?.merge_target || ""; els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge"); els.msgNotes.value = saved?.notes || ""; if (msg.is_thread_parent || msg.thread_reply_count > 0) { els.btnThreadAll.classList.remove("hidden"); const n = msg.thread_reply_count || msg.reply_count || 0; els.btnThreadAll.textContent = `Import whole thread (${n} replies)`; els.btnThreadAll.onclick = () => applyThreadImport(msg, "import"); } else if (msg.is_thread_reply) { els.btnThreadAll.classList.remove("hidden"); els.btnThreadAll.textContent = "Import whole thread"; els.btnThreadAll.onclick = () => { const root = state.allMessages.find( (m) => String(m.ts) === String(msg.thread_root_ts) ); if (root) applyThreadImport(root, "import"); }; } else { els.btnThreadAll.classList.add("hidden"); } els.fileToggles.innerHTML = ""; if (msg.files?.length) { els.fileToggles.innerHTML = ""; for (const f of msg.files) { const fid = f.id || f.name; const on = isOn && saved?.files?.[fid] !== false; const url = fileUploadUrl(f); const row = document.createElement("div"); row.className = "file-toggle-row"; const openLink = f.id || url ? `` : ""; row.innerHTML = `${openLink}`; els.fileToggles.appendChild(row); } } renderAllMessages(); } function applyThreadImport(rootMsg, action) { const ch = state.selectedChannel; const rootTs = String(rootMsg.ts); for (const msg of state.allMessages) { if ( String(msg.ts) === rootTs || String(msg.thread_root_ts) === rootTs ) { if (action === "import" || action === "merge") { const sel = msgEntry(ch, msg.id); sel.action = action; sel.checked = true; if (action !== "merge") sel.merge_target = ""; } else { clearMsgSelection(ch, msg.id); } } } if (channelEntry(ch).import_mode !== "pick") { channelEntry(ch).import_mode = "pick"; els.importModePick.checked = true; channelEntry(ch).status = "partial"; } renderAllMessages(); scheduleAutosave(); } function applyMessageDetailFromForm() { if (!state.selectedChannel || !state.selectedMessageId) return; const action = els.msgAction.value; const msg = state.allMessages.find((m) => m.id === state.selectedMessageId); if (!msg) return; if (action === "skip" || action === "inherit") { clearMsgSelection(state.selectedChannel, state.selectedMessageId); } else { ensureCherryPick(); const sel = msgEntry(state.selectedChannel, state.selectedMessageId); sel.action = action; sel.checked = action === "import" || action === "merge"; sel.merge_target = action === "merge" ? els.msgMergeTarget.value.trim() : ""; sel.notes = els.msgNotes.value; els.fileToggles.querySelectorAll("[data-file-id]").forEach((inp) => { if (!sel.files) sel.files = {}; sel.files[inp.dataset.fileId] = inp.checked; }); } renderAllMessages(); renderSidebar(); updateTriageBar(); scheduleAutosave(); } async function loadChannelMessages(name) { if (state.messageCache[name]) { state.allMessages = state.messageCache[name]; return; } els.messagesWrap.classList.add("loading"); const data = await api( `/api/channels/${encodeURIComponent(name)}/messages/all` ); state.messageCache[name] = data.messages; state.allMessages = data.messages; els.messagesWrap.classList.remove("loading"); } function updateTriageBar() { const ch = state.channels.find((c) => c.name === state.selectedChannel); if (!ch) return; const entry = channelEntry(ch.name); const idx = channelIndex(ch.name); const list = visibleChannelList(); const { imp, merge } = countSelections(ch.name); els.channelTitle.textContent = `#${ch.name}`; const statusLabel = entry.status === "partial" ? `Cherry-pick (${imp} import, ${merge} merge)` : entry.status === "import" ? "Import whole channel" : entry.status === "skip" ? "Skip channel" : "Undecided"; els.channelMeta.textContent = `${formatNumber(ch.message_count)} msgs · ${statusLabel} · ${idx + 1}/${list.length}`; els.btnImport.classList.toggle("active", entry.status === "import"); els.btnSkip.classList.toggle("active", entry.status === "skip"); setDecideButtonsEnabled(true); } async function selectChannel(name, { scrollTop = true } = {}) { persistNotes(); state.selectedChannel = name; state.selectedUser = null; state.selectedMessageId = null; state.focusMessageId = null; els.messageDetail.classList.add("hidden"); renderSidebar(); syncNotesPanel(); setDecideButtonsEnabled(false); els.channelMeta.textContent = "Loading messages…"; try { await loadChannelMessages(name); renderAllMessages(); updateTriageBar(); if (scrollTop) { els.messages.scrollTop = 0; focusFirstMessage(); } } catch (err) { els.messages.innerHTML = `
${escapeHtml(err.message)}
`; updateTriageBar(); } } function selectUser(user) { state.selectedUser = user.id; state.selectedChannel = null; state.allMessages = []; els.channelTitle.textContent = user.real_name || user.name; els.channelMeta.textContent = `@${user.name}`; els.messages.innerHTML = '
User mapping only — use Channels for messages.
'; setDecideButtonsEnabled(false); renderSidebar(); } function goNextChannel() { const list = visibleChannelList(); if (!list.length) return; const idx = channelIndex(state.selectedChannel); setNavCooldown(); selectChannel(list[(idx + 1) % list.length].name); } function goPrevChannel() { const list = visibleChannelList(); if (!list.length) return; const idx = channelIndex(state.selectedChannel); setNavCooldown(); selectChannel(list[(idx - 1 + list.length) % list.length].name); } function goNextUndecided() { const list = visibleChannelList(); const start = Math.max(0, channelIndex(state.selectedChannel)); for (let i = 1; i <= list.length; i++) { const ch = list[(start + i) % list.length]; if (!channelEntry(ch.name).reviewed) { setNavCooldown(); selectChannel(ch.name); return; } } goNextChannel(); } function importChannel() { if (!state.selectedChannel || state.navCooldown) return; const entry = channelEntry(state.selectedChannel); if (entry.import_mode === "pick") { setChannelDecision(state.selectedChannel, "partial"); } else { setChannelDecision(state.selectedChannel, "import"); } persistNotes(); } function skipChannel() { if (!state.selectedChannel || state.navCooldown) return; setChannelDecision(state.selectedChannel, "skip"); persistNotes(); } function syncGlobalSearchModeUi() { const filesMode = els.globalSearchMode.value === "files"; els.globalFileType.classList.toggle("hidden", !filesMode); els.globalSearch.placeholder = filesMode ? "Search attachments in all channels… (optional keywords)" : "Search channels & messages… (press /)"; } async function jumpToSearchHit(ch, msgId) { els.globalSearchResults.classList.add("hidden"); els.globalSearch.value = ""; await selectChannel(ch); if (msgId) { const msg = state.allMessages.find((m) => m.id === msgId); if (msg) { requestAnimationFrame(() => { focusMessage(msg.id); openMessageDetail(msg); document .querySelector( `[data-id="${msgId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]` ) ?.scrollIntoView({ block: "center" }); }); } } } async function runGlobalSearch(query) { const filesMode = els.globalSearchMode.value === "files"; if (!filesMode && !query.trim()) { els.globalSearchResults.classList.add("hidden"); return; } const parts = []; if (filesMode) { const ft = els.globalFileType.value || "all"; const data = await api( `/api/search-files?q=${encodeURIComponent(query)}&type=${encodeURIComponent(ft)}&limit=60` ); if (data.hits?.length) { const label = ft === "all" ? "Attachments" : `${ft} files`; parts.push(`

${escapeHtml(label)}

`); for (const m of data.hits.slice(0, 25)) { const names = (m.matching_files || m.files || []) .map((f) => f.name) .filter(Boolean) .slice(0, 2) .join(", "); parts.push( `` ); } parts.push("
"); } } else { const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`); if (data.channels?.length) { parts.push('

Channels

'); for (const ch of data.channels.slice(0, 12)) { parts.push( `` ); } parts.push("
"); } if (data.messages?.length) { parts.push('

Messages

'); for (const m of data.messages.slice(0, 20)) { parts.push( `` ); } parts.push("
"); } } els.globalSearchResults.innerHTML = parts.join("") || '
No results
'; els.globalSearchResults.classList.remove("hidden"); els.globalSearchResults.querySelectorAll(".search-hit").forEach((btn) => { btn.addEventListener("click", () => jumpToSearchHit(btn.dataset.channel, btn.dataset.msg)); }); } function visibleFileMessages(imagesOnly) { return visibleMessages().filter((msg) => { const files = msg.files || []; if (!files.length) return false; if (imagesOnly && !files.some(isImageFile)) return false; return true; }); } function selectVisibleFileMessages({ imagesOnly = false } = {}) { if (!state.selectedChannel) return; ensureCherryPick(); const targets = visibleFileMessages(imagesOnly); const allSelected = targets.length > 0 && targets.every((msg) => isMsgSelected(state.selectedChannel, msg)); let n = 0; for (const msg of targets) { const sel = msgEntry(state.selectedChannel, msg.id); if (allSelected) { clearMsgSelection(state.selectedChannel, msg.id); } else { setMsgImport(state.selectedChannel, msg, true); const sel = msgEntry(state.selectedChannel, msg.id); for (const f of msg.files || []) { const fid = f.id || f.name; if (!fid) continue; if (imagesOnly && !isImageFile(f)) continue; sel.files[fid] = true; } } n += 1; } renderAllMessages(); renderSidebar(); updateTriageBar(); scheduleAutosave(); const label = imagesOnly ? "image" : "file"; const verb = allSelected ? "Cleared" : "Selected"; els.saveStatus.textContent = `${verb} ${n} ${label} message(s) in view`; updateBulkSelectButtons(); setTimeout(() => { els.saveStatus.textContent = ""; }, 2500); } function updateBulkSelectButtons() { if (!els.btnSelectImages || !els.btnSelectFiles) return; const imgTargets = visibleFileMessages(true); const fileTargets = visibleFileMessages(false); const imgAll = imgTargets.length > 0 && imgTargets.every((m) => isMsgSelected(state.selectedChannel, m)); const fileAll = fileTargets.length > 0 && fileTargets.every((m) => isMsgSelected(state.selectedChannel, m)); els.btnSelectImages.textContent = imgAll ? "Clear images in view" : "Select images in view"; els.btnSelectFiles.textContent = fileAll ? "Clear files in view" : "Select files in view"; els.btnSelectImages.classList.toggle("active", imgAll); els.btnSelectFiles.classList.toggle("active", fileAll); } function selectVisibleImageMessages() { selectVisibleFileMessages({ imagesOnly: true }); } function scheduleAutosave() { clearTimeout(state.saveTimer); state.saveTimer = setTimeout(() => saveManifest(true), 600); } async function saveManifest(silent = false) { persistNotes(); state.globalNotes = els.globalNotes.value; try { await api("/api/manifest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ global_notes: state.globalNotes, channels: state.channelManifest, users: state.userManifest, }), }); if (!silent) { els.saveStatus.textContent = "Saved"; els.saveStatus.className = "save-status ok"; setTimeout(() => { els.saveStatus.textContent = ""; els.saveStatus.className = "save-status"; }, 2000); } } catch (err) { els.saveStatus.textContent = err.message; } } async function loadChannels() { const data = await api("/api/channels"); state.channels = data.channels; for (const ch of state.channels) { const e = channelEntry(ch.name); e.import = !!ch.import; e.status = ch.status || (ch.import ? "import" : "undecided"); e.reviewed = !!ch.reviewed || ["import", "skip", "partial"].includes(e.status); e.import_mode = ch.import_mode === "pick" || ch.status === "partial" ? "pick" : ch.import_mode || "all"; if (ch.status === "partial" && e.import_mode !== "pick") { e.import_mode = "pick"; } e.notes = ch.notes || ""; e.mattermost_channel = ch.mattermost_channel || ""; e.mattermost_team = ch.mattermost_team || ""; // Message picks come from loadManifest() only — avoid clobbering on parallel load. } } async function loadManifest() { const manifest = await api("/api/manifest"); state.channelManifest = manifest.channels || {}; state.userManifest = manifest.users || {}; state.globalNotes = manifest.global_notes || ""; els.globalNotes.value = state.globalNotes; } function onMessageListClick(e) { if (!state.selectedChannel) return; if (e.target.closest(".msg-check")) return; const previewBtn = e.target.closest( ".image-preview, .file-doc-preview, .file-open-link[data-preview-file-id], .file-open-link[data-preview-url]" ); if (previewBtn?.dataset.previewFileId || previewBtn?.dataset.previewUrl) { e.preventDefault(); e.stopPropagation(); const asImage = previewBtn.classList.contains("image-preview"); openFilePreview(previewBtn.dataset.previewUrl, previewBtn.dataset.previewName, { asImage, fileId: previewBtn.dataset.previewFileId || "", downloadUrl: previewBtn.dataset.previewDownload || "", }); return; } const row = e.target.closest(".message"); if (!row) return; const msg = state.allMessages.find((m) => m.id === row.dataset.id); if (msg) { focusMessage(msg.id, { scroll: false }); openMessageDetail(msg); } } function bindEvents() { document.querySelectorAll(".tabs button").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".tabs button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.tab = btn.dataset.tab; renderSidebar(); }); }); [els.sidebarSearch, els.filterStatus, els.filterHideArchived].forEach((el) => { el.addEventListener("input", renderSidebar); el.addEventListener("change", renderSidebar); }); [ els.messageSearch, els.filterAttachmentType, els.filterHideBots, els.filterHideSystem, els.filterOnlySelected, ].forEach((el) => { el.addEventListener("input", renderAllMessages); el.addEventListener("change", renderAllMessages); }); els.btnSelectImages?.addEventListener("click", selectVisibleImageMessages); els.btnSelectFiles?.addEventListener("click", () => selectVisibleFileMessages()); els.globalSearchMode.addEventListener("change", () => { syncGlobalSearchModeUi(); const q = els.globalSearch.value; if (els.globalSearchMode.value === "files" || q.trim()) { runGlobalSearch(q); } else { els.globalSearchResults.classList.add("hidden"); } }); els.globalFileType.addEventListener("change", () => { if (els.globalSearchMode.value === "files") runGlobalSearch(els.globalSearch.value); }); syncGlobalSearchModeUi(); els.importModePick.addEventListener("change", () => { if (!state.selectedChannel) return; const entry = channelEntry(state.selectedChannel); entry.import_mode = els.importModePick.checked ? "pick" : "all"; if (entry.import_mode === "pick") entry.status = "partial"; renderSelectionSummary(); updateTriageBar(); scheduleAutosave(); }); [els.mattermostChannel, els.mattermostTeam, els.itemNotes].forEach((el) => { el.addEventListener("input", persistNotes); }); els.globalNotes.addEventListener("input", scheduleAutosave); els.btnSave.addEventListener("click", () => saveManifest(false)); els.btnImport.addEventListener("click", importChannel); els.btnSkip.addEventListener("click", skipChannel); els.btnNext.addEventListener("click", goNextUndecided); els.btnPrev.addEventListener("click", goPrevChannel); els.btnMsgNext.addEventListener("click", goNextMessage); els.btnMsgPrev.addEventListener("click", goPrevMessage); els.messages.addEventListener("click", onMessageListClick); els.messages.addEventListener("change", (e) => { const input = e.target.closest("input[data-msg-check]"); if (!input || !state.selectedChannel) return; e.stopPropagation(); const row = input.closest(".message"); const msg = state.allMessages.find((m) => m.id === row?.dataset.id); if (!msg) return; setMsgImport(state.selectedChannel, msg, input.checked); renderAllMessages(); renderSidebar(); updateTriageBar(); scheduleAutosave(); if (state.selectedMessageId === msg.id) { openMessageDetail(msg); } else { focusMessage(msg.id, { scroll: false }); } }); els.msgAction.addEventListener("change", () => { els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge"); applyMessageDetailFromForm(); }); els.msgMergeTarget.addEventListener("input", applyMessageDetailFromForm); els.msgNotes.addEventListener("input", applyMessageDetailFromForm); els.fileToggles.addEventListener("change", applyMessageDetailFromForm); els.fileToggles.addEventListener("click", (e) => { const btn = e.target.closest("[data-preview-file-id], [data-preview-url]"); if (!btn) return; e.preventDefault(); e.stopPropagation(); openFilePreview(btn.dataset.previewUrl, btn.dataset.previewName, { fileId: btn.dataset.previewFileId || "", downloadUrl: btn.dataset.previewDownload || "", }); }); els.globalSearch.addEventListener("input", () => { clearTimeout(state.globalSearchTimer); const q = els.globalSearch.value; if (!q.trim() && els.globalSearchMode.value !== "files") { els.globalSearchResults.classList.add("hidden"); return; } state.globalSearchTimer = setTimeout(() => runGlobalSearch(q), 250); }); document.addEventListener("click", (e) => { if (!e.target.closest(".global-search-wrap")) { els.globalSearchResults.classList.add("hidden"); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !document.getElementById("preview-modal")?.classList.contains("hidden")) { e.preventDefault(); closePreview(); return; } if (e.target.matches("textarea, input, select") && e.key !== "Escape") return; if (e.key === "/") { e.preventDefault(); els.globalSearch.focus(); return; } if (!state.selectedChannel) return; const msg = state.allMessages.find((m) => m.id === state.focusMessageId); const shift = e.shiftKey; if (e.key === " " && state.focusMessageId) { e.preventDefault(); toggleFocusedImport(); return; } if ((e.key === "i" || e.key === "I") && shift) { e.preventDefault(); importChannel(); return; } if ((e.key === "s" || e.key === "S") && shift) { e.preventDefault(); skipChannel(); return; } if (e.key === "i" || e.key === "I") { e.preventDefault(); if (msg) setMessageAction(msg, "import", { advance: true }); else focusFirstMessage(); return; } if (e.key === "s" || e.key === "S") { e.preventDefault(); if (msg) setMessageAction(msg, "skip", { advance: true }); else focusFirstMessage(); return; } if (e.key === "x" || e.key === "X") { e.preventDefault(); toggleFocusedImport(); return; } if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); goNextMessage(); return; } if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); goPrevMessage(); return; } if (e.key === "n" || e.key === "N") { e.preventDefault(); goNextUndecided(); return; } if (e.key === "Enter" && msg) { e.preventDefault(); openMessageDetail(msg); } }); } async function init() { bindEvents(); try { const summary = await api("/api/summary"); await loadManifest(); await Promise.all([ loadChannels(), api("/api/users").then((d) => { state.users = d.users; }), ]); renderStats(summary); renderSidebar(); renderSelectionSummary(); els.filterStatus.value = "undecided"; const first = visibleChannelList()[0] || state.channels.find((c) => !channelEntry(c.name).reviewed) || state.channels[0]; if (first) await selectChannel(first.name); } catch (err) { els.messages.innerHTML = `
${escapeHtml(err.message)}
`; } } init();