/* 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 = `
`;
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();