summarum/src/main.ts
Horizontal42 aa29c12b37 flush pending autosave before migrating the data folder
save_documents debounces 400ms; migrateDataDir used to fire without
waiting for it, so a save still in flight to the old folder could
land after migrate had already moved documents.json away, silently
recreating it in the abandoned folder and stranding the last edit.
2026-08-12 20:00:26 +03:00

1082 lines
37 KiB
TypeScript

/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
import "./ui/app.css";
import { SumEngine } from "./engine";
import { SumEditor } from "./ui/editor";
import {
AppData, DocMeta, SettingsData, defaultSettingsData,
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
chooseFolder, dataDirHasDocuments, migrateDataDir,
} from "./storage";
import type { LineResult } from "./engine";
import { setLang, detectLang, t } from "./i18n";
import { runExtensions } from "./extensions";
import { checkForUpdate } from "./updater";
import { Workspace } from "./workspace";
import { EN, RU } from "./engine/vocab-data";
import { exportSheetImage } from "./ui/export";
import { initSearch, SearchController } from "./ui/search";
import pkg from "../package.json";
function welcomeText(lang: string): string {
const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"];
return (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n";
}
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
let engine: SumEngine;
let editor: SumEditor;
let workspace: Workspace;
let search: SearchController;
let settings: SettingsData;
let data: AppData;
let lastResults: LineResult[] = [];
let selectedRange: [number, number] | null = null;
let ratesFetchedAt = 0;
let liveRates: Record<string, number> = {};
let lastWindowFocusAt = 0;
function uid(): string {
return Math.random().toString(36).slice(2, 10);
}
// ---------- documents
function activeDoc(): DocMeta {
return data.docs.find((d) => d.id === data.activeId) ?? data.docs[0];
}
function titleFromContent(text: string): string {
let start = 0;
while (start < text.length) {
let end = text.indexOf("\n", start);
if (end === -1) end = text.length;
const s = text.slice(start, end).trim();
if (s.length > 0) return s.replace(/^#\s*/, "").slice(0, 30);
start = end + 1;
}
return t("untitled");
}
function syncTitleField(): void {
const field = $<HTMLInputElement>("#doc-title");
if (document.activeElement !== field) field.value = activeDoc()?.title ?? "";
}
function pinDoc(id: string): void {
const doc = data.docs.find((d) => d.id === id);
if (!doc) return;
doc.pinned = !doc.pinned;
if (doc.pinned) {
// move to end of pinned group
data.docs = data.docs.filter((d) => d.id !== id);
let lastPinned = -1;
for (let i = data.docs.length - 1; i >= 0; i--) { if (data.docs[i].pinned) { lastPinned = i; break; } }
data.docs.splice(lastPinned + 1, 0, doc);
} else {
// move to start of non-pinned group
data.docs = data.docs.filter((d) => d.id !== id);
const firstNonPinned = data.docs.findIndex((d) => !d.pinned);
data.docs.splice(firstNonPinned === -1 ? data.docs.length : firstNonPinned, 0, doc);
}
saveAppData(data);
renderDocList();
}
/** drag id -> drop-target id; no-op across the pinned/unpinned boundary */
function reorderDoc(srcId: string, targetId: string): void {
if (srcId === targetId) return;
const src = data.docs.find((d) => d.id === srcId);
const target = data.docs.find((d) => d.id === targetId);
if (!src || !target || !!src.pinned !== !!target.pinned) return;
data.docs = data.docs.filter((d) => d.id !== srcId);
const targetIdx = data.docs.findIndex((d) => d.id === targetId);
data.docs.splice(targetIdx, 0, src);
saveAppData(data);
renderDocList();
}
function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, data: AppData): void {
// Reordering uses plain mouse tracking, not the native HTML5 drag API —
// Tauri's window-level file-drop hook (dragDropEnabled, needed for
// dropping .numi files from Explorer) intercepts WebView2's own drag
// sessions, so draggable="true" elements never get a valid drop target
// and the OS always shows a "not allowed" cursor.
let dragging = false;
let suppressClick = false;
el.addEventListener("mousedown", (e) => {
if ((e.target as HTMLElement).closest("button")) return;
const startY = e.clientY;
let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null;
const onMove = (ev: MouseEvent) => {
if (!dragging) {
if (Math.abs(ev.clientY - startY) < 4) return;
dragging = true;
suppressClick = true;
el.classList.add("dragging");
document.body.style.cursor = "grabbing";
}
if (!cachedItems) {
const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")];
cachedItems = domItems.map((item) => {
const rect = item.getBoundingClientRect();
return { el: item, top: rect.top, bottom: rect.bottom };
});
}
let over: HTMLElement | undefined;
for (const item of cachedItems) {
item.el.classList.remove("drag-over");
if (!over && item.el !== el) {
if (ev.clientY >= item.top && ev.clientY <= item.bottom) {
over = item.el;
}
}
}
if (over) {
const overDoc = data.docs.find((d) => d.id === over.dataset.docId);
if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over");
}
};
const onScroll = () => { cachedItems = null; };
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
list.removeEventListener("scroll", onScroll);
cachedItems = null;
if (dragging) {
const target = list.querySelector<HTMLElement>(".doc-item.drag-over");
for (const item of list.querySelectorAll(".doc-item")) item.classList.remove("drag-over", "dragging");
document.body.style.cursor = "";
if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId);
}
dragging = false;
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
list.addEventListener("scroll", onScroll);
});
el.addEventListener("click", () => {
if (suppressClick) { suppressClick = false; return; }
switchDoc(doc.id);
});
}
function mkBtn(cls: string, text: string, title: string, cb: () => void): HTMLButtonElement {
const b = document.createElement("button");
b.className = cls;
b.textContent = text;
b.title = title;
b.setAttribute("aria-label", title);
b.addEventListener("click", (e) => { e.stopPropagation(); cb(); });
return b;
}
function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: SettingsData): HTMLButtonElement {
const del = document.createElement("button");
del.className = "del";
del.textContent = "✕";
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
del.addEventListener("click", (e) => {
e.stopPropagation();
if (appData.docs.length === 1) return;
if (!del.classList.contains("confirm")) {
del.classList.add("confirm");
del.textContent = "✓";
confirmTimer = setTimeout(() => {
del.classList.remove("confirm");
del.textContent = "✕";
}, 2000);
return;
}
if (confirmTimer) clearTimeout(confirmTimer);
void backupDeletedSheet(appSettings.dataDir, doc.title, appData.contents[doc.id] ?? "");
delete appData.contents[doc.id];
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
else renderDocList();
saveAppData(appData);
});
return del;
}
function renderDocList(): void {
const list = $("#doc-list");
list.replaceChildren();
const frag = document.createDocumentFragment();
for (const doc of data.docs) {
const el = document.createElement("div");
el.className = "doc-item" + (doc.id === data.activeId ? " active" : "") + (doc.pinned ? " pinned" : "");
const name = document.createElement("span");
name.className = "doc-name";
name.textContent = doc.title || t("untitled");
el.appendChild(name);
el.appendChild(mkBtn("pin-btn" + (doc.pinned ? " active" : ""), "📌", doc.pinned ? t("unpin") : t("pin"), () => pinDoc(doc.id)));
setupDocInteraction(el, doc, list, data);
el.dataset.docId = doc.id;
el.appendChild(createDeleteButton(doc, data, settings));
frag.appendChild(el);
}
list.appendChild(frag);
}
function switchDoc(id: string): void {
data.activeId = id;
const text = data.contents[id] ?? "";
editor.setText(text);
syncTitleField();
renderDocList();
saveAppData(data);
editor.focus();
void fetchNeededHistoricalRates(text);
}
function newDoc(content = ""): void {
const id = uid();
data.docs.push({ id, title: content ? titleFromContent(content) : t("untitled") });
data.contents[id] = content;
switchDoc(id);
}
async function closeActiveDoc(): Promise<void> {
if (data.docs.length <= 1) return;
const doc = data.docs.find((d) => d.id === data.activeId);
if (!doc) return;
const content = data.contents[doc.id] ?? "";
if (content.trim()) {
const ans = await askModal(t("closeSheet"), t("close"), t("cancel"));
if (ans !== "a") return;
}
const idx = data.docs.findIndex((d) => d.id === doc.id);
void backupDeletedSheet(settings.dataDir, doc.title, content);
delete data.contents[doc.id];
data.docs = data.docs.filter((d) => d.id !== doc.id);
const nextId = data.docs[idx] ? data.docs[idx].id : data.docs[idx - 1]?.id ?? data.docs[0].id;
switchDoc(nextId);
saveAppData(data);
}
// ---------- settings
function applySettings(): void {
const theme = settings.theme === "system"
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: settings.theme;
document.documentElement.dataset.theme = theme;
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
setLang(settings.language);
const searchInput = $<HTMLInputElement>("#search-input");
searchInput.placeholder = t("searchPlaceholder");
searchInput.setAttribute("aria-label", t("searchPlaceholder"));
engine.updateSettings({
precision: settings.precision,
groupSeparator: settings.groupSeparator,
decimalSeparator: settings.decimalSeparator,
dateFormat: settings.dateFormat,
});
editor?.refresh();
syncTitleField();
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
}
function bindFormattingSettings(): void {
const themeSel = $<HTMLSelectElement>("#set-theme");
const precision = $<HTMLInputElement>("#set-precision");
const groupSep = $<HTMLSelectElement>("#set-groupsep");
const decimalSep = $<HTMLSelectElement>("#set-decimalsep");
const dateFmt = $<HTMLSelectElement>("#set-dateformat");
const langSel = $<HTMLSelectElement>("#set-lang");
const fontSize = $<HTMLInputElement>("#set-fontsize");
const resultsWidth = $<HTMLInputElement>("#set-resultswidth");
themeSel.value = settings.theme;
precision.value = String(settings.precision);
groupSep.value = settings.groupSeparator;
decimalSep.value = settings.decimalSeparator;
dateFmt.value = settings.dateFormat;
langSel.value = settings.language;
fontSize.value = String(settings.fontSize);
resultsWidth.value = String(settings.resultsWidth);
const save = () => {
settings.theme = themeSel.value as SettingsData["theme"];
settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
settings.groupSeparator = groupSep.value;
settings.decimalSeparator = decimalSep.value;
// "1,234,56" is unreadable — a comma decimal forces a space group separator
if (settings.decimalSeparator === settings.groupSeparator) {
settings.groupSeparator = settings.decimalSeparator === "," ? " " : ",";
groupSep.value = settings.groupSeparator;
}
settings.dateFormat = dateFmt.value as SettingsData["dateFormat"];
settings.language = langSel.value;
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
applySettings();
void saveSettings(settings);
};
for (const el of [themeSel, precision, groupSep, decimalSep, dateFmt, langSel, fontSize]) {
el.addEventListener("change", save);
}
resultsWidth.addEventListener("input", save); // live while sliding
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (settings.theme === "system") applySettings();
});
}
function bindHotkeySettings(): void {
const hotkey = $<HTMLInputElement>("#set-hotkey");
hotkey.value = settings.hotkey;
hotkey.readOnly = true;
hotkey.addEventListener("focus", () => {
hotkey.value = "";
hotkey.placeholder = t("pressKeys");
});
hotkey.addEventListener("blur", () => {
hotkey.value = settings.hotkey;
hotkey.placeholder = defaultSettingsData.hotkey;
});
hotkey.addEventListener("keydown", async (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
hotkey.blur();
return;
}
const mods = [
e.ctrlKey && "Ctrl",
e.altKey && "Alt",
e.shiftKey && "Shift",
e.metaKey && "Super",
].filter(Boolean) as string[];
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
hotkey.value = mods.length ? mods.join("+") + "+…" : "";
return;
}
if (mods.length === 0) return; // a bare key is not a global hotkey
const keyName = normalizeKeyName(e);
if (!keyName) return;
const combo = [...mods, keyName].join("+");
const old = settings.hotkey;
if (await registerHotkey(old, combo)) {
settings.hotkey = combo;
hotkey.value = combo;
void saveSettings(settings);
} else {
// combo is taken by another app — restore the old one
await registerHotkey(null, old);
toast(t("hotkeyFailed"));
}
hotkey.blur();
});
}
function bindBehaviorSettings(): void {
const autostart = $<HTMLInputElement>("#set-autostart");
const alwaysOnTop = $<HTMLInputElement>("#set-alwaysontop");
const binDays = $<HTMLInputElement>("#set-bindays");
const autoUpdate = $<HTMLInputElement>("#set-autoupdate");
autostart.checked = settings.autostart;
alwaysOnTop.checked = settings.alwaysOnTop;
binDays.value = String(settings.deletedRetentionDays);
autoUpdate.checked = settings.autoUpdateEnabled;
autostart.addEventListener("change", async () => {
settings.autostart = autostart.checked;
await applyAutostart(settings.autostart);
void saveSettings(settings);
});
alwaysOnTop.addEventListener("change", async () => {
settings.alwaysOnTop = alwaysOnTop.checked;
await applyAlwaysOnTop(settings.alwaysOnTop);
void saveSettings(settings);
});
binDays.addEventListener("change", () => {
settings.deletedRetentionDays = Math.max(1, Math.min(365, Math.round(Number(binDays.value)) || 14));
binDays.value = String(settings.deletedRetentionDays);
void saveSettings(settings);
void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
});
autoUpdate.addEventListener("change", () => {
settings.autoUpdateEnabled = autoUpdate.checked;
void saveSettings(settings);
});
void showAppVersion();
}
function bindDataDirSettings(): void {
const dataDirBtn = $<HTMLButtonElement>("#set-datadir");
const renderDataDir = () => {
dataDirBtn.textContent = settings.dataDir ? settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir : t("defaultFolder");
dataDirBtn.title = settings.dataDir || t("defaultFolder");
};
renderDataDir();
dataDirBtn.addEventListener("click", async () => {
const picked = await chooseFolder();
if (!picked || picked === settings.dataDir) return;
let strategy: "move" | "overwrite" | "use_existing" = "move";
try {
if (await dataDirHasDocuments(picked)) {
const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine"));
if (ans === null) return;
strategy = ans === "a" ? "use_existing" : "overwrite";
}
// a debounced save still aimed at the old folder would land after the
// migration moved documents.json away and silently strand the last edit
await flushAppData();
await migrateDataDir(settings.dataDir, picked, strategy);
} catch (e) {
console.warn("migrate failed", e);
toast(t("folderError"));
return;
}
settings.dataDir = picked;
setDataDir(picked);
await saveSettings(settings);
renderDataDir();
if (strategy === "use_existing") {
const fresh = await loadAppData(picked);
if (fresh && fresh.docs.length > 0) {
data = fresh;
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
switchDoc(data.activeId);
}
}
toast(t("folderChanged"));
});
}
function bindSettingsNavigation(): void {
const tabs = document.querySelectorAll<HTMLButtonElement>("#settings-tabs .tab");
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
for (const tab of tabs) {
tab.addEventListener("click", () => {
for (const other of tabs) other.classList.toggle("active", other === tab);
for (const panel of tabPanels) panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab);
});
}
$("#open-settings").addEventListener("click", () => {
$("#settings-panel").classList.toggle("hidden");
});
$("#close-settings").addEventListener("click", () => {
$("#settings-panel").classList.add("hidden");
});
$("#open-extensions").addEventListener("click", () => void openExtensionsFolder());
$("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir));
$("#check-update").addEventListener("click", () => void checkUpdate(true));
}
function bindSettingsUI(): void {
bindFormattingSettings();
bindHotkeySettings();
bindBehaviorSettings();
bindDataDirSettings();
bindSettingsNavigation();
}
/** tauri.conf.json's version is the source of truth for an actual build; package.json is the vite-dev fallback */
async function showAppVersion(): Promise<void> {
let version = pkg.version;
if (isTauri()) {
const { getVersion } = await import("@tauri-apps/api/app");
version = await getVersion();
}
$("#app-version").textContent = `${t("version")} ${version}`;
}
/** Physical key (e.code) → Tauri accelerator name, layout-independent (works on ru). */
function normalizeKeyName(e: KeyboardEvent): string | null {
const code = e.code;
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit\d$/.test(code)) return code.slice(5);
if (/^Numpad\d$/.test(code)) return code;
if (/^F\d{1,2}$/.test(code)) return code;
const map: Record<string, string> = {
Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right",
Minus: "-", Equal: "=", Comma: ",", Period: ".", Slash: "/", Backquote: "`",
BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'",
};
return map[code] ?? null;
}
// ---------- tauri integration
async function registerHotkey(old: string | null, combo: string): Promise<boolean> {
if (!isTauri()) return true;
try {
const gs = await import("@tauri-apps/plugin-global-shortcut");
if (old) await gs.unregister(old).catch(() => {});
await gs.register(combo, async (e) => {
if (e.state !== "Pressed") return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
if (await win.isVisible()) {
if (document.hasFocus()) {
await win.hide();
} else {
await win.show();
await win.setFocus();
}
} else {
await win.show();
await win.setFocus();
}
});
return true;
} catch (e) {
console.warn("hotkey registration failed", e);
return false;
}
}
async function applyAutostart(enabled: boolean): Promise<void> {
if (!isTauri()) return;
try {
const auto = await import("@tauri-apps/plugin-autostart");
if (enabled) await auto.enable();
else await auto.disable();
} catch (e) {
console.warn("autostart failed", e);
}
}
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
if (!isTauri()) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e));
}
async function refreshRates(force = false): Promise<void> {
const info = $("#rates-info");
info.classList.add("spin");
const payload = await fetchRates(force);
info.classList.remove("spin");
if (payload) {
liveRates = Object.fromEntries(
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)])
);
applyAllRates();
workspace.invalidateAll();
editor.refresh();
ratesFetchedAt = payload.fetchedAt;
if (force) toast(t("ratesUpdated"));
}
renderRatesInfo();
}
// ---------- market data
// Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss.
const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"];
let marketPrices: Record<string, number> = {};
let marketFetchedAt = 0;
function applyAllRates(): void {
const marketRates: Record<string, number> = {};
for (const [sym, price] of Object.entries(marketPrices)) {
if (price > 0) marketRates[sym] = 1 / price;
}
engine.setRates({ ...liveRates, ...marketRates });
}
async function refreshMarket(force = false): Promise<void> {
if (!isTauri()) return;
const info = $("#market-info") as HTMLElement;
info.style.display = "";
info.classList.add("spin");
info.textContent = t("market");
const prices = await fetchMarketData(MARKET_SYMBOLS);
info.classList.remove("spin");
if (Object.keys(prices).length > 0) {
marketPrices = prices;
marketFetchedAt = Math.floor(Date.now() / 1000);
applyAllRates();
workspace.invalidateAll();
editor.refresh();
if (force) toast(t("marketUpdated"));
}
renderMarketInfo();
}
// ---------- historical rates
const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi;
async function fetchNeededHistoricalRates(text: string): Promise<void> {
const dates = new Set<string>();
for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!);
const datesToFetch = Array.from(dates).filter((date) => !engine.hasHistoricalRates(date));
let fetched = false;
if (datesToFetch.length > 0) {
const results = await fetchHistoricalRatesBatch(datesToFetch);
for (const date of datesToFetch) {
const rates = results[date];
if (rates) {
engine.setHistoricalRates(date, rates);
fetched = true;
}
}
}
if (fetched) { workspace.invalidateAll(); editor.refresh(); }
}
function renderMarketInfo(): void {
const el = $("#market-info") as HTMLElement;
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
el.style.display = "";
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
el.title = t("refreshRates");
}
// ---------- status bar
/** total of the selection (≥2 lines) or of the whole sheet */
function renderTotal(): void {
const line = $("#total-line");
let results = lastResults;
let label = t("total");
if (selectedRange && selectedRange[1] > selectedRange[0]) {
results = lastResults.slice(selectedRange[0], selectedRange[1] + 1);
label = t("selection");
}
const total = engine.totalOf(results);
if (total) {
line.innerHTML = "";
const lbl = document.createElement("span");
lbl.className = "total-label";
lbl.textContent = label;
line.append(lbl, document.createTextNode(total));
line.dataset.value = total;
line.style.display = "";
} else {
line.style.display = "none";
delete line.dataset.value;
}
}
function agoText(unixSec: number): string {
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
if (s < 90) return t("justNow");
if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
if (s < 86400) return t("hourAgo").replace("{}", String(Math.round(s / 3600)));
return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
}
function renderRatesInfo(): void {
const el = $("#rates-info");
if (ratesFetchedAt > 0) {
el.textContent = `${t("rates")}: ${agoText(ratesFetchedAt)}`;
el.title = t("refreshRates");
} else {
el.textContent = `${t("rates")}: ⚠ offline`;
el.title = t("ratesOffline");
}
}
// ---------- modal
let modalChain: Promise<unknown> = Promise.resolve();
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
calls serialize on the shared #modal singleton — a second call waits for
the first to resolve rather than clobbering its handlers */
function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
const run = modalChain.then(() => showModal(msg, aLabel, bLabel));
modalChain = run.catch(() => {});
return run;
}
function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
return new Promise((resolve) => {
const modal = $("#modal");
const a = $("#modal-a");
const b = $("#modal-b");
$("#modal-msg").textContent = msg;
a.textContent = aLabel;
b.textContent = bLabel;
a.classList.add("primary");
modal.classList.remove("hidden");
const done = (res: "a" | "b" | null) => {
modal.classList.add("hidden");
a.onclick = b.onclick = modal.onclick = null;
resolve(res);
};
a.onclick = () => done("a");
b.onclick = () => done("b");
modal.onclick = (e) => {
// a click that just refocused the window (native dialog closing, or
// bringing a backgrounded tray window to front) lands on the backdrop
// too — ignore a backdrop dismiss that arrives right after refocus
if (e.target === modal && performance.now() - lastWindowFocusAt > 300) done(null);
};
});
}
// ---------- toast
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function toast(msg: string): void {
const el = $("#toast");
el.textContent = msg;
el.classList.remove("hidden");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.add("hidden"), 1200);
}
// ---------- boot
async function initSettings(): Promise<void> {
settings = await loadSettings();
if (!localStorage.getItem("summarum.langInit") && settings.language === "en") {
settings.language = detectLang();
localStorage.setItem("summarum.langInit", "1");
}
// snapshots run before the app writes anything
setDataDir(settings.dataDir);
await runBackups(settings.dataDir, settings.deletedRetentionDays);
}
async function initDataAndEngine(): Promise<void> {
engine = new SumEngine({
precision: settings.precision,
groupSeparator: settings.groupSeparator,
decimalSeparator: settings.decimalSeparator,
dateFormat: settings.dateFormat,
});
const stored = await loadAppData(settings.dataDir);
// a corrupt or foreign documents.json must not crash the boot
if (stored && Array.isArray(stored.docs) && stored.docs.length > 0 && stored.contents && typeof stored.contents === "object") {
data = stored;
} else {
const id = uid();
data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: welcomeText(settings.language) } };
}
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
const scripts = await loadExtensionScripts();
await runExtensions(engine, scripts);
workspace = new Workspace(engine, () =>
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
);
editor = new SumEditor(
$("#editor"),
$("#results"),
engine,
{
onChange(text) {
const doc = activeDoc();
data.contents[doc.id] = text;
workspace.invalidate(doc.id);
if (!doc.customTitle) {
doc.title = titleFromContent(text);
syncTitleField();
}
renderDocList();
saveAppData(data);
void fetchNeededHistoricalRates(text);
},
onCopy(text) {
void navigator.clipboard.writeText(text);
toast(t("copied"));
},
onResults(results) {
lastResults = results;
renderTotal();
},
onSelection(range) {
selectedRange = range;
renderTotal();
},
},
(text) => workspace.evaluateSheet(activeDoc().id, text),
data.contents[data.activeId] ?? "",
);
// the editor above was seeded directly with initialText (not via switchDoc),
// so a reopened sheet with "on 2024-01-01" needs its own historical-rate fetch
void fetchNeededHistoricalRates(data.contents[data.activeId] ?? "");
search = initSearch({
engine,
workspace,
docs: () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
t,
onOpen: (docId, line) => {
if (docId !== data.activeId) switchDoc(docId);
editor.goToLine(line);
},
});
}
function bindTitleUI(): void {
const titleField = $<HTMLInputElement>("#doc-title");
titleField.addEventListener("change", () => {
const doc = activeDoc();
const oldTitle = doc.title;
const v = titleField.value.trim();
if (v) {
doc.title = v.slice(0, 60);
doc.customTitle = true;
const rewrites = workspace.renameSheet(doc.id, oldTitle, doc.title);
for (const r of rewrites) data.contents[r.id] = r.text;
workspace.invalidateAll();
} else {
doc.customTitle = false;
doc.title = titleFromContent(data.contents[doc.id] ?? "");
workspace.invalidateAll();
}
syncTitleField();
renderDocList();
saveAppData(data);
});
titleField.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === "Escape") titleField.blur();
});
}
function bindMiscUI(): void {
$("#total-line").addEventListener("click", () => {
const v = $("#total-line").dataset.value;
if (v) {
void navigator.clipboard.writeText(v);
toast(t("copied"));
}
});
$("#open-search").addEventListener("click", () => search.open());
window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); });
}
async function bindWindowControls(): Promise<void> {
if (isTauri()) {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
$("#win-min").addEventListener("click", () => void win.minimize());
$("#win-close").addEventListener("click", () => void win.hide());
} else {
$("#win-controls").style.display = "none";
}
}
function bindSidebarUI(): void {
$("#toggle-sidebar").addEventListener("click", () => {
settings.sidebarVisible = !settings.sidebarVisible;
$("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
void saveSettings(settings);
});
$("#new-doc").addEventListener("click", () => newDoc());
document.addEventListener("mousedown", (e) => {
const target = e.target as HTMLElement;
const panel = $("#settings-panel");
if (!panel.classList.contains("hidden") && !target.closest("#settings-panel") && !target.closest("#open-settings")) {
panel.classList.add("hidden");
}
if (!settings.sidebarVisible) return;
if (target.closest("#sidebar") || target.closest("#toggle-sidebar")) return;
settings.sidebarVisible = false;
$("#sidebar").classList.add("hidden");
void saveSettings(settings);
});
}
function bindDividerUI(): void {
const divider = $("#col-divider");
const wrap = $("#editor-wrap");
divider.addEventListener("mousedown", (e) => {
e.preventDefault();
divider.classList.add("dragging");
document.body.style.cursor = "col-resize";
const onMove = (ev: MouseEvent) => {
const rect = wrap.getBoundingClientRect();
const pct = ((rect.right - ev.clientX) / rect.width) * 100;
settings.resultsWidth = Math.round(Math.max(20, Math.min(60, pct)));
wrap.style.setProperty("--results-width", `${settings.resultsWidth}%`);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
divider.classList.remove("dragging");
document.body.style.cursor = "";
$<HTMLInputElement>("#set-resultswidth").value = String(settings.resultsWidth);
void saveSettings(settings);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
});
}
function bindKeyboardShortcuts(): void {
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault();
newDoc();
}
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault();
void closeActiveDoc();
}
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") {
e.preventDefault();
$("#toggle-sidebar").click();
}
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
search.open();
}
if (e.ctrlKey && e.shiftKey && e.code === "KeyC") {
e.preventDefault();
void navigator.clipboard.writeText(editor.getSheetWithResults());
toast(t("copied"));
}
});
}
function bindExportUI(): void {
const exportBtn = $("#export-btn");
const exportMenu = $("#export-menu");
exportBtn.addEventListener("click", (e) => {
e.stopPropagation();
exportMenu.classList.toggle("hidden");
});
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
exportMenu.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action]");
if (!btn) return;
exportMenu.classList.add("hidden");
const action = btn.dataset.action;
if (action === "image") {
void exportSheetImage({
lines: editor.getText().split("\n"),
results: workspace.evaluateSheet(activeDoc().id, editor.getText()),
fontSize: settings.fontSize,
toast,
t,
});
} else if (action === "copy") {
void navigator.clipboard.writeText(editor.getSheetWithResults());
toast(t("copied"));
} else if (action === "print") {
window.print();
} else if (isTauri()) {
const { invoke } = await import("@tauri-apps/api/core");
const isSum = action === "save-sum";
const content = isSum ? editor.getText() : editor.getSheetWithResults();
try {
const saved = await invoke<boolean>("write_text_file", { contents: content, isSum });
if (saved) {
toast(t("saved"));
}
} catch {
toast(t("imageFailed"));
}
}
});
}
function bindRatesAndMarketUI(): void {
$("#rates-info").addEventListener("click", () => void refreshRates(true));
$("#market-info").addEventListener("click", () => void refreshMarket(true));
setInterval(renderRatesInfo, 60_000);
setInterval(renderMarketInfo, 60_000);
}
async function bindLifecycle(): Promise<void> {
void onFileDrop((content) => newDoc(content));
if (!(await registerHotkey(null, settings.hotkey))) toast(t("hotkeyFailed"));
if (settings.autostart) await applyAutostart(true);
if (settings.alwaysOnTop) await applyAlwaysOnTop(true);
void onAppQuit(() => flushAppData());
void refreshRates();
setInterval(() => void refreshRates(), 60 * 60 * 1000);
void refreshMarket();
setInterval(() => void refreshMarket(), 15 * 60 * 1000);
const launched = await getLaunchFile();
if (launched) newDoc(launched);
void onOpenFile((content) => newDoc(content));
editor.focus();
if (isTauri()) {
const { invoke } = await import("@tauri-apps/api/core");
const hidden = await invoke<boolean>("is_hidden_launch");
if (!hidden) {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().show();
}
}
if (settings.autoUpdateEnabled) void checkUpdate();
// a tray app can stay running for weeks without a cold restart, and the
// update check above only ever runs once at boot — recheck periodically
// so a long-lived process still notices new releases; re-read the setting
// on every tick since the user can flip it while the app is running
setInterval(() => { if (settings.autoUpdateEnabled) void checkUpdate(); }, 6 * 60 * 60 * 1000);
}
async function initUI(): Promise<void> {
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
bindSettingsUI();
renderDocList();
syncTitleField();
bindTitleUI();
bindMiscUI();
await bindWindowControls();
bindSidebarUI();
bindDividerUI();
bindKeyboardShortcuts();
bindExportUI();
bindRatesAndMarketUI();
await bindLifecycle();
}
async function boot(): Promise<void> {
await initSettings();
await initDataAndEngine();
await initUI();
}
/** manual = true when the user clicked "Check for updates" — only then report "up to date" / "check failed" */
async function checkUpdate(manual = false): Promise<void> {
const result = await checkForUpdate();
if (result === "error") {
if (manual) toast(t("updateCheckFailed"));
return;
}
if (!result) {
if (manual) toast(t("upToDate"));
return;
}
const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater"));
if (choice !== "a") return;
toast(t("updateInstalling"));
try {
await result.install();
} catch {
toast(t("updateFailed"));
}
}
void boot();