diff --git a/src/engine/index.ts b/src/engine/index.ts index 2cc981e..1e75483 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,18 @@ +import { logger } from "../logger"; // Public facade: evaluates whole documents line by line and exposes the // extension API (numi.addUnit / addFunction / setVariable). -import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; +import { + DateOrder, + Decimal, + EngineSettings, + EvalError, + Quantity, + Unit, + Value, + XRefError, + defaultSettings, + qty, +} from "./types"; import { Registry, buildRegistry, Completion } from "./registry"; import { tokenize, Token } from "./tokenizer"; import { parseLine } from "./parser"; @@ -84,7 +96,8 @@ export class SumEngine { updateSettings(patch: Partial): void { this.settings = { ...this.settings, ...patch }; - if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat); + if (patch.dateFormat) + this.dateOrder = resolveDateOrder(this.settings.dateFormat); } completions(): Completion[] { @@ -106,13 +119,25 @@ export class SumEngine { if (trimmed.length === 0) { const kind = commentStart >= 0 ? "comment" : "empty"; - results.push({ text: null, value: null, kind, tokens: [], commentStart: commentStart >= 0 ? commentStart : null }); + results.push({ + text: null, + value: null, + kind, + tokens: [], + commentStart: commentStart >= 0 ? commentStart : null, + }); lineValues.push(null); lineKinds.push("empty"); continue; } if (trimmed.startsWith("#")) { - results.push({ text: null, value: null, kind: "header", tokens: [], commentStart: commentStart >= 0 ? commentStart : null }); + results.push({ + text: null, + value: null, + kind: "header", + tokens: [], + commentStart: commentStart >= 0 ? commentStart : null, + }); lineValues.push(null); lineKinds.push("header"); continue; @@ -144,7 +169,7 @@ export class SumEngine { } else { // Surface unexpected bugs/BigInt errors and log them lineError = e instanceof Error ? e.message : String(e); - console.warn("evaluate failed:", e); + logger.warn("evaluate failed:", e); } value = null; } @@ -155,7 +180,10 @@ export class SumEngine { lineValues.push(value); lineKinds.push("normal"); results.push({ - text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null, + text: + value && value.kind !== "chart" + ? formatValue(value, this.settings) + : null, value, kind: "normal", tokens, @@ -213,8 +241,11 @@ export class SumEngine { } addUnit(spec: ExtensionUnitSpec): void { - const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId); - if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`); + const base = + this.reg.unitsById.get(spec.baseUnitId) ?? + this.currencyBase(spec.baseUnitId); + if (!base) + throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`); const unit: Unit = { id: spec.id, dimension: base.dimension, @@ -225,12 +256,24 @@ export class SumEngine { this.reg.unitsById.set(unit.id, unit); for (const p of spec.phrases.split(",")) { const phrase = p.trim(); - if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false }); + if (phrase) + this.reg.addPhrase( + phrase, + { t: "unit", unit }, + { caseSensitive: false }, + ); } - this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" }); + this.reg.completions.push({ + label: spec.id, + type: "unit", + detail: "extension", + }); } - addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void { + addFunction( + spec: { id: string; phrases: string }, + fn: (values: ExtensionValue[]) => ExtensionValue | number, + ): void { const name = spec.id; this.reg.customFuncs.set(name, (args: Value[]) => { const mapped = args.map((a) => this.fromValue(a)); @@ -238,26 +281,42 @@ export class SumEngine { }); for (const p of (spec.phrases || spec.id).split(",")) { const phrase = p.trim(); - if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false }); + if (phrase) + this.reg.addPhrase( + phrase, + { t: "func", name }, + { caseSensitive: false }, + ); } - this.reg.completions.push({ label: name, type: "function", detail: "extension" }); + this.reg.completions.push({ + label: name, + type: "function", + detail: "extension", + }); } private currencyBase(code: string): Unit | null { - return this.reg.currencyCodes.has(code) ? this.reg.makeCurrencyUnit(code) : null; + return this.reg.currencyCodes.has(code) + ? this.reg.makeCurrencyUnit(code) + : null; } private toValue(v: number | ExtensionValue): Value { if (typeof v === "number") return qty(v); const unit = v.unitId - ? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null + ? (this.reg.unitsById.get(v.unitId) ?? + this.currencyBase(v.unitId) ?? + null) : null; return qty(new Decimal(v.double), unit); } private fromValue(v: Value): ExtensionValue { if (v.kind === "quantity") { - return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) }; + return { + double: v.value.toNumber(), + ...(v.unit ? { unitId: v.unit.id } : {}), + }; } if (v.kind === "percent") return { double: v.value.div(100).toNumber() }; if (v.kind === "date") return { double: v.ms }; diff --git a/src/extensions.test.ts b/src/extensions.test.ts index 104eda9..7e296a2 100644 --- a/src/extensions.test.ts +++ b/src/extensions.test.ts @@ -1,3 +1,12 @@ +import { logger } from "./logger"; + +vi.mock("./logger", () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { makeApi, runExtensions } from "./extensions"; import { SumEngine } from "./engine"; @@ -6,6 +15,7 @@ describe("extensions", () => { let mockEngine: any; beforeEach(() => { + vi.clearAllMocks(); mockEngine = { setVariable: vi.fn(), addUnit: vi.fn(), @@ -22,7 +32,12 @@ describe("extensions", () => { it("delegates addUnit to engine", () => { const api = makeApi(mockEngine as SumEngine); - const spec = { id: "testUnit", phrases: "tests", baseUnitId: "USD", ratio: 1 }; + const spec = { + id: "testUnit", + phrases: "tests", + baseUnitId: "USD", + ratio: 1, + }; api.addUnit(spec); expect(mockEngine.addUnit).toHaveBeenCalledWith(spec); }); @@ -37,17 +52,11 @@ describe("extensions", () => { }); describe("runExtensions", () => { - let consoleErrorSpy: any; - let consoleWarnSpy: any; - - beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); + beforeEach(() => {}); afterEach(() => { - consoleErrorSpy.mockRestore(); - consoleWarnSpy.mockRestore(); + vi.mocked(logger.error).mockRestore(); + vi.mocked(logger.warn).mockRestore(); vi.resetModules(); vi.doUnmock("quickjs-emscripten-core"); }); @@ -55,7 +64,10 @@ describe("extensions", () => { it("runs scripts successfully using numi api", async () => { await runExtensions(mockEngine as SumEngine, [ { name: "script1", code: "numi.setVariable('var1', 10);" }, - { name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" }, + { + name: "script2", + code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });", + }, ]); expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10); @@ -65,14 +77,21 @@ describe("extensions", () => { baseUnitId: "USD", ratio: 1, }); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(vi.mocked(logger.error)).not.toHaveBeenCalled(); }); - it("swallows errors and logs to console.error", async () => { - await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]); + it("swallows errors and logs to logger.error", async () => { + await runExtensions(mockEngine as SumEngine, [ + { name: "script1", code: "throw new Error('test error');" }, + ]); - expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error)); - expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error"); + expect(vi.mocked(logger.error)).toHaveBeenCalledWith( + "extension script1 failed:", + expect.any(Error), + ); + expect(vi.mocked(logger.error).mock.calls[0][1].message).toContain( + "test error", + ); }); it("continues running other scripts if one fails", async () => { @@ -84,22 +103,32 @@ describe("extensions", () => { expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10); expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(logger.error)).toHaveBeenCalledTimes(1); }); it("passes unit-carrying values through setVariable", async () => { await runExtensions(mockEngine as SumEngine, [ - { name: "s", code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });" }, + { + name: "s", + code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });", + }, ]); - expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { double: 1200, unitId: "USD" }); + expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { + double: 1200, + unitId: "USD", + }); }); it("rejects malformed api arguments as a script error", async () => { - await runExtensions(mockEngine as SumEngine, [{ name: "bad", code: "numi.setVariable('x', 'nope');" }]); + await runExtensions(mockEngine as SumEngine, [ + { name: "bad", code: "numi.setVariable('x', 'nope');" }, + ]); expect(mockEngine.setVariable).not.toHaveBeenCalled(); - expect(consoleErrorSpy.mock.calls[0][1].message).toContain("numi.setVariable"); + expect(vi.mocked(logger.error).mock.calls[0][1].message).toContain( + "numi.setVariable", + ); }); it("registers a function callable with one array of values", async () => { @@ -110,7 +139,10 @@ describe("extensions", () => { }, ]); - expect(mockEngine.addFunction).toHaveBeenCalledWith({ id: "hyp", phrases: "hyp" }, expect.any(Function)); + expect(mockEngine.addFunction).toHaveBeenCalledWith( + { id: "hyp", phrases: "hyp" }, + expect.any(Function), + ); const fn = mockEngine.addFunction.mock.calls[0][1]; expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 }); expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 }); @@ -125,15 +157,21 @@ describe("extensions", () => { ]); const fn = mockEngine.addFunction.mock.calls[0][1]; - expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ double: 10, unitId: "meter" }); + expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ + double: 10, + unitId: "meter", + }); }); it("has no host globals inside the sandbox", async () => { await runExtensions(mockEngine as SumEngine, [ - { name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');" }, + { + name: "probe", + code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');", + }, ]); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(vi.mocked(logger.error)).not.toHaveBeenCalled(); }); it("interrupts an infinite loop at load time", async () => { @@ -142,8 +180,10 @@ describe("extensions", () => { { name: "after", code: "numi.setVariable('ok', 1);" }, ]); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:"); + expect(vi.mocked(logger.error)).toHaveBeenCalledTimes(1); + expect(vi.mocked(logger.error).mock.calls[0][0]).toBe( + "extension spin failed:", + ); // the runtime survives the interrupt and keeps loading scripts expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1); }); @@ -172,7 +212,10 @@ describe("extensions", () => { it("surfaces a bad return value as a function error", async () => { await runExtensions(mockEngine as SumEngine, [ - { name: "bad", code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });" }, + { + name: "bad", + code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });", + }, ]); const fn = mockEngine.addFunction.mock.calls[0][1]; @@ -182,14 +225,17 @@ describe("extensions", () => { it("skips extensions when the sandbox fails to load", async () => { vi.resetModules(); vi.doMock("quickjs-emscripten-core", () => ({ - newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")), + newQuickJSWASMModuleFromVariant: () => + Promise.reject(new Error("no WebAssembly")), })); const { runExtensions: run } = await import("./extensions"); - await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]); + await run(mockEngine as SumEngine, [ + { name: "s", code: "numi.setVariable('x', 1);" }, + ]); expect(mockEngine.setVariable).not.toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalledWith( + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( "extensions disabled: QuickJS sandbox unavailable:", expect.any(Error), ); @@ -197,8 +243,8 @@ describe("extensions", () => { it("does not load the sandbox when there are no scripts", async () => { await runExtensions(mockEngine as SumEngine, []); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(vi.mocked(logger.warn)).not.toHaveBeenCalled(); + expect(vi.mocked(logger.error)).not.toHaveBeenCalled(); }); }); }); diff --git a/src/extensions.ts b/src/extensions.ts index 0722e68..b4f2cd9 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -1,3 +1,4 @@ +import { logger } from "./logger"; // JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction. // Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals. import { @@ -75,7 +76,7 @@ export async function runExtensions( try { await load(engine, scripts); } catch (e) { - console.warn("extensions disabled: QuickJS sandbox unavailable:", e); + logger.warn("extensions disabled: QuickJS sandbox unavailable:", e); } } @@ -200,7 +201,7 @@ function evaluateScripts( } res.value.dispose(); } catch (e) { - console.error(`extension ${s.name} failed:`, e); + logger.error(`extension ${s.name} failed:`, e); } } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..14c113f --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,11 @@ +/** + * Simple logger utility to replace console.log/warn/error/info calls + * in production code, allowing for better observability, filtering, + * or future integrations (e.g. sending logs to a backend or Tauri rust layer). + */ +export const logger = { + warn: (...args: any[]) => console.warn(...args), + error: (...args: any[]) => console.error(...args), + info: (...args: any[]) => console.info(...args), + log: (...args: any[]) => console.log(...args), +}; diff --git a/src/main.ts b/src/main.ts index 6102e50..abf6be0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,35 @@ +import { logger } from "./logger"; /** 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, + 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"; @@ -22,10 +43,13 @@ 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"; + return ( + (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n" + ); } -const $ = (sel: string): T => document.querySelector(sel) as T; +const $ = (sel: string): T => + document.querySelector(sel) as T; let engine: SumEngine; let editor: SumEditor; @@ -74,13 +98,22 @@ function pinDoc(id: string): void { // 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; } } + 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); + data.docs.splice( + firstNonPinned === -1 ? data.docs.length : firstNonPinned, + 0, + doc, + ); } saveAppData(data); renderDocList(); @@ -99,7 +132,12 @@ function reorderDoc(srcId: string, targetId: string): void { renderDocList(); } -function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, data: AppData): void { +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 @@ -110,7 +148,8 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d 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; + let cachedItems: { el: HTMLElement; top: number; bottom: number }[] | null = + null; const onMove = (ev: MouseEvent) => { if (!dragging) { if (Math.abs(ev.clientY - startY) < 4) return; @@ -137,10 +176,13 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d } if (over) { const overDoc = data.docs.find((d) => d.id === over.dataset.docId); - if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over"); + if (overDoc && !!overDoc.pinned === !!doc.pinned) + over.classList.add("drag-over"); } }; - const onScroll = () => { cachedItems = null; }; + const onScroll = () => { + cachedItems = null; + }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); @@ -148,7 +190,8 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d cachedItems = null; if (dragging) { const target = list.querySelector(".doc-item.drag-over"); - for (const item of list.querySelectorAll(".doc-item")) item.classList.remove("drag-over", "dragging"); + 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); } @@ -159,22 +202,37 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d list.addEventListener("scroll", onScroll); }); el.addEventListener("click", () => { - if (suppressClick) { suppressClick = false; return; } + if (suppressClick) { + suppressClick = false; + return; + } switchDoc(doc.id); }); } -function mkBtn(cls: string, text: string, title: string, cb: () => void): HTMLButtonElement { +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(); }); + b.addEventListener("click", (e) => { + e.stopPropagation(); + cb(); + }); return b; } -function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: SettingsData): HTMLButtonElement { +function createDeleteButton( + doc: DocMeta, + appData: AppData, + appSettings: SettingsData, +): HTMLButtonElement { const del = document.createElement("button"); del.className = "del"; del.textContent = "✕"; @@ -192,7 +250,11 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting return; } if (confirmTimer) clearTimeout(confirmTimer); - void backupDeletedSheet(appSettings.dataDir, doc.title, appData.contents[doc.id] ?? ""); + 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); @@ -208,13 +270,23 @@ function renderDocList(): void { 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" : ""); + 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))); + 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; @@ -238,7 +310,10 @@ function switchDoc(id: string): void { function newDoc(content = ""): void { const id = uid(); - data.docs.push({ id, title: content ? titleFromContent(content) : t("untitled") }); + data.docs.push({ + id, + title: content ? titleFromContent(content) : t("untitled"), + }); data.contents[id] = content; switchDoc(id); } @@ -256,7 +331,9 @@ async function closeActiveDoc(): Promise { 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; + const nextId = data.docs[idx] + ? data.docs[idx].id + : (data.docs[idx - 1]?.id ?? data.docs[0].id); switchDoc(nextId); saveAppData(data); } @@ -264,12 +341,21 @@ async function closeActiveDoc(): Promise { // ---------- settings function applySettings(): void { - const theme = settings.theme === "system" - ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") - : settings.theme; + 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}%`); + document.documentElement.style.setProperty( + "--editor-font-size", + `${settings.fontSize}px`, + ); + $("#editor-wrap").style.setProperty( + "--results-width", + `${settings.resultsWidth}%`, + ); setLang(settings.language); const searchInput = $("#search-input"); searchInput.placeholder = t("searchPlaceholder"); @@ -282,7 +368,10 @@ function applySettings(): void { }); editor?.refresh(); syncTitleField(); - $("#sidebar").classList.toggle("hidden", !settings.sidebarVisible); + $("#sidebar").classList.toggle( + "hidden", + !settings.sidebarVisible, + ); } function bindFormattingSettings(): void { @@ -306,7 +395,10 @@ function bindFormattingSettings(): void { const save = () => { settings.theme = themeSel.value as SettingsData["theme"]; - settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2)); + 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 @@ -316,13 +408,27 @@ function bindFormattingSettings(): void { } 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)); + 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]) { + for (const el of [ + themeSel, + precision, + groupSep, + decimalSep, + dateFmt, + langSel, + fontSize, + ]) { el.addEventListener("change", save); } resultsWidth.addEventListener("input", save); // live while sliding @@ -401,7 +507,10 @@ function bindBehaviorSettings(): void { void saveSettings(settings); }); binDays.addEventListener("change", () => { - settings.deletedRetentionDays = Math.max(1, Math.min(365, Math.round(Number(binDays.value)) || 14)); + 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 @@ -417,7 +526,9 @@ function bindBehaviorSettings(): void { function bindDataDirSettings(): void { const dataDirBtn = $("#set-datadir"); const renderDataDir = () => { - dataDirBtn.textContent = settings.dataDir ? settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir : t("defaultFolder"); + dataDirBtn.textContent = settings.dataDir + ? (settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir) + : t("defaultFolder"); dataDirBtn.title = settings.dataDir || t("defaultFolder"); }; renderDataDir(); @@ -426,14 +537,18 @@ function bindDataDirSettings(): void { if (!picked || picked === settings.dataDir) return; let strategy: "move" | "overwrite" | "use_existing" = "move"; if (await dataDirHasDocuments(picked)) { - const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine")); + const ans = await askModal( + t("folderConflict"), + t("useExisting"), + t("replaceMine"), + ); if (ans === null) return; strategy = ans === "a" ? "use_existing" : "overwrite"; } try { await migrateDataDir(settings.dataDir, picked, strategy); } catch (e) { - console.warn("migrate failed", e); + logger.warn("migrate failed", e); toast(t("folderError")); return; } @@ -445,7 +560,8 @@ function bindDataDirSettings(): void { 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; + if (!data.docs.some((d) => d.id === data.activeId)) + data.activeId = data.docs[0].id; switchDoc(data.activeId); } } @@ -454,12 +570,19 @@ function bindDataDirSettings(): void { } function bindSettingsNavigation(): void { - const tabs = document.querySelectorAll("#settings-tabs .tab"); - const tabPanels = document.querySelectorAll(".settings-tabpanel"); + const tabs = document.querySelectorAll( + "#settings-tabs .tab", + ); + const tabPanels = + document.querySelectorAll(".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); + for (const panel of tabPanels) + panel.classList.toggle( + "hidden", + panel.dataset.tabpanel !== tab.dataset.tab, + ); }); } @@ -469,8 +592,14 @@ function bindSettingsNavigation(): void { $("#close-settings").addEventListener("click", () => { $("#settings-panel").classList.add("hidden"); }); - $("#open-extensions").addEventListener("click", () => void openExtensionsFolder()); - $("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir)); + $("#open-extensions").addEventListener( + "click", + () => void openExtensionsFolder(), + ); + $("#open-backups").addEventListener( + "click", + () => void openBackupsFolder(settings.dataDir), + ); $("#check-update").addEventListener("click", () => void checkUpdate(true)); } @@ -500,18 +629,39 @@ function normalizeKeyName(e: KeyboardEvent): string | null { if (/^Numpad\d$/.test(code)) return code; if (/^F\d{1,2}$/.test(code)) return code; const map: Record = { - 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: "'", + 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 { +async function registerHotkey( + old: string | null, + combo: string, +): Promise { if (!isTauri()) return true; try { const gs = await import("@tauri-apps/plugin-global-shortcut"); @@ -534,7 +684,7 @@ async function registerHotkey(old: string | null, combo: string): Promise { if (enabled) await auto.enable(); else await auto.disable(); } catch (e) { - console.warn("autostart failed", e); + logger.warn("autostart failed", e); } } async function applyAlwaysOnTop(enabled: boolean): Promise { if (!isTauri()) return; const { getCurrentWindow } = await import("@tauri-apps/api/window"); - await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e)); + await getCurrentWindow() + .setAlwaysOnTop(enabled) + .catch((e) => logger.warn("always-on-top failed", e)); } async function refreshRates(force = false): Promise { @@ -563,7 +715,7 @@ async function refreshRates(force = false): Promise { info.classList.remove("spin"); if (payload) { liveRates = Object.fromEntries( - Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]) + Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]), ); applyAllRates(); workspace.invalidateAll(); @@ -577,7 +729,15 @@ async function refreshRates(force = false): Promise { // ---------- market data // Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss. -const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"]; +const MARKET_SYMBOLS = [ + "AAPL", + "MSFT", + "GOOGL", + "AMZN", + "TSLA", + "NVDA", + "META", +]; let marketPrices: Record = {}; let marketFetchedAt = 0; @@ -616,7 +776,9 @@ const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi; async function fetchNeededHistoricalRates(text: string): Promise { const dates = new Set(); for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!); - const datesToFetch = Array.from(dates).filter((date) => !engine.hasHistoricalRates(date)); + const datesToFetch = Array.from(dates).filter( + (date) => !engine.hasHistoricalRates(date), + ); let fetched = false; if (datesToFetch.length > 0) { const results = await fetchHistoricalRatesBatch(datesToFetch); @@ -628,12 +790,18 @@ async function fetchNeededHistoricalRates(text: string): Promise { } } } - if (fetched) { workspace.invalidateAll(); editor.refresh(); } + if (fetched) { + workspace.invalidateAll(); + editor.refresh(); + } } function renderMarketInfo(): void { const el = $("#market-info") as HTMLElement; - if (marketFetchedAt === 0) { el.style.display = "none"; return; } + if (marketFetchedAt === 0) { + el.style.display = "none"; + return; + } el.style.display = ""; el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`; el.title = t("refreshRates"); @@ -669,7 +837,8 @@ 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))); + if (s < 86400) + return t("hourAgo").replace("{}", String(Math.round(s / 3600))); return t("dayAgo").replace("{}", String(Math.round(s / 86400))); } @@ -691,13 +860,21 @@ let modalChain: Promise = 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> { +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> { +function showModal( + msg: string, + aLabel: string, + bLabel: string, +): Promise<"a" | "b" | null> { return new Promise((resolve) => { const modal = $("#modal"); const a = $("#modal-a"); @@ -718,7 +895,8 @@ function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | " // 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); + if (e.target === modal && performance.now() - lastWindowFocusAt > 300) + done(null); }; }); } @@ -738,7 +916,10 @@ function toast(msg: string): void { async function initSettings(): Promise { settings = await loadSettings(); - if (!localStorage.getItem("summarum.langInit") && settings.language === "en") { + if ( + !localStorage.getItem("summarum.langInit") && + settings.language === "en" + ) { settings.language = detectLang(); localStorage.setItem("summarum.langInit", "1"); } @@ -758,19 +939,34 @@ async function initDataAndEngine(): Promise { 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") { + 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) } }; + 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; + 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] ?? "" })), + data.docs.map((d) => ({ + id: d.id, + title: d.title, + text: data.contents[d.id] ?? "", + })), ); editor = new SumEditor( @@ -813,7 +1009,12 @@ async function initDataAndEngine(): Promise { search = initSearch({ engine, workspace, - docs: () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })), + 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); @@ -859,7 +1060,9 @@ function bindMiscUI(): void { $("#open-search").addEventListener("click", () => search.open()); - window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); }); + window.addEventListener("focus", () => { + lastWindowFocusAt = performance.now(); + }); } async function bindWindowControls(): Promise { @@ -884,7 +1087,11 @@ function bindSidebarUI(): void { 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")) { + if ( + !panel.classList.contains("hidden") && + !target.closest("#settings-panel") && + !target.closest("#open-settings") + ) { panel.classList.add("hidden"); } if (!settings.sidebarVisible) return; @@ -913,7 +1120,9 @@ function bindDividerUI(): void { window.removeEventListener("mouseup", onUp); divider.classList.remove("dragging"); document.body.style.cursor = ""; - $("#set-resultswidth").value = String(settings.resultsWidth); + $("#set-resultswidth").value = String( + settings.resultsWidth, + ); void saveSettings(settings); }; window.addEventListener("mousemove", onMove); @@ -956,7 +1165,9 @@ function bindExportUI(): void { }); document.addEventListener("click", () => exportMenu.classList.add("hidden")); exportMenu.addEventListener("click", async (e) => { - const btn = (e.target as HTMLElement).closest("[data-action]"); + const btn = (e.target as HTMLElement).closest( + "[data-action]", + ); if (!btn) return; exportMenu.classList.add("hidden"); const action = btn.dataset.action; @@ -978,7 +1189,10 @@ function bindExportUI(): void { const isSum = action === "save-sum"; const content = isSum ? editor.getText() : editor.getSheetWithResults(); try { - const saved = await invoke("write_text_file", { contents: content, isSum }); + const saved = await invoke("write_text_file", { + contents: content, + isSum, + }); if (saved) { toast(t("saved")); } @@ -1028,11 +1242,15 @@ async function bindLifecycle(): Promise { // 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); + setInterval( + () => { + if (settings.autoUpdateEnabled) void checkUpdate(); + }, + 6 * 60 * 60 * 1000, + ); } async function initUI(): Promise { - applySettings(); // sets the language before bindSettingsUI renders dynamic labels bindSettingsUI(); renderDocList(); @@ -1066,7 +1284,11 @@ async function checkUpdate(manual = false): Promise { if (manual) toast(t("upToDate")); return; } - const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater")); + const choice = await askModal( + t("updateAvailable").replace("{}", result.version), + t("updateInstall"), + t("updateLater"), + ); if (choice !== "a") return; toast(t("updateInstalling")); try { diff --git a/src/storage.test.ts b/src/storage.test.ts index 35e00b2..44fa51c 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -1,157 +1,219 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage'; -import { invoke } from '@tauri-apps/api/core'; +import { logger } from "./logger"; -vi.mock('@tauri-apps/api/core', () => ({ +vi.mock("./logger", () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isTauri, + loadSettings, + saveSettings, + defaultSettingsData, + loadAppData, + saveAppData, + flushAppData, + setDataDir, + runBackups, + backupDeletedSheet, + openBackupsFolder, + chooseFolder, + dataDirHasDocuments, + migrateDataDir, + fetchRates, + fetchHistoricalRates, +} from "./storage"; +import { invoke } from "@tauri-apps/api/core"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); -describe('storage', () => { +describe("storage", () => { beforeEach(() => { vi.resetAllMocks(); - vi.stubGlobal('window', {}); - vi.stubGlobal('localStorage', { + vi.stubGlobal("window", {}); + vi.stubGlobal("localStorage", { getItem: vi.fn(), setItem: vi.fn(), }); - vi.stubGlobal('console', { warn: vi.fn() }); - vi.stubGlobal('fetch', vi.fn()); + vi.stubGlobal("console", { warn: vi.fn() }); + vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); - describe('isTauri', () => { - it('returns false when not in Tauri', () => { + describe("isTauri", () => { + it("returns false when not in Tauri", () => { expect(isTauri()).toBe(false); }); - it('returns true when in Tauri', () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("returns true when in Tauri", () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); expect(isTauri()).toBe(true); }); }); - describe('loadSettings', () => { - it('loads from localStorage when not in Tauri', async () => { - vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ precision: 5 })); + describe("loadSettings", () => { + it("loads from localStorage when not in Tauri", async () => { + vi.mocked(localStorage.getItem).mockReturnValue( + JSON.stringify({ precision: 5 }), + ); const settings = await loadSettings(); - expect(localStorage.getItem).toHaveBeenCalledWith('summarum.settings'); + expect(localStorage.getItem).toHaveBeenCalledWith("summarum.settings"); expect(settings.precision).toBe(5); expect(settings.theme).toBe(defaultSettingsData.theme); }); - it('loads via invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("loads via invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 })); const settings = await loadSettings(); - expect(invoke).toHaveBeenCalledWith('load_file', { name: 'settings.json' }); + expect(invoke).toHaveBeenCalledWith("load_file", { + name: "settings.json", + }); expect(settings.precision).toBe(8); expect(settings.theme).toBe(defaultSettingsData.theme); }); - it('falls back to defaultSettingsData if localStorage throws', async () => { - vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); }); + it("falls back to defaultSettingsData if localStorage throws", async () => { + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error("localStorage error"); + }); const settings = await loadSettings(); - expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error)); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "loadSettings failed", + expect.any(Error), + ); expect(settings).toEqual(defaultSettingsData); }); - it('falls back to defaultSettingsData if invoke throws', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(invoke).mockRejectedValue(new Error('invoke error')); + it("falls back to defaultSettingsData if invoke throws", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + vi.mocked(invoke).mockRejectedValue(new Error("invoke error")); const settings = await loadSettings(); - expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error)); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "loadSettings failed", + expect.any(Error), + ); expect(settings).toEqual(defaultSettingsData); }); - it('falls back to defaultSettingsData if localStorage returns null', async () => { + it("falls back to defaultSettingsData if localStorage returns null", async () => { vi.mocked(localStorage.getItem).mockReturnValue(null); const settings = await loadSettings(); expect(settings).toEqual(defaultSettingsData); }); - it('falls back to defaultSettingsData if JSON is invalid', async () => { + it("falls back to defaultSettingsData if JSON is invalid", async () => { vi.mocked(localStorage.getItem).mockReturnValue("invalid json"); const settings = await loadSettings(); - expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error)); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "loadSettings failed", + expect.any(Error), + ); expect(settings).toEqual(defaultSettingsData); }); }); - describe('saveSettings', () => { - it('saves to localStorage when not in Tauri', async () => { + describe("saveSettings", () => { + it("saves to localStorage when not in Tauri", async () => { const settings = { ...defaultSettingsData, precision: 10 }; await saveSettings(settings); - expect(localStorage.setItem).toHaveBeenCalledWith('summarum.settings', JSON.stringify(settings, null, 2)); + expect(localStorage.setItem).toHaveBeenCalledWith( + "summarum.settings", + JSON.stringify(settings, null, 2), + ); }); - it('saves via invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("saves via invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); const settings = { ...defaultSettingsData, precision: 12 }; await saveSettings(settings); - expect(invoke).toHaveBeenCalledWith('save_file', { name: 'settings.json', contents: JSON.stringify(settings, null, 2) }); + expect(invoke).toHaveBeenCalledWith("save_file", { + name: "settings.json", + contents: JSON.stringify(settings, null, 2), + }); }); }); - describe('loadAppData', () => { - const mockAppData = { docs: [], activeId: '1', contents: {} }; + describe("loadAppData", () => { + const mockAppData = { docs: [], activeId: "1", contents: {} }; - it('loads from localStorage when not in Tauri', async () => { - vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockAppData)); - const data = await loadAppData(''); - expect(localStorage.getItem).toHaveBeenCalledWith('summarum.documents'); + it("loads from localStorage when not in Tauri", async () => { + vi.mocked(localStorage.getItem).mockReturnValue( + JSON.stringify(mockAppData), + ); + const data = await loadAppData(""); + expect(localStorage.getItem).toHaveBeenCalledWith("summarum.documents"); expect(data).toEqual(mockAppData); }); - it('loads via invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("loads via invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData)); - const data = await loadAppData(''); - expect(invoke).toHaveBeenCalledWith('load_documents', { dir: null }); + const data = await loadAppData(""); + expect(invoke).toHaveBeenCalledWith("load_documents", { dir: null }); expect(data).toEqual(mockAppData); }); - it('loads via invoke when in Tauri with specific dataDir', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("loads via invoke when in Tauri with specific dataDir", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData)); - const data = await loadAppData('custom_dir'); - expect(invoke).toHaveBeenCalledWith('load_documents', { dir: 'custom_dir' }); + const data = await loadAppData("custom_dir"); + expect(invoke).toHaveBeenCalledWith("load_documents", { + dir: "custom_dir", + }); expect(data).toEqual(mockAppData); }); - it('returns null if localStorage throws', async () => { - vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); }); - const data = await loadAppData(''); - expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error)); + it("returns null if localStorage throws", async () => { + vi.mocked(localStorage.getItem).mockImplementation(() => { + throw new Error("localStorage error"); + }); + const data = await loadAppData(""); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "loadAppData failed", + expect.any(Error), + ); expect(data).toBeNull(); }); - it('returns null if invoke throws', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(invoke).mockRejectedValue(new Error('invoke error')); - const data = await loadAppData(''); - expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error)); + it("returns null if invoke throws", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + vi.mocked(invoke).mockRejectedValue(new Error("invoke error")); + const data = await loadAppData(""); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "loadAppData failed", + expect.any(Error), + ); expect(data).toBeNull(); }); - it('returns null if localStorage returns null', async () => { + it("returns null if localStorage returns null", async () => { vi.mocked(localStorage.getItem).mockReturnValue(null); - const data = await loadAppData(''); + const data = await loadAppData(""); expect(data).toBeNull(); }); - it('returns null if JSON is invalid', async () => { + it("returns null if JSON is invalid", async () => { vi.mocked(localStorage.getItem).mockReturnValue("invalid json"); - const data = await loadAppData(''); - expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error)); + const data = await loadAppData(""); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "loadAppData failed", + expect.any(Error), + ); expect(data).toBeNull(); }); }); - describe('saveAppData & flushAppData', () => { - const mockAppData = { docs: [], activeId: '1', contents: {} }; + describe("saveAppData & flushAppData", () => { + const mockAppData = { docs: [], activeId: "1", contents: {} }; beforeEach(() => { vi.useFakeTimers(); @@ -161,8 +223,8 @@ describe('storage', () => { vi.useRealTimers(); }); - it('debounces saves and flushes correctly when not in Tauri', async () => { - setDataDir(''); + it("debounces saves and flushes correctly when not in Tauri", async () => { + setDataDir(""); saveAppData(mockAppData); // Should not save immediately @@ -171,13 +233,16 @@ describe('storage', () => { // Fast forward past debounce time vi.advanceTimersByTime(400); - expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData)); + expect(localStorage.setItem).toHaveBeenCalledWith( + "summarum.documents", + JSON.stringify(mockAppData), + ); }); - it('debounces saves and flushes correctly when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("debounces saves and flushes correctly when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); - setDataDir('custom_dir'); + setDataDir("custom_dir"); saveAppData(mockAppData); // Should not save immediately @@ -192,17 +257,23 @@ describe('storage', () => { // until it settles instead of guessing a fixed number of ticks. vi.useRealTimers(); await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith('save_documents', { dir: 'custom_dir', contents: JSON.stringify(mockAppData) }); + expect(invoke).toHaveBeenCalledWith("save_documents", { + dir: "custom_dir", + contents: JSON.stringify(mockAppData), + }); }); }); - it('flushAppData clears timer and saves immediately', async () => { - setDataDir(''); + it("flushAppData clears timer and saves immediately", async () => { + setDataDir(""); saveAppData(mockAppData); await flushAppData(); - expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData)); + expect(localStorage.setItem).toHaveBeenCalledWith( + "summarum.documents", + JSON.stringify(mockAppData), + ); // Advancing timers should not trigger another save vi.mocked(localStorage.setItem).mockClear(); @@ -210,208 +281,269 @@ describe('storage', () => { expect(localStorage.setItem).not.toHaveBeenCalled(); }); - it('flushAppData does nothing if no pending data', async () => { + it("flushAppData does nothing if no pending data", async () => { await flushAppData(); expect(localStorage.setItem).not.toHaveBeenCalled(); expect(invoke).not.toHaveBeenCalled(); }); - it('handles localStorage throw gracefully in flushAppData', async () => { - setDataDir(''); + it("handles localStorage throw gracefully in flushAppData", async () => { + setDataDir(""); saveAppData(mockAppData); - vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('localStorage error'); }); + vi.mocked(localStorage.setItem).mockImplementation(() => { + throw new Error("localStorage error"); + }); await flushAppData(); - expect(console.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error)); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "saveAppData failed", + expect.any(Error), + ); }); - it('handles invoke throw gracefully in flushAppData', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - setDataDir(''); + it("handles invoke throw gracefully in flushAppData", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + setDataDir(""); saveAppData(mockAppData); - vi.mocked(invoke).mockRejectedValue(new Error('invoke error')); + vi.mocked(invoke).mockRejectedValue(new Error("invoke error")); await flushAppData(); - expect(console.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error)); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "saveAppData failed", + expect.any(Error), + ); }); }); - describe('backup and utility functions', () => { - describe('runBackups', () => { - it('does nothing when not in Tauri', async () => { - await runBackups('dir', 10); + describe("backup and utility functions", () => { + describe("runBackups", () => { + it("does nothing when not in Tauri", async () => { + await runBackups("dir", 10); expect(invoke).not.toHaveBeenCalled(); }); - it('calls invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - await runBackups('dir', 10); - expect(invoke).toHaveBeenCalledWith('run_backups', { dir: 'dir', retentionDays: 10 }); + it("calls invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + await runBackups("dir", 10); + expect(invoke).toHaveBeenCalledWith("run_backups", { + dir: "dir", + retentionDays: 10, + }); }); - it('handles invoke throw gracefully', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(invoke).mockRejectedValue(new Error('invoke error')); - await runBackups('dir', 10); - expect(console.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error)); + it("handles invoke throw gracefully", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + vi.mocked(invoke).mockRejectedValue(new Error("invoke error")); + await runBackups("dir", 10); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "runBackups failed", + expect.any(Error), + ); }); }); - describe('backupDeletedSheet', () => { - it('does nothing when not in Tauri', async () => { - await backupDeletedSheet('dir', 'title', 'contents'); + describe("backupDeletedSheet", () => { + it("does nothing when not in Tauri", async () => { + await backupDeletedSheet("dir", "title", "contents"); expect(invoke).not.toHaveBeenCalled(); }); - it('does nothing if contents are empty', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - await backupDeletedSheet('dir', 'title', ' '); + it("does nothing if contents are empty", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + await backupDeletedSheet("dir", "title", " "); expect(invoke).not.toHaveBeenCalled(); }); - it('calls invoke when in Tauri with valid contents', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - await backupDeletedSheet('dir', 'title', 'contents'); - expect(invoke).toHaveBeenCalledWith('backup_deleted_sheet', { dir: 'dir', title: 'title', contents: 'contents' }); + it("calls invoke when in Tauri with valid contents", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + await backupDeletedSheet("dir", "title", "contents"); + expect(invoke).toHaveBeenCalledWith("backup_deleted_sheet", { + dir: "dir", + title: "title", + contents: "contents", + }); }); - it('handles invoke throw gracefully', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(invoke).mockRejectedValue(new Error('invoke error')); - await backupDeletedSheet('dir', 'title', 'contents'); - expect(console.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error)); + it("handles invoke throw gracefully", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + vi.mocked(invoke).mockRejectedValue(new Error("invoke error")); + await backupDeletedSheet("dir", "title", "contents"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "backupDeletedSheet failed", + expect.any(Error), + ); }); }); - describe('openBackupsFolder', () => { - it('does nothing when not in Tauri', async () => { - await openBackupsFolder('dir'); + describe("openBackupsFolder", () => { + it("does nothing when not in Tauri", async () => { + await openBackupsFolder("dir"); expect(invoke).not.toHaveBeenCalled(); }); - it('calls invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - await openBackupsFolder('dir'); - expect(invoke).toHaveBeenCalledWith('open_backups_folder', { dir: 'dir' }); + it("calls invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + await openBackupsFolder("dir"); + expect(invoke).toHaveBeenCalledWith("open_backups_folder", { + dir: "dir", + }); }); }); - describe('chooseFolder', () => { - it('returns null when not in Tauri', async () => { + describe("chooseFolder", () => { + it("returns null when not in Tauri", async () => { expect(await chooseFolder()).toBeNull(); }); - it('returns path when in Tauri and folder is chosen', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(invoke).mockResolvedValue('chosen_path'); - expect(await chooseFolder()).toBe('chosen_path'); - expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined); + it("returns path when in Tauri and folder is chosen", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + vi.mocked(invoke).mockResolvedValue("chosen_path"); + expect(await chooseFolder()).toBe("chosen_path"); + expect(invoke).toHaveBeenCalledWith("pick_data_dir", undefined); }); - it('returns null when in Tauri and folder choice is cancelled', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("returns null when in Tauri and folder choice is cancelled", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); vi.mocked(invoke).mockResolvedValue(null); expect(await chooseFolder()).toBeNull(); }); }); - describe('dataDirHasDocuments', () => { - it('returns false when not in Tauri', async () => { - expect(await dataDirHasDocuments('dir')).toBe(false); + describe("dataDirHasDocuments", () => { + it("returns false when not in Tauri", async () => { + expect(await dataDirHasDocuments("dir")).toBe(false); }); - it('calls invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + it("calls invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); vi.mocked(invoke).mockResolvedValue(true); - expect(await dataDirHasDocuments('dir')).toBe(true); - expect(invoke).toHaveBeenCalledWith('data_dir_has_documents', { dir: 'dir' }); + expect(await dataDirHasDocuments("dir")).toBe(true); + expect(invoke).toHaveBeenCalledWith("data_dir_has_documents", { + dir: "dir", + }); }); }); - describe('migrateDataDir', () => { - it('does nothing when not in Tauri', async () => { - await migrateDataDir('old', 'new', 'move'); + describe("migrateDataDir", () => { + it("does nothing when not in Tauri", async () => { + await migrateDataDir("old", "new", "move"); expect(invoke).not.toHaveBeenCalled(); }); - it('calls invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - await migrateDataDir('old', 'new', 'move'); - expect(invoke).toHaveBeenCalledWith('migrate_data_dir', { oldDir: 'old', newDir: 'new', strategy: 'move' }); + it("calls invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + await migrateDataDir("old", "new", "move"); + expect(invoke).toHaveBeenCalledWith("migrate_data_dir", { + oldDir: "old", + newDir: "new", + strategy: "move", + }); }); }); }); - describe('fetchRates', () => { - it('uses invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - const mockRates = { date: '2024-01-01', rates: { EUR: 0.85 }, fetchedAt: 12345 }; + describe("fetchRates", () => { + it("uses invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + const mockRates = { + date: "2024-01-01", + rates: { EUR: 0.85 }, + fetchedAt: 12345, + }; vi.mocked(invoke).mockResolvedValue(mockRates); const result = await fetchRates(true); - expect(invoke).toHaveBeenCalledWith('fetch_rates', { force: true }); + expect(invoke).toHaveBeenCalledWith("fetch_rates", { force: true }); expect(result).toEqual(mockRates); }); - it('uses fetch when not in Tauri', async () => { - const mockResponse = { result: 'success', time_last_update_utc: '2024-01-01', rates: { EUR: 0.85 } }; - vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response); + it("uses fetch when not in Tauri", async () => { + const mockResponse = { + result: "success", + time_last_update_utc: "2024-01-01", + rates: { EUR: 0.85 }, + }; + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + } as unknown as Response); const result = await fetchRates(); - expect(fetch).toHaveBeenCalledWith('https://open.er-api.com/v6/latest/USD'); - expect(result?.date).toBe('2024-01-01'); + expect(fetch).toHaveBeenCalledWith( + "https://open.er-api.com/v6/latest/USD", + ); + expect(result?.date).toBe("2024-01-01"); expect(result?.rates).toEqual({ EUR: 0.85 }); - expect(result?.fetchedAt).toBeTypeOf('number'); + expect(result?.fetchedAt).toBeTypeOf("number"); }); - it('returns null if fetch fails', async () => { - vi.mocked(fetch).mockRejectedValue(new Error('Network error')); + it("returns null if fetch fails", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); const result = await fetchRates(); - expect(console.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error)); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "fetchRates failed", + expect.any(Error), + ); expect(result).toBeNull(); }); - it('returns null if API result is not success', async () => { - const mockResponse = { result: 'error', 'error-type': 'unsupported-code' }; - vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response); + it("returns null if API result is not success", async () => { + const mockResponse = { + result: "error", + "error-type": "unsupported-code", + }; + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + } as unknown as Response); const result = await fetchRates(); expect(result).toBeNull(); }); }); - describe('fetchHistoricalRates', () => { - it('uses invoke when in Tauri', async () => { - vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + describe("fetchHistoricalRates", () => { + it("uses invoke when in Tauri", async () => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); const mockRates = { EUR: 0.85 }; vi.mocked(invoke).mockResolvedValue(mockRates); - const result = await fetchHistoricalRates('2024-01-01'); - expect(invoke).toHaveBeenCalledWith('fetch_historical_rates', { date: '2024-01-01' }); + const result = await fetchHistoricalRates("2024-01-01"); + expect(invoke).toHaveBeenCalledWith("fetch_historical_rates", { + date: "2024-01-01", + }); expect(result).toEqual(mockRates); }); - it('uses fetch when not in Tauri and formats rates correctly', async () => { + it("uses fetch when not in Tauri and formats rates correctly", async () => { const mockResponse = { rates: { EUR: 0.85, GBP: 0.75, JPY: -1 } }; // negative value should be ignored - vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response); + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + } as unknown as Response); - const result = await fetchHistoricalRates('2024-01-01'); - expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2024-01-01?from=USD'); + const result = await fetchHistoricalRates("2024-01-01"); + expect(fetch).toHaveBeenCalledWith( + "https://api.frankfurter.dev/v1/2024-01-01?from=USD", + ); expect(result).toEqual({ USD: 1, EUR: 0.85, GBP: 0.75 }); // USD: 1 added, negative ignored }); - it('returns null if fetch fails', async () => { - vi.mocked(fetch).mockRejectedValue(new Error('Network error')); - const result = await fetchHistoricalRates('2024-01-01'); - expect(console.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error)); + it("returns null if fetch fails", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + const result = await fetchHistoricalRates("2024-01-01"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "fetchHistoricalRates failed", + "2024-01-01", + expect.any(Error), + ); expect(result).toBeNull(); }); - it('returns null if rates missing in response', async () => { - const mockResponse = { amount: 1, base: 'USD', date: '2024-01-01' }; // missing rates - vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response); - const result = await fetchHistoricalRates('2024-01-01'); + it("returns null if rates missing in response", async () => { + const mockResponse = { amount: 1, base: "USD", date: "2024-01-01" }; // missing rates + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + } as unknown as Response); + const result = await fetchHistoricalRates("2024-01-01"); expect(result).toBeNull(); }); }); diff --git a/src/storage.ts b/src/storage.ts index 9544485..1cf4f09 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,4 @@ +import { logger } from "./logger"; // Storage: Tauri commands in the app, localStorage fallback for `vite dev`. export interface DocMeta { @@ -65,7 +66,10 @@ export function isTauri(): boolean { return "__TAURI_INTERNALS__" in window; } -async function invoke(cmd: string, args?: Record): Promise { +async function invoke( + cmd: string, + args?: Record, +): Promise { const { invoke } = await import("@tauri-apps/api/core"); return invoke(cmd, args); } @@ -79,14 +83,15 @@ export async function loadSettings(): Promise { : localStorage.getItem("summarum.settings"); if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) }; } catch (e) { - console.warn("loadSettings failed", e); + logger.warn("loadSettings failed", e); } return { ...defaultSettingsData }; } export async function saveSettings(s: SettingsData): Promise { const raw = JSON.stringify(s, null, 2); - if (isTauri()) await invoke("save_file", { name: "settings.json", contents: raw }); + if (isTauri()) + await invoke("save_file", { name: "settings.json", contents: raw }); else localStorage.setItem("summarum.settings", raw); } @@ -99,7 +104,7 @@ export async function loadAppData(dataDir: string): Promise { : localStorage.getItem("summarum.documents"); if (raw) return JSON.parse(raw); } catch (e) { - console.warn("loadAppData failed", e); + logger.warn("loadAppData failed", e); } return null; } @@ -129,10 +134,14 @@ export async function flushAppData(): Promise { const raw = JSON.stringify(pendingData); pendingData = null; try { - if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw }); + if (isTauri()) + await invoke("save_documents", { + dir: dirArg(currentDataDir), + contents: raw, + }); else localStorage.setItem("summarum.documents", raw); } catch (e) { - console.warn("saveAppData failed", e); + logger.warn("saveAppData failed", e); } } @@ -140,26 +149,40 @@ export async function flushAppData(): Promise { export async function onAppQuit(cb: () => Promise): Promise { if (!isTauri()) return; const { listen } = await import("@tauri-apps/api/event"); - await listen("app-quit", () => void cb().finally(() => void invoke("exit_app"))); + await listen( + "app-quit", + () => void cb().finally(() => void invoke("exit_app")), + ); } // ---------- backups & data folder -export async function runBackups(dataDir: string, retentionDays: number): Promise { +export async function runBackups( + dataDir: string, + retentionDays: number, +): Promise { if (!isTauri()) return; try { await invoke("run_backups", { dir: dirArg(dataDir), retentionDays }); } catch (e) { - console.warn("runBackups failed", e); + logger.warn("runBackups failed", e); } } -export async function backupDeletedSheet(dataDir: string, title: string, contents: string): Promise { +export async function backupDeletedSheet( + dataDir: string, + title: string, + contents: string, +): Promise { if (!isTauri() || !contents.trim()) return; try { - await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents }); + await invoke("backup_deleted_sheet", { + dir: dirArg(dataDir), + title, + contents, + }); } catch (e) { - console.warn("backupDeletedSheet failed", e); + logger.warn("backupDeletedSheet failed", e); } } @@ -180,9 +203,17 @@ export async function dataDirHasDocuments(dir: string): Promise { export type MigrateStrategy = "move" | "overwrite" | "use_existing"; -export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise { +export async function migrateDataDir( + oldDir: string, + newDir: string, + strategy: MigrateStrategy, +): Promise { if (!isTauri()) return; - await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy }); + await invoke("migrate_data_dir", { + oldDir: dirArg(oldDir), + newDir, + strategy, + }); } // ---------- rates @@ -202,40 +233,53 @@ export async function fetchRates(force = false): Promise { const res = await fetch("https://open.er-api.com/v6/latest/USD"); const json = await res.json(); if (json?.result === "success") { - return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) }; + return { + date: json.time_last_update_utc, + rates: json.rates, + fetchedAt: Math.floor(Date.now() / 1000), + }; } } catch (e) { - console.warn("fetchRates failed", e); + logger.warn("fetchRates failed", e); } return null; } // ---------- historical rates -export async function fetchHistoricalRatesBatch(dates: string[]): Promise>> { +export async function fetchHistoricalRatesBatch( + dates: string[], +): Promise>> { if (dates.length === 0) return {}; try { if (isTauri()) { - return await invoke>>("fetch_historical_rates_batch", { dates }); + return await invoke>>( + "fetch_historical_rates_batch", + { dates }, + ); } const results: Record> = {}; await Promise.all( dates.map(async (date) => { const rates = await fetchHistoricalRates(date); if (rates) results[date] = rates; - }) + }), ); return results; } catch (e) { - console.warn("fetchHistoricalRatesBatch failed", e); + logger.warn("fetchHistoricalRatesBatch failed", e); return {}; } } -export async function fetchHistoricalRates(date: string): Promise | null> { +export async function fetchHistoricalRates( + date: string, +): Promise | null> { try { if (isTauri()) { - return await invoke>("fetch_historical_rates", { date }); + return await invoke>("fetch_historical_rates", { + date, + }); } const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`); const json = await res.json(); @@ -246,7 +290,7 @@ export async function fetchHistoricalRates(date: string): Promise { // ---------- market data -export async function fetchMarketData(symbols: string[]): Promise> { +export async function fetchMarketData( + symbols: string[], +): Promise> { if (!isTauri() || symbols.length === 0) return {}; try { - return await invoke>("fetch_market_data", { symbols }); + return await invoke>("fetch_market_data", { + symbols, + }); } catch (e) { - console.warn("fetchMarketData failed", e); + logger.warn("fetchMarketData failed", e); return {}; } } // ---------- extensions -export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> { +export async function loadExtensionScripts(): Promise< + { name: string; code: string }[] +> { if (!isTauri()) return []; try { return await invoke<{ name: string; code: string }[]>("load_extensions"); } catch (e) { - console.warn("loadExtensions failed", e); + logger.warn("loadExtensions failed", e); return []; } } @@ -314,7 +364,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise { const content = await invoke("read_text_file", { path }); cb(content); } catch (e) { - console.warn("file drop rejected", path, e); + logger.warn("file drop rejected", path, e); } } }); diff --git a/src/updater.test.ts b/src/updater.test.ts index 2b56531..cae1173 100644 --- a/src/updater.test.ts +++ b/src/updater.test.ts @@ -1,32 +1,39 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { checkForUpdate } from './updater'; -import * as storage from './storage'; -import type { Update } from '@tauri-apps/plugin-updater'; +import { logger } from "./logger"; -vi.mock('./storage', () => ({ +vi.mock("./logger", () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { checkForUpdate } from "./updater"; +import * as storage from "./storage"; +import type { Update } from "@tauri-apps/plugin-updater"; + +vi.mock("./storage", () => ({ isTauri: vi.fn(), })); -vi.mock('@tauri-apps/plugin-updater', () => ({ +vi.mock("@tauri-apps/plugin-updater", () => ({ check: vi.fn(), })); -vi.mock('@tauri-apps/api/app', () => ({ +vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn(), })); -vi.mock('@tauri-apps/plugin-process', () => ({ +vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: vi.fn(), })); -describe('checkForUpdate', () => { +describe("checkForUpdate", () => { beforeEach(() => { vi.resetAllMocks(); - vi.spyOn(console, 'info').mockImplementation(() => {}); - vi.spyOn(console, 'warn').mockImplementation(() => {}); }); - it('should return null if not in Tauri', async () => { + it("should return null if not in Tauri", async () => { vi.mocked(storage.isTauri).mockReturnValue(false); const result = await checkForUpdate(); expect(result).toBeNull(); @@ -34,44 +41,49 @@ describe('checkForUpdate', () => { it('should return "error" if dynamic import or check throws', async () => { vi.mocked(storage.isTauri).mockReturnValue(true); - const { check } = await import('@tauri-apps/plugin-updater'); - vi.mocked(check).mockRejectedValue(new Error('Network error')); + const { check } = await import("@tauri-apps/plugin-updater"); + vi.mocked(check).mockRejectedValue(new Error("Network error")); const result = await checkForUpdate(); - expect(result).toBe('error'); - expect(console.warn).toHaveBeenCalledWith('update check failed', expect.any(Error)); + expect(result).toBe("error"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "update check failed", + expect.any(Error), + ); }); - it('should return null if no update is available', async () => { + it("should return null if no update is available", async () => { vi.mocked(storage.isTauri).mockReturnValue(true); - const { check } = await import('@tauri-apps/plugin-updater'); - const { getVersion } = await import('@tauri-apps/api/app'); + const { check } = await import("@tauri-apps/plugin-updater"); + const { getVersion } = await import("@tauri-apps/api/app"); vi.mocked(check).mockResolvedValue(null); - vi.mocked(getVersion).mockResolvedValue('1.0.0'); + vi.mocked(getVersion).mockResolvedValue("1.0.0"); const result = await checkForUpdate(); expect(result).toBeNull(); - expect(console.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release"); + expect(vi.mocked(logger.info)).toHaveBeenCalledWith( + "no update: running 1.0.0, that's the latest published release", + ); }); - it('should return an update object if an update is available', async () => { + it("should return an update object if an update is available", async () => { vi.mocked(storage.isTauri).mockReturnValue(true); - const { check } = await import('@tauri-apps/plugin-updater'); + const { check } = await import("@tauri-apps/plugin-updater"); const mockUpdate = { - version: '1.1.0', - currentVersion: '1.0.0', + version: "1.1.0", + currentVersion: "1.0.0", downloadAndInstall: vi.fn().mockResolvedValue(undefined), } as unknown as Update; vi.mocked(check).mockResolvedValue(mockUpdate); const result = await checkForUpdate(); expect(result).not.toBeNull(); - if (result !== 'error' && result !== null) { - expect(result.version).toBe('1.1.0'); + if (result !== "error" && result !== null) { + expect(result.version).toBe("1.1.0"); - const { relaunch } = await import('@tauri-apps/plugin-process'); + const { relaunch } = await import("@tauri-apps/plugin-process"); await result.install(); expect(mockUpdate.downloadAndInstall).toHaveBeenCalled(); expect(relaunch).toHaveBeenCalled(); diff --git a/src/updater.ts b/src/updater.ts index 292859e..32cb8d1 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -1,3 +1,4 @@ +import { logger } from "./logger"; import { isTauri } from "./storage"; interface AvailableUpdate { @@ -10,7 +11,9 @@ interface AvailableUpdate { * itself failed (offline, updater/signature error); running outside Tauri * (`vite dev`) also reports null. */ -export async function checkForUpdate(): Promise { +export async function checkForUpdate(): Promise< + AvailableUpdate | null | "error" +> { if (!isTauri()) return null; try { const { check } = await import("@tauri-apps/plugin-updater"); @@ -19,10 +22,14 @@ export async function checkForUpdate(): Promise ${update.version}`); + logger.info( + `update available: ${update.currentVersion} -> ${update.version}`, + ); return { version: update.version, async install() { @@ -32,7 +39,7 @@ export async function checkForUpdate(): Promise