/* ============================================================ PST Archive — frontend logic - Setup wizard (first run) - Login (+ MFA) - Main app: upload, index, search, view ============================================================ */ const API = "/api"; const state = { activePstId: null, activePstName: "", folder: "", query: "", limit: 25, offset: 0, total: 0, refreshTimer: null, user: null, folders: [], // [{id, name, file_count}, ...] psts: [], // last loaded pst list collapsedFolders: new Set(), // folder IDs (or "__uncat__") that are collapsed movePstId: null, // currently being moved }; const $ = (id) => document.getElementById(id); // ---------- helpers ---------- function fmtBytes(n) { if (!n) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${units[i]}`; } function fmtDate(iso) { if (!iso) return "—"; try { const d = new Date(iso); if (isNaN(d)) return iso; return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); } catch { return iso; } } function fmtDateTime(iso) { if (!iso) return "—"; try { const d = new Date(iso); if (isNaN(d)) return iso; return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return iso; } } function toast(msg, ms = 3000) { const el = $("toast"); el.textContent = msg; el.classList.remove("hidden"); clearTimeout(toast._t); toast._t = setTimeout(() => el.classList.add("hidden"), ms); } function escapeHtml(s) { if (s == null) return ""; return String(s) .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } function renderSnippet(snippet) { if (!snippet) return ""; return escapeHtml(snippet) .replace(/<mark>/g, "") .replace(/<\/mark>/g, ""); } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } // ---------- API ---------- async function api(path, opts = {}) { const res = await fetch(API + path, { credentials: "same-origin", ...opts, }); let body = null; try { body = await res.json(); } catch {} if (!res.ok) { const err = new Error((body && body.detail) || res.statusText); err.status = res.status; err.body = body || {}; throw err; } return body; } // ---------- Screen switching ---------- function showScreen(screen) { ["setupScreen", "loginScreen", "mainApp"].forEach((id) => { $(id).classList.toggle("hidden", id !== screen); }); } // ---------- Bootstrap ---------- async function bootstrap() { // 1. Try to fetch current user try { const { user } = await api("/me"); state.user = user; enterApp(); return; } catch (e) { if (e.status !== 401) { showLogin(); toast("Connection problem — please retry."); return; } } // 2. Not logged in — check setup status try { const { needs_setup } = await api("/setup/status"); if (needs_setup) showSetup(); else showLogin(); } catch { showLogin(); } } // ---------- Setup ---------- function showSetup() { showScreen("setupScreen"); $("setupForm").reset(); $("setupError").classList.add("hidden"); setTimeout(() => $("setupForm").username?.focus(), 50); } async function handleSetup(e) { e.preventDefault(); const form = e.target; const errEl = $("setupError"); errEl.classList.add("hidden"); const username = form.username.value.trim(); const password = form.password.value; const password2 = form.password2.value; if (password !== password2) { errEl.textContent = "Passwords do not match."; errEl.classList.remove("hidden"); return; } try { const { user } = await api("/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); state.user = user; toast("Welcome — admin account created."); enterApp(); } catch (err) { errEl.textContent = err.message || "Setup failed."; errEl.classList.remove("hidden"); } } // ---------- Login ---------- function showLogin() { showScreen("loginScreen"); $("loginForm").reset(); $("mfaField").classList.add("hidden"); $("loginError").classList.add("hidden"); setTimeout(() => $("loginForm").username?.focus(), 50); } async function handleLogin(e) { e.preventDefault(); const form = e.target; const errEl = $("loginError"); errEl.classList.add("hidden"); const payload = { username: form.username.value.trim(), password: form.password.value, }; const mfaInput = form.totp_code.value.trim(); if (mfaInput) payload.totp_code = mfaInput; try { const { user } = await api("/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); state.user = user; toast(`Welcome, ${user.username}.`); enterApp(); } catch (err) { if (err.status === 401 && err.body && err.body.mfa_required) { $("mfaField").classList.remove("hidden"); const f = $("loginForm").totp_code; f.value = ""; setTimeout(() => f.focus(), 50); errEl.textContent = "Enter your authenticator code to continue."; errEl.classList.remove("hidden"); return; } errEl.textContent = err.message || "Login failed."; errEl.classList.remove("hidden"); } } async function handleLogout() { try { await api("/logout", { method: "POST" }); } catch {} state.user = null; state.activePstId = null; resetReader(); showLogin(); } // ---------- Enter the app ---------- function enterApp() { showScreen("mainApp"); $("userChip").textContent = `${state.user.username}${state.user.role === "admin" ? " · admin" : ""}`; checkHealth(); loadPstList(); setInterval(checkHealth, 30000); } // ---------- Health ---------- async function checkHealth() { const dot = $("statusDot"); const text = $("statusText"); try { await api("/health"); dot.className = "status-dot ok"; text.textContent = "connected"; } catch { dot.className = "status-dot err"; text.textContent = "offline"; } } // ---------- PST list ---------- async function loadPstList() { try { const [folders, items] = await Promise.all([ api("/folders"), api("/pst-files"), ]); state.folders = folders; state.psts = items; renderPstList(); if (items.some((i) => i.status === "indexing")) { clearTimeout(state.refreshTimer); state.refreshTimer = setTimeout(loadPstList, 2500); } } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Failed to load archives: ${e.message}`); } } function pstItemHtml(i) { const active = i.id === state.activePstId ? "active" : ""; const disabled = i.status !== "ready"; const statusText = i.status === "indexing" ? "indexing…" : i.status === "ready" ? `${i.message_count.toLocaleString()} msgs` : i.status === "failed" ? "failed" : i.status; return `
  • ${escapeHtml(i.original_name)}
    ${fmtBytes(i.size_bytes)} · ${fmtDate(i.uploaded_at)} ${statusText}
  • `; } function renderPstList() { const ul = $("pstList"); const { folders, psts } = state; if (!folders.length && !psts.length) { ul.innerHTML = '
  • No archives yet.
  • '; return; } // Group PSTs by folder_id const byFolder = new Map(); byFolder.set(null, []); // uncategorized bucket folders.forEach((f) => byFolder.set(f.id, [])); psts.forEach((p) => { const bucket = byFolder.has(p.folder_id) ? p.folder_id : null; byFolder.get(bucket).push(p); }); const parts = []; // Render user-defined folders first (sorted alphabetically — server does this) for (const f of folders) { const items = byFolder.get(f.id) || []; const collapsed = state.collapsedFolders.has(f.id) ? "collapsed" : ""; parts.push(`
  • ${escapeHtml(f.name)} ${items.length}
  • `); } // Uncategorized group — only shown if it has items OR there are no folders const uncat = byFolder.get(null) || []; if (uncat.length || !folders.length) { const collapsed = state.collapsedFolders.has("__uncat__") ? "collapsed" : ""; parts.push(`
  • Uncategorized ${uncat.length}
  • `); } ul.innerHTML = parts.join(""); // PST click ul.querySelectorAll(".pst-item").forEach((el) => { el.addEventListener("click", (e) => { if (e.target.closest("[data-pst-menu]")) return; if (el.dataset.disabled) { toast(el.dataset.status === "indexing" ? "Still indexing — one moment…" : "This archive is unavailable."); return; } selectPst(el.dataset.id, el.dataset.name); }); }); // PST action menu ul.querySelectorAll("[data-pst-menu]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); openPstMenu(e, btn.dataset.pstMenu); }); }); // Folder header toggle (collapse/expand) ul.querySelectorAll("[data-folder-header]").forEach((el) => { el.addEventListener("click", (e) => { if (e.target.closest("[data-folder-menu]")) return; const id = el.dataset.folderHeader; if (state.collapsedFolders.has(id)) state.collapsedFolders.delete(id); else state.collapsedFolders.add(id); const group = el.closest(".folder-group"); group.classList.toggle("collapsed"); }); }); // Folder action menu ul.querySelectorAll("[data-folder-menu]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); openFolderMenu(e, btn.dataset.folderMenu); }); }); } // ---------- Context menus ---------- function closeContextMenu() { document.querySelectorAll(".context-menu").forEach((el) => el.remove()); } function openContextMenu(x, y, items) { closeContextMenu(); const menu = document.createElement("div"); menu.className = "context-menu"; items.forEach((it) => { if (it.divider) { menu.appendChild(document.createElement("hr")); return; } const btn = document.createElement("button"); btn.textContent = it.label; if (it.danger) btn.classList.add("danger"); btn.addEventListener("click", () => { closeContextMenu(); it.onClick(); }); menu.appendChild(btn); }); document.body.appendChild(menu); // Position, keeping menu inside viewport const rect = menu.getBoundingClientRect(); const maxX = window.innerWidth - rect.width - 4; const maxY = window.innerHeight - rect.height - 4; menu.style.left = Math.min(x, maxX) + "px"; menu.style.top = Math.min(y, maxY) + "px"; // Close on outside click or escape setTimeout(() => { document.addEventListener("click", closeContextMenu, { once: true }); }, 0); } function openPstMenu(event, pstId) { const p = state.psts.find((x) => x.id === pstId); if (!p) return; const r = event.currentTarget.getBoundingClientRect(); openContextMenu(r.left, r.bottom + 4, [ { label: "Move to folder…", onClick: () => openMoveModal(p), }, { divider: true }, { label: "Delete archive", danger: true, onClick: () => deletePst(p), }, ]); } function openFolderMenu(event, folderId) { const f = state.folders.find((x) => x.id === folderId); if (!f) return; const r = event.currentTarget.getBoundingClientRect(); openContextMenu(r.left, r.bottom + 4, [ { label: "Rename folder…", onClick: () => renameFolder(f), }, { divider: true }, { label: "Delete folder", danger: true, onClick: () => deleteFolder(f), }, ]); } // ---------- Folder operations ---------- function openFolderModal() { $("folderForm").reset(); $("folderError").classList.add("hidden"); $("folderModal").classList.remove("hidden"); setTimeout(() => $("folderForm").name.focus(), 50); } async function handleCreateFolder(e) { e.preventDefault(); const form = e.target; const errEl = $("folderError"); errEl.classList.add("hidden"); try { await api("/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: form.name.value }), }); toast("Folder created."); closeModals(); loadPstList(); } catch (err) { errEl.textContent = err.message || "Create failed."; errEl.classList.remove("hidden"); } } async function renameFolder(f) { const newName = prompt(`Rename "${f.name}" to:`, f.name); if (!newName || newName.trim() === f.name) return; try { await api(`/folders/${f.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName }), }); toast("Folder renamed."); loadPstList(); } catch (err) { toast(`Rename failed: ${err.message}`); } } async function deleteFolder(f) { if (!confirm(`Delete folder "${f.name}"?\n\nAny archives inside will be moved to Uncategorized.`)) return; try { await api(`/folders/${f.id}`, { method: "DELETE" }); toast("Folder deleted."); loadPstList(); } catch (err) { toast(`Delete failed: ${err.message}`); } } async function deletePst(p) { if (!confirm(`Remove "${p.original_name}" and all indexed messages?`)) return; try { await api(`/pst-files/${p.id}`, { method: "DELETE" }); if (state.activePstId === p.id) { state.activePstId = null; resetReader(); } toast("Archive removed."); loadPstList(); } catch (err) { toast(`Delete failed: ${err.message}`); } } function openMoveModal(p) { state.movePstId = p.id; $("moveSubject").textContent = p.original_name; const sel = $("moveFolderSelect"); sel.innerHTML = '' + state.folders.map((f) => `` ).join(""); if (!p.folder_id) sel.value = ""; $("moveError").classList.add("hidden"); $("moveModal").classList.remove("hidden"); } async function handleMovePst(e) { e.preventDefault(); const errEl = $("moveError"); errEl.classList.add("hidden"); const folderId = $("moveFolderSelect").value || null; try { await api(`/pst-files/${state.movePstId}/folder`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ folder_id: folderId }), }); toast("Moved."); closeModals(); loadPstList(); } catch (err) { errEl.textContent = err.message || "Move failed."; errEl.classList.remove("hidden"); } } function closeModals() { document.querySelectorAll(".modal").forEach((m) => m.classList.add("hidden")); } // ---------- Selection + folders ---------- async function selectPst(id, name) { state.activePstId = id; state.activePstName = name; state.folder = ""; state.query = ""; state.offset = 0; $("searchInput").value = ""; $("searchInput").disabled = false; $("folderFilter").disabled = false; document.querySelectorAll(".pst-item").forEach((el) => { el.classList.toggle("active", el.dataset.id === id); }); $("readerTitle").textContent = name; $("readerMeta").textContent = "Loading messages…"; hideMessageView(); await Promise.all([loadFolders(), loadMessages()]); } async function loadFolders() { if (!state.activePstId) return; try { const folders = await api(`/pst-files/${state.activePstId}/folders`); const sel = $("folderFilter"); sel.innerHTML = '' + folders.map((f) => ``).join(""); sel.value = state.folder || ""; } catch (e) { console.warn("folders", e); } } // ---------- Messages ---------- async function loadMessages() { if (!state.activePstId) return; const params = new URLSearchParams(); if (state.query) params.set("q", state.query); if (state.folder) params.set("folder", state.folder); params.set("limit", state.limit); params.set("offset", state.offset); try { const res = await api(`/pst-files/${state.activePstId}/messages?${params}`); state.total = res.total; renderMessages(res.items); renderPaginator(); $("readerMeta").textContent = `${state.total.toLocaleString()} message${state.total === 1 ? "" : "s"}` + (state.query ? ` matching "${state.query}"` : "") + (state.folder ? ` in ${state.folder}` : ""); } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Search failed: ${e.message}`); } } function renderMessages(items) { const list = $("messageList"); if (!items.length) { list.innerHTML = `

    No messages match.

    `; return; } list.innerHTML = items.map((m) => { const hasAtt = m.attachments && m.attachments.trim().length > 0; return `
    ${escapeHtml(m.sender || "(unknown sender)")}
    ${escapeHtml(m.subject || "(no subject)")} ${hasAtt ? '' : ""}
    ${renderSnippet(m.snippet || "")}
    ${fmtDate(m.sent_date)}
    ${escapeHtml((m.folder || "").split("/").filter(Boolean).pop() || "inbox")}
    `; }).join(""); list.querySelectorAll(".message-row").forEach((el) => { el.addEventListener("click", () => openMessage(el.dataset.id)); }); } function renderPaginator() { const p = $("paginator"); if (state.total <= state.limit) { p.classList.add("hidden"); return; } p.classList.remove("hidden"); const from = state.offset + 1; const to = Math.min(state.offset + state.limit, state.total); $("pageInfo").textContent = `${from}–${to} of ${state.total.toLocaleString()}`; $("prevBtn").disabled = state.offset === 0; $("nextBtn").disabled = state.offset + state.limit >= state.total; } // ---------- Message view ---------- async function openMessage(id) { try { const m = await api(`/messages/${id}`); $("mvSubject").textContent = m.subject || "(no subject)"; $("mvSender").textContent = m.sender || "—"; $("mvRecipients").textContent = m.recipients || "—"; $("mvDate").textContent = fmtDateTime(m.sent_date); $("mvFolder").textContent = m.folder || "—"; if (m.attachments && m.attachments.trim()) { $("mvAttLabel").classList.remove("hidden"); $("mvAttachments").classList.remove("hidden"); $("mvAttachments").textContent = m.attachments.split(" | ").join(", "); } else { $("mvAttLabel").classList.add("hidden"); $("mvAttachments").classList.add("hidden"); } $("mvBody").textContent = m.body || "(empty body)"; $("messageView").classList.remove("hidden"); } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Could not open message: ${e.message}`); } } function hideMessageView() { $("messageView").classList.add("hidden"); } function resetReader() { $("readerTitle").textContent = "Select an archive"; $("readerMeta").textContent = "Upload a PST file or select one from the left to begin."; $("searchInput").disabled = true; $("folderFilter").disabled = true; $("messageList").innerHTML = `

    The archive awaits.

    `; $("paginator").classList.add("hidden"); hideMessageView(); } // ---------- Upload ---------- function uploadFile(file) { if (!file) return; const lower = file.name.toLowerCase(); if (!lower.endsWith(".pst") && !lower.endsWith(".ost")) { toast("Only .pst and .ost files are accepted."); return; } const progress = $("uploadProgress"); const bar = $("progressBar"); const pct = $("progressPct"); const nm = $("progressName"); progress.classList.remove("hidden"); nm.textContent = file.name; bar.style.width = "0%"; pct.textContent = "0%"; const form = new FormData(); form.append("file", file); const xhr = new XMLHttpRequest(); xhr.open("POST", API + "/pst-files"); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); bar.style.width = p + "%"; pct.textContent = p + "%"; } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { pct.textContent = "indexing…"; bar.style.width = "100%"; toast("Upload complete. Indexing started in background."); setTimeout(() => progress.classList.add("hidden"), 2500); loadPstList(); } else if (xhr.status === 401) { handleLogout(); } else { let msg = "Upload failed"; try { msg = JSON.parse(xhr.responseText).detail || msg; } catch {} toast(msg); progress.classList.add("hidden"); } }; xhr.onerror = () => { toast("Upload failed (network)."); progress.classList.add("hidden"); }; xhr.send(form); } // ---------- Theme ---------- function initTheme() { // Preference order: localStorage → prefers-color-scheme → light let theme = "light"; try { const saved = localStorage.getItem("pst-theme"); if (saved === "light" || saved === "dark") { theme = saved; } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { theme = "dark"; } } catch { /* ignore storage errors */ } document.documentElement.dataset.theme = theme; } function toggleTheme() { const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; const next = current === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next; try { localStorage.setItem("pst-theme", next); } catch {} } // Apply theme immediately so there's no flash of wrong theme initTheme(); // ---------- Wire up ---------- function init() { $("setupForm").addEventListener("submit", handleSetup); $("loginForm").addEventListener("submit", handleLogin); $("logoutBtn").addEventListener("click", handleLogout); // Theme toggles (both the in-masthead and floating variants) ["themeToggle", "themeToggleFloat"].forEach((id) => { const el = document.getElementById(id); if (el) el.addEventListener("click", toggleTheme); }); $("fileInput").addEventListener("change", (e) => { const f = e.target.files[0]; if (f) uploadFile(f); e.target.value = ""; }); const dz = $("dropzone"); ["dragenter", "dragover"].forEach((ev) => { dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag-over"); }); }); ["dragleave", "drop"].forEach((ev) => { dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag-over"); }); }); dz.addEventListener("drop", (e) => { const f = e.dataTransfer.files?.[0]; if (f) uploadFile(f); }); $("refreshBtn").addEventListener("click", loadPstList); $("newFolderBtn").addEventListener("click", openFolderModal); $("folderForm").addEventListener("submit", handleCreateFolder); $("moveForm").addEventListener("submit", handleMovePst); // Modal close buttons + backdrop clicks + Escape document.querySelectorAll("[data-close]").forEach((el) => { el.addEventListener("click", closeModals); }); document.querySelectorAll(".modal").forEach((m) => { m.addEventListener("click", (e) => { if (e.target === m) closeModals(); }); }); const searchHandler = debounce(() => { state.query = $("searchInput").value.trim(); state.offset = 0; loadMessages(); }, 250); $("searchInput").addEventListener("input", searchHandler); $("folderFilter").addEventListener("change", () => { state.folder = $("folderFilter").value; state.offset = 0; loadMessages(); }); $("prevBtn").addEventListener("click", () => { state.offset = Math.max(0, state.offset - state.limit); loadMessages(); }); $("nextBtn").addEventListener("click", () => { state.offset += state.limit; loadMessages(); }); $("closeMessageBtn").addEventListener("click", hideMessageView); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideMessageView(); closeModals(); closeContextMenu(); } }); bootstrap(); } document.addEventListener("DOMContentLoaded", init);