Files
slack-sieve/static/app.js
T
ilia 4b034d19f6
CI / skip-ci-check (push) Successful in 4s
CI / secret-scan (push) Failing after 2s
CI / python-ci (push) Successful in 25s
Add triage tests, prepare-export, and UI fixes for Levkin migration.
Cherry-pick heuristics, manifest merge, global file search, scrollbar styling, and pytest/Playwright coverage unblock review and filtered export for mmetl.
2026-06-03 15:42:48 -04:00

1601 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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("<!DOCTYPE")
? `${res.status} ${res.statusText}`
: text;
throw new Error(brief);
}
return res.json();
}
function formatNumber(n) {
return new Intl.NumberFormat().format(n);
}
function escapeHtml(text) {
return String(text || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function highlightHtml(text, query) {
const safe = escapeHtml(text);
if (!query) return safe;
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
return safe.replace(re, "<mark>$1</mark>");
}
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("<!doctype") ||
t.startsWith("<html") ||
t.startsWith("<div") ||
t.startsWith("<h1")
);
}
function extractPreviewHtml(text) {
try {
const doc = new DOMParser().parseFromString(text, "text/html");
const quip = doc.querySelector(".quip-canvas-content");
if (quip) return quip.outerHTML;
if (doc.body?.innerHTML?.trim()) return doc.body.innerHTML;
} catch {
/* fall through */
}
return text;
}
function fileDownloadUrl(file) {
if (file?.id) return `/api/file-download?file_id=${encodeURIComponent(file.id)}`;
return fileUploadUrl(file);
}
function renderMessageFiles(msg) {
if (!msg.files?.length) return "";
const items = msg.files.map((f) => {
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 `<button type="button" class="file-preview image-preview" ${previewAttrs} data-preview-name="${name}" data-preview-download="${escapeHtml(
dl
)}" title="View full size">
<img class="thumb" alt="${name}" src="${imgSrc}" loading="lazy" />
</button>`;
}
if (fid || url) {
const cloud = f.local_exists === false ? '<span class="file-cloud" title="Fetched from Slack">☁</span>' : "";
return `<button type="button" class="file-chip file-doc-preview" ${previewAttrs} data-preview-name="${name}" data-preview-download="${escapeHtml(
dl
)}" title="Preview ${name}">
<span class="file-ext">${escapeHtml(fileExtLabel(f.name))}</span>
<span class="file-name">${name}</span>${cloud}
</button>`;
}
return `<span class="file-chip missing" title="No file id in export">${name}</span>`;
});
return `<div class="msg-attachments">${items.join("")}</div>`;
}
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 = `
<div class="preview-backdrop" data-close></div>
<div class="preview-panel">
<button type="button" class="preview-close" data-close aria-label="Close">×</button>
<div class="preview-loading hidden">Loading preview…</div>
<div class="preview-error hidden"></div>
<div class="preview-image-wrap hidden">
<img class="preview-image" alt="" />
</div>
<div class="preview-doc-wrap hidden">
<div class="preview-doc-scroll"></div>
</div>
<div class="preview-caption"></div>
<a class="preview-download" href="#" target="_blank" rel="noopener">Download original</a>
</div>
`;
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 || "<p><em>Empty document</em></p>";
const warnings =
result.messages?.length
? `<p class="preview-warn">${escapeHtml(
result.messages.map((m) => m.message).join("; ")
)}</p>`
: "";
modal.querySelector(".preview-doc-scroll").innerHTML =
`<div class="docx-preview">${docHtml}</div>${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 = `<pre class="text-preview">${escapeHtml(
text
)}</pre>`;
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 = `
<strong>${reviewed}/${total}</strong> reviewed ·
<strong>${imported}</strong> import/pick ·
<strong>${total - reviewed}</strong> left
`;
}
function renderStats(summary) {
els.stats.innerHTML = `
<span>${formatNumber(summary.channel_count)} ch</span>
<span>${formatNumber(summary.total_messages)} msgs</span>
<span>${reviewedCount()} done</span>
`;
}
function renderSidebar() {
els.sidebarList.innerHTML = "";
renderProgress();
if (state.tab === "channels") {
const items = visibleChannelList();
if (!items.length) {
els.sidebarList.innerHTML = '<div class="empty">Nothing matches filters.</div>';
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 = `
<span class="status-dot ${dotStatus}"></span>
<div class="body">
<div class="name">#${escapeHtml(ch.name)}</div>
<div class="meta">${formatNumber(ch.message_count)} msgs${mode}</div>
</div>
`;
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 = `
<span class="status-dot ${entry.import ? "import" : "undecided"}"></span>
<div class="body">
<div class="name">${escapeHtml(user.real_name || user.name)}</div>
<div class="meta">@${escapeHtml(user.name || "?")}</div>
</div>
`;
btn.addEventListener("click", () => selectUser(user));
els.sidebarList.appendChild(btn);
}
}
function renderSelectionSummary() {
if (!state.selectedChannel) {
els.selectionSummary.innerHTML =
'<p class="hint">Cherry-pick mode: check messages or use the panel to merge individual posts, threads, and files into another channel.</p>';
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 = `
<p class="counts"><strong>${imp}</strong> import · <strong>${merge}</strong> merge · <strong>${skip}</strong> skip</p>
<p class="hint">${mode}</p>
`;
}
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(`<span class="msg-badge merge">→ ${escapeHtml(sel.merge_target)}</span>`);
}
if (msg.is_thread_parent || msg.thread_reply_count > 0) {
badges.push(
`<span class="msg-badge thread">${msg.thread_reply_count || msg.reply_count || 0} replies</span>`
);
}
if (msg.files?.length) {
badges.push(`<span class="msg-badge">${msg.files.length} file(s)</span>`);
}
const files = renderMessageFiles(msg);
return `
<article class="${classes.join(" ")}" data-id="${escapeHtml(msg.id)}" data-ts="${escapeHtml(msg.ts || "")}">
<label class="msg-check" title="Import this message">
<input type="checkbox" data-msg-check ${checked ? "checked" : ""} />
</label>
<div class="msg-body">
<div class="head">
<span class="author">${escapeHtml(msg.user_name)}</span>
<span class="time">${escapeHtml((msg.time || "").slice(0, 16).replace("T", " "))}</span>
</div>
<div class="text">${highlightHtml(msg.text || "", q)}</div>
${files}
${badges.length ? `<div class="msg-badges">${badges.join("")}</div>` : ""}
</div>
</article>
`;
}
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(`<div class="date-divider">${escapeHtml(day)}</div>`);
}
parts.push(messageRowHtml(msg, q));
}
if (!state.allMessages.length) {
els.messages.innerHTML = '<div class="empty">No messages.</div>';
} else if (!shown) {
els.messages.innerHTML = '<div class="empty">No messages match filters.</div>';
} 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 = "<label>Attachments</label>";
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
? `<button type="button" class="file-open-link" ${
f.id ? `data-preview-file-id="${escapeHtml(f.id)}"` : ""
} data-preview-url="${escapeHtml(url || "")}" data-preview-name="${escapeHtml(
f.name || "file"
)}" data-preview-download="${escapeHtml(fileDownloadUrl(f))}">Preview</button>`
: "";
row.innerHTML = `<label class="file-toggle"><input type="checkbox" data-file-id="${escapeHtml(
fid
)}" ${on ? "checked" : ""} /> ${escapeHtml(f.name || "file")}</label>${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 = `<div class="empty">${escapeHtml(err.message)}</div>`;
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 = '<div class="empty">User mapping only — use Channels for messages.</div>';
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(`<div class="search-group"><h4>${escapeHtml(label)}</h4>`);
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(
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
<div class="sub">${escapeHtml(names || (m.text || "").slice(0, 80))}</div>
</button>`
);
}
parts.push("</div>");
}
} else {
const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`);
if (data.channels?.length) {
parts.push('<div class="search-group"><h4>Channels</h4>');
for (const ch of data.channels.slice(0, 12)) {
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(ch.name)}">#${escapeHtml(ch.name)}</button>`
);
}
parts.push("</div>");
}
if (data.messages?.length) {
parts.push('<div class="search-group"><h4>Messages</h4>');
for (const m of data.messages.slice(0, 20)) {
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
<div class="sub">${escapeHtml((m.text || "").slice(0, 100))}</div>
</button>`
);
}
parts.push("</div>");
}
}
els.globalSearchResults.innerHTML =
parts.join("") || '<div class="empty" style="padding:1rem">No results</div>';
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 = `<div class="empty">${escapeHtml(err.message)}</div>`;
}
}
init();