/* ============================================================
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" : ""}`;
// Show the ADMINISTRATION button only for admins.
const adminBtn = $("adminNavBtn");
if (adminBtn) adminBtn.classList.toggle("hidden", state.user.role !== "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(`
${items.length
? items.map(pstItemHtml).join("")
: '— empty — '}
`);
}
// 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(`
${uncat.length
? uncat.map(pstItemHtml).join("")
: '— empty — '}
`);
}
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 = 'Uncategorized ' +
state.folders.map((f) =>
`${escapeHtml(f.name)} `
).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 =
'All folders ' +
folders.map((f) => `${escapeHtml(f.folder || "(root)")} · ${f.count} `).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 = `
`;
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 || "")}
`;
}).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 = `
`;
$("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);
const adminNavBtn = $("adminNavBtn");
if (adminNavBtn) {
adminNavBtn.addEventListener("click", () => { window.location.href = "/admin/"; });
}
// 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);