Cherry-pick heuristics, manifest merge, global file search, scrollbar styling, and pytest/Playwright coverage unblock review and filtered export for mmetl.
1601 lines
51 KiB
JavaScript
1601 lines
51 KiB
JavaScript
/* 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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
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();
|