mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
refactor: replace direct console logging with a dedicated logger utility
Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
parent
b399b95263
commit
9c63845e45
9 changed files with 906 additions and 366 deletions
|
|
@ -1,6 +1,18 @@
|
||||||
|
import { logger } from "../logger";
|
||||||
// Public facade: evaluates whole documents line by line and exposes the
|
// Public facade: evaluates whole documents line by line and exposes the
|
||||||
// extension API (numi.addUnit / addFunction / setVariable).
|
// 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 { Registry, buildRegistry, Completion } from "./registry";
|
||||||
import { tokenize, Token } from "./tokenizer";
|
import { tokenize, Token } from "./tokenizer";
|
||||||
import { parseLine } from "./parser";
|
import { parseLine } from "./parser";
|
||||||
|
|
@ -84,7 +96,8 @@ export class SumEngine {
|
||||||
|
|
||||||
updateSettings(patch: Partial<EngineSettings>): void {
|
updateSettings(patch: Partial<EngineSettings>): void {
|
||||||
this.settings = { ...this.settings, ...patch };
|
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[] {
|
completions(): Completion[] {
|
||||||
|
|
@ -106,13 +119,25 @@ export class SumEngine {
|
||||||
|
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
const kind = commentStart >= 0 ? "comment" : "empty";
|
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);
|
lineValues.push(null);
|
||||||
lineKinds.push("empty");
|
lineKinds.push("empty");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("#")) {
|
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);
|
lineValues.push(null);
|
||||||
lineKinds.push("header");
|
lineKinds.push("header");
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -144,7 +169,7 @@ export class SumEngine {
|
||||||
} else {
|
} else {
|
||||||
// Surface unexpected bugs/BigInt errors and log them
|
// Surface unexpected bugs/BigInt errors and log them
|
||||||
lineError = e instanceof Error ? e.message : String(e);
|
lineError = e instanceof Error ? e.message : String(e);
|
||||||
console.warn("evaluate failed:", e);
|
logger.warn("evaluate failed:", e);
|
||||||
}
|
}
|
||||||
value = null;
|
value = null;
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +180,10 @@ export class SumEngine {
|
||||||
lineValues.push(value);
|
lineValues.push(value);
|
||||||
lineKinds.push("normal");
|
lineKinds.push("normal");
|
||||||
results.push({
|
results.push({
|
||||||
text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null,
|
text:
|
||||||
|
value && value.kind !== "chart"
|
||||||
|
? formatValue(value, this.settings)
|
||||||
|
: null,
|
||||||
value,
|
value,
|
||||||
kind: "normal",
|
kind: "normal",
|
||||||
tokens,
|
tokens,
|
||||||
|
|
@ -213,8 +241,11 @@ export class SumEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
addUnit(spec: ExtensionUnitSpec): void {
|
addUnit(spec: ExtensionUnitSpec): void {
|
||||||
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
|
const base =
|
||||||
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
|
this.reg.unitsById.get(spec.baseUnitId) ??
|
||||||
|
this.currencyBase(spec.baseUnitId);
|
||||||
|
if (!base)
|
||||||
|
throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
|
||||||
const unit: Unit = {
|
const unit: Unit = {
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
dimension: base.dimension,
|
dimension: base.dimension,
|
||||||
|
|
@ -225,12 +256,24 @@ export class SumEngine {
|
||||||
this.reg.unitsById.set(unit.id, unit);
|
this.reg.unitsById.set(unit.id, unit);
|
||||||
for (const p of spec.phrases.split(",")) {
|
for (const p of spec.phrases.split(",")) {
|
||||||
const phrase = p.trim();
|
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;
|
const name = spec.id;
|
||||||
this.reg.customFuncs.set(name, (args: Value[]) => {
|
this.reg.customFuncs.set(name, (args: Value[]) => {
|
||||||
const mapped = args.map((a) => this.fromValue(a));
|
const mapped = args.map((a) => this.fromValue(a));
|
||||||
|
|
@ -238,26 +281,42 @@ export class SumEngine {
|
||||||
});
|
});
|
||||||
for (const p of (spec.phrases || spec.id).split(",")) {
|
for (const p of (spec.phrases || spec.id).split(",")) {
|
||||||
const phrase = p.trim();
|
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 {
|
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 {
|
private toValue(v: number | ExtensionValue): Value {
|
||||||
if (typeof v === "number") return qty(v);
|
if (typeof v === "number") return qty(v);
|
||||||
const unit = v.unitId
|
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;
|
: null;
|
||||||
return qty(new Decimal(v.double), unit);
|
return qty(new Decimal(v.double), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fromValue(v: Value): ExtensionValue {
|
private fromValue(v: Value): ExtensionValue {
|
||||||
if (v.kind === "quantity") {
|
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 === "percent") return { double: v.value.div(100).toNumber() };
|
||||||
if (v.kind === "date") return { double: v.ms };
|
if (v.kind === "date") return { double: v.ms };
|
||||||
|
|
|
||||||
|
|
@ -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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { makeApi, runExtensions } from "./extensions";
|
import { makeApi, runExtensions } from "./extensions";
|
||||||
import { SumEngine } from "./engine";
|
import { SumEngine } from "./engine";
|
||||||
|
|
@ -6,6 +15,7 @@ describe("extensions", () => {
|
||||||
let mockEngine: any;
|
let mockEngine: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
mockEngine = {
|
mockEngine = {
|
||||||
setVariable: vi.fn(),
|
setVariable: vi.fn(),
|
||||||
addUnit: vi.fn(),
|
addUnit: vi.fn(),
|
||||||
|
|
@ -22,7 +32,12 @@ describe("extensions", () => {
|
||||||
|
|
||||||
it("delegates addUnit to engine", () => {
|
it("delegates addUnit to engine", () => {
|
||||||
const api = makeApi(mockEngine as SumEngine);
|
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);
|
api.addUnit(spec);
|
||||||
expect(mockEngine.addUnit).toHaveBeenCalledWith(spec);
|
expect(mockEngine.addUnit).toHaveBeenCalledWith(spec);
|
||||||
});
|
});
|
||||||
|
|
@ -37,17 +52,11 @@ describe("extensions", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runExtensions", () => {
|
describe("runExtensions", () => {
|
||||||
let consoleErrorSpy: any;
|
beforeEach(() => {});
|
||||||
let consoleWarnSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
consoleErrorSpy.mockRestore();
|
vi.mocked(logger.error).mockRestore();
|
||||||
consoleWarnSpy.mockRestore();
|
vi.mocked(logger.warn).mockRestore();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doUnmock("quickjs-emscripten-core");
|
vi.doUnmock("quickjs-emscripten-core");
|
||||||
});
|
});
|
||||||
|
|
@ -55,7 +64,10 @@ describe("extensions", () => {
|
||||||
it("runs scripts successfully using numi api", async () => {
|
it("runs scripts successfully using numi api", async () => {
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
await runExtensions(mockEngine as SumEngine, [
|
||||||
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
{ 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);
|
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
||||||
|
|
@ -65,14 +77,21 @@ describe("extensions", () => {
|
||||||
baseUnitId: "USD",
|
baseUnitId: "USD",
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
});
|
});
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
expect(vi.mocked(logger.error)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("swallows errors and logs to console.error", async () => {
|
it("swallows errors and logs to logger.error", async () => {
|
||||||
await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]);
|
await runExtensions(mockEngine as SumEngine, [
|
||||||
|
{ name: "script1", code: "throw new Error('test error');" },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error));
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error");
|
"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 () => {
|
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("var1", 10);
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
|
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 () => {
|
it("passes unit-carrying values through setVariable", async () => {
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
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 () => {
|
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(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 () => {
|
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];
|
const fn = mockEngine.addFunction.mock.calls[0][1];
|
||||||
expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 });
|
expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 });
|
||||||
expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 });
|
expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 });
|
||||||
|
|
@ -125,15 +157,21 @@ describe("extensions", () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
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 () => {
|
it("has no host globals inside the sandbox", async () => {
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
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 () => {
|
it("interrupts an infinite loop at load time", async () => {
|
||||||
|
|
@ -142,8 +180,10 @@ describe("extensions", () => {
|
||||||
{ name: "after", code: "numi.setVariable('ok', 1);" },
|
{ name: "after", code: "numi.setVariable('ok', 1);" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
expect(vi.mocked(logger.error)).toHaveBeenCalledTimes(1);
|
||||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:");
|
expect(vi.mocked(logger.error).mock.calls[0][0]).toBe(
|
||||||
|
"extension spin failed:",
|
||||||
|
);
|
||||||
// the runtime survives the interrupt and keeps loading scripts
|
// the runtime survives the interrupt and keeps loading scripts
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1);
|
expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1);
|
||||||
});
|
});
|
||||||
|
|
@ -172,7 +212,10 @@ describe("extensions", () => {
|
||||||
|
|
||||||
it("surfaces a bad return value as a function error", async () => {
|
it("surfaces a bad return value as a function error", async () => {
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
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];
|
const fn = mockEngine.addFunction.mock.calls[0][1];
|
||||||
|
|
@ -182,14 +225,17 @@ describe("extensions", () => {
|
||||||
it("skips extensions when the sandbox fails to load", async () => {
|
it("skips extensions when the sandbox fails to load", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doMock("quickjs-emscripten-core", () => ({
|
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");
|
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(mockEngine.setVariable).not.toHaveBeenCalled();
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
|
||||||
"extensions disabled: QuickJS sandbox unavailable:",
|
"extensions disabled: QuickJS sandbox unavailable:",
|
||||||
expect.any(Error),
|
expect.any(Error),
|
||||||
);
|
);
|
||||||
|
|
@ -197,8 +243,8 @@ describe("extensions", () => {
|
||||||
|
|
||||||
it("does not load the sandbox when there are no scripts", async () => {
|
it("does not load the sandbox when there are no scripts", async () => {
|
||||||
await runExtensions(mockEngine as SumEngine, []);
|
await runExtensions(mockEngine as SumEngine, []);
|
||||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
expect(vi.mocked(logger.warn)).not.toHaveBeenCalled();
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
expect(vi.mocked(logger.error)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { logger } from "./logger";
|
||||||
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||||
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,7 +76,7 @@ export async function runExtensions(
|
||||||
try {
|
try {
|
||||||
await load(engine, scripts);
|
await load(engine, scripts);
|
||||||
} catch (e) {
|
} 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();
|
res.value.dispose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`extension ${s.name} failed:`, e);
|
logger.error(`extension ${s.name} failed:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/logger.ts
Normal file
11
src/logger.ts
Normal file
|
|
@ -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),
|
||||||
|
};
|
||||||
374
src/main.ts
374
src/main.ts
|
|
@ -1,14 +1,35 @@
|
||||||
|
import { logger } from "./logger";
|
||||||
/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
|
/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
|
||||||
import "./ui/app.css";
|
import "./ui/app.css";
|
||||||
import { SumEngine } from "./engine";
|
import { SumEngine } from "./engine";
|
||||||
import { SumEditor } from "./ui/editor";
|
import { SumEditor } from "./ui/editor";
|
||||||
import {
|
import {
|
||||||
AppData, DocMeta, SettingsData, defaultSettingsData,
|
AppData,
|
||||||
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
DocMeta,
|
||||||
fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri,
|
SettingsData,
|
||||||
getLaunchFile, onOpenFile, onFileDrop,
|
defaultSettingsData,
|
||||||
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
loadAppData,
|
||||||
chooseFolder, dataDirHasDocuments, migrateDataDir,
|
saveAppData,
|
||||||
|
flushAppData,
|
||||||
|
onAppQuit,
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
fetchRates,
|
||||||
|
fetchMarketData,
|
||||||
|
fetchHistoricalRatesBatch,
|
||||||
|
loadExtensionScripts,
|
||||||
|
openExtensionsFolder,
|
||||||
|
isTauri,
|
||||||
|
getLaunchFile,
|
||||||
|
onOpenFile,
|
||||||
|
onFileDrop,
|
||||||
|
setDataDir,
|
||||||
|
runBackups,
|
||||||
|
backupDeletedSheet,
|
||||||
|
openBackupsFolder,
|
||||||
|
chooseFolder,
|
||||||
|
dataDirHasDocuments,
|
||||||
|
migrateDataDir,
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
import type { LineResult } from "./engine";
|
import type { LineResult } from "./engine";
|
||||||
import { setLang, detectLang, t } from "./i18n";
|
import { setLang, detectLang, t } from "./i18n";
|
||||||
|
|
@ -22,10 +43,13 @@ import pkg from "../package.json";
|
||||||
|
|
||||||
function welcomeText(lang: string): string {
|
function welcomeText(lang: string): string {
|
||||||
const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"];
|
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 $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
|
const $ = <T extends HTMLElement>(sel: string): T =>
|
||||||
|
document.querySelector(sel) as T;
|
||||||
|
|
||||||
let engine: SumEngine;
|
let engine: SumEngine;
|
||||||
let editor: SumEditor;
|
let editor: SumEditor;
|
||||||
|
|
@ -74,13 +98,22 @@ function pinDoc(id: string): void {
|
||||||
// move to end of pinned group
|
// move to end of pinned group
|
||||||
data.docs = data.docs.filter((d) => d.id !== id);
|
data.docs = data.docs.filter((d) => d.id !== id);
|
||||||
let lastPinned = -1;
|
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);
|
data.docs.splice(lastPinned + 1, 0, doc);
|
||||||
} else {
|
} else {
|
||||||
// move to start of non-pinned group
|
// move to start of non-pinned group
|
||||||
data.docs = data.docs.filter((d) => d.id !== id);
|
data.docs = data.docs.filter((d) => d.id !== id);
|
||||||
const firstNonPinned = data.docs.findIndex((d) => !d.pinned);
|
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);
|
saveAppData(data);
|
||||||
renderDocList();
|
renderDocList();
|
||||||
|
|
@ -99,7 +132,12 @@ function reorderDoc(srcId: string, targetId: string): void {
|
||||||
renderDocList();
|
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 —
|
// Reordering uses plain mouse tracking, not the native HTML5 drag API —
|
||||||
// Tauri's window-level file-drop hook (dragDropEnabled, needed for
|
// Tauri's window-level file-drop hook (dragDropEnabled, needed for
|
||||||
// dropping .numi files from Explorer) intercepts WebView2's own drag
|
// 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) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
const startY = e.clientY;
|
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) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
if (!dragging) {
|
if (!dragging) {
|
||||||
if (Math.abs(ev.clientY - startY) < 4) return;
|
if (Math.abs(ev.clientY - startY) < 4) return;
|
||||||
|
|
@ -137,10 +176,13 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
||||||
}
|
}
|
||||||
if (over) {
|
if (over) {
|
||||||
const overDoc = data.docs.find((d) => d.id === over.dataset.docId);
|
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 = () => {
|
const onUp = () => {
|
||||||
window.removeEventListener("mousemove", onMove);
|
window.removeEventListener("mousemove", onMove);
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
|
@ -148,7 +190,8 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
||||||
cachedItems = null;
|
cachedItems = null;
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
const target = list.querySelector<HTMLElement>(".doc-item.drag-over");
|
const target = list.querySelector<HTMLElement>(".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 = "";
|
document.body.style.cursor = "";
|
||||||
if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId);
|
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);
|
list.addEventListener("scroll", onScroll);
|
||||||
});
|
});
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
if (suppressClick) { suppressClick = false; return; }
|
if (suppressClick) {
|
||||||
|
suppressClick = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
switchDoc(doc.id);
|
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");
|
const b = document.createElement("button");
|
||||||
b.className = cls;
|
b.className = cls;
|
||||||
b.textContent = text;
|
b.textContent = text;
|
||||||
b.title = title;
|
b.title = title;
|
||||||
b.setAttribute("aria-label", title);
|
b.setAttribute("aria-label", title);
|
||||||
b.addEventListener("click", (e) => { e.stopPropagation(); cb(); });
|
b.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
cb();
|
||||||
|
});
|
||||||
return b;
|
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");
|
const del = document.createElement("button");
|
||||||
del.className = "del";
|
del.className = "del";
|
||||||
del.textContent = "✕";
|
del.textContent = "✕";
|
||||||
|
|
@ -192,7 +250,11 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirmTimer) clearTimeout(confirmTimer);
|
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];
|
delete appData.contents[doc.id];
|
||||||
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
|
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
|
||||||
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
|
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
|
||||||
|
|
@ -208,13 +270,23 @@ function renderDocList(): void {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
for (const doc of data.docs) {
|
for (const doc of data.docs) {
|
||||||
const el = document.createElement("div");
|
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");
|
const name = document.createElement("span");
|
||||||
name.className = "doc-name";
|
name.className = "doc-name";
|
||||||
name.textContent = doc.title || t("untitled");
|
name.textContent = doc.title || t("untitled");
|
||||||
el.appendChild(name);
|
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);
|
setupDocInteraction(el, doc, list, data);
|
||||||
el.dataset.docId = doc.id;
|
el.dataset.docId = doc.id;
|
||||||
|
|
@ -238,7 +310,10 @@ function switchDoc(id: string): void {
|
||||||
|
|
||||||
function newDoc(content = ""): void {
|
function newDoc(content = ""): void {
|
||||||
const id = uid();
|
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;
|
data.contents[id] = content;
|
||||||
switchDoc(id);
|
switchDoc(id);
|
||||||
}
|
}
|
||||||
|
|
@ -256,7 +331,9 @@ async function closeActiveDoc(): Promise<void> {
|
||||||
void backupDeletedSheet(settings.dataDir, doc.title, content);
|
void backupDeletedSheet(settings.dataDir, doc.title, content);
|
||||||
delete data.contents[doc.id];
|
delete data.contents[doc.id];
|
||||||
data.docs = data.docs.filter((d) => d.id !== 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);
|
switchDoc(nextId);
|
||||||
saveAppData(data);
|
saveAppData(data);
|
||||||
}
|
}
|
||||||
|
|
@ -264,12 +341,21 @@ async function closeActiveDoc(): Promise<void> {
|
||||||
// ---------- settings
|
// ---------- settings
|
||||||
|
|
||||||
function applySettings(): void {
|
function applySettings(): void {
|
||||||
const theme = settings.theme === "system"
|
const theme =
|
||||||
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
settings.theme === "system"
|
||||||
: settings.theme;
|
? matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light"
|
||||||
|
: settings.theme;
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
|
document.documentElement.style.setProperty(
|
||||||
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
|
"--editor-font-size",
|
||||||
|
`${settings.fontSize}px`,
|
||||||
|
);
|
||||||
|
$("#editor-wrap").style.setProperty(
|
||||||
|
"--results-width",
|
||||||
|
`${settings.resultsWidth}%`,
|
||||||
|
);
|
||||||
setLang(settings.language);
|
setLang(settings.language);
|
||||||
const searchInput = $<HTMLInputElement>("#search-input");
|
const searchInput = $<HTMLInputElement>("#search-input");
|
||||||
searchInput.placeholder = t("searchPlaceholder");
|
searchInput.placeholder = t("searchPlaceholder");
|
||||||
|
|
@ -282,7 +368,10 @@ function applySettings(): void {
|
||||||
});
|
});
|
||||||
editor?.refresh();
|
editor?.refresh();
|
||||||
syncTitleField();
|
syncTitleField();
|
||||||
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
|
$<HTMLElement>("#sidebar").classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
!settings.sidebarVisible,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindFormattingSettings(): void {
|
function bindFormattingSettings(): void {
|
||||||
|
|
@ -306,7 +395,10 @@ function bindFormattingSettings(): void {
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
settings.theme = themeSel.value as SettingsData["theme"];
|
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.groupSeparator = groupSep.value;
|
||||||
settings.decimalSeparator = decimalSep.value;
|
settings.decimalSeparator = decimalSep.value;
|
||||||
// "1,234,56" is unreadable — a comma decimal forces a space group separator
|
// "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.dateFormat = dateFmt.value as SettingsData["dateFormat"];
|
||||||
settings.language = langSel.value;
|
settings.language = langSel.value;
|
||||||
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
|
settings.fontSize = Math.max(
|
||||||
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
|
10,
|
||||||
|
Math.min(32, Number(fontSize.value) || 15),
|
||||||
|
);
|
||||||
|
settings.resultsWidth = Math.max(
|
||||||
|
20,
|
||||||
|
Math.min(60, Number(resultsWidth.value) || 42),
|
||||||
|
);
|
||||||
applySettings();
|
applySettings();
|
||||||
void saveSettings(settings);
|
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);
|
el.addEventListener("change", save);
|
||||||
}
|
}
|
||||||
resultsWidth.addEventListener("input", save); // live while sliding
|
resultsWidth.addEventListener("input", save); // live while sliding
|
||||||
|
|
@ -401,7 +507,10 @@ function bindBehaviorSettings(): void {
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
});
|
});
|
||||||
binDays.addEventListener("change", () => {
|
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);
|
binDays.value = String(settings.deletedRetentionDays);
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
|
void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
|
||||||
|
|
@ -417,7 +526,9 @@ function bindBehaviorSettings(): void {
|
||||||
function bindDataDirSettings(): void {
|
function bindDataDirSettings(): void {
|
||||||
const dataDirBtn = $<HTMLButtonElement>("#set-datadir");
|
const dataDirBtn = $<HTMLButtonElement>("#set-datadir");
|
||||||
const renderDataDir = () => {
|
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");
|
dataDirBtn.title = settings.dataDir || t("defaultFolder");
|
||||||
};
|
};
|
||||||
renderDataDir();
|
renderDataDir();
|
||||||
|
|
@ -426,14 +537,18 @@ function bindDataDirSettings(): void {
|
||||||
if (!picked || picked === settings.dataDir) return;
|
if (!picked || picked === settings.dataDir) return;
|
||||||
let strategy: "move" | "overwrite" | "use_existing" = "move";
|
let strategy: "move" | "overwrite" | "use_existing" = "move";
|
||||||
if (await dataDirHasDocuments(picked)) {
|
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;
|
if (ans === null) return;
|
||||||
strategy = ans === "a" ? "use_existing" : "overwrite";
|
strategy = ans === "a" ? "use_existing" : "overwrite";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await migrateDataDir(settings.dataDir, picked, strategy);
|
await migrateDataDir(settings.dataDir, picked, strategy);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("migrate failed", e);
|
logger.warn("migrate failed", e);
|
||||||
toast(t("folderError"));
|
toast(t("folderError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -445,7 +560,8 @@ function bindDataDirSettings(): void {
|
||||||
const fresh = await loadAppData(picked);
|
const fresh = await loadAppData(picked);
|
||||||
if (fresh && fresh.docs.length > 0) {
|
if (fresh && fresh.docs.length > 0) {
|
||||||
data = fresh;
|
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);
|
switchDoc(data.activeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -454,12 +570,19 @@ function bindDataDirSettings(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindSettingsNavigation(): void {
|
function bindSettingsNavigation(): void {
|
||||||
const tabs = document.querySelectorAll<HTMLButtonElement>("#settings-tabs .tab");
|
const tabs = document.querySelectorAll<HTMLButtonElement>(
|
||||||
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
"#settings-tabs .tab",
|
||||||
|
);
|
||||||
|
const tabPanels =
|
||||||
|
document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
for (const other of tabs) other.classList.toggle("active", other === tab);
|
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", () => {
|
$("#close-settings").addEventListener("click", () => {
|
||||||
$("#settings-panel").classList.add("hidden");
|
$("#settings-panel").classList.add("hidden");
|
||||||
});
|
});
|
||||||
$("#open-extensions").addEventListener("click", () => void openExtensionsFolder());
|
$("#open-extensions").addEventListener(
|
||||||
$("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir));
|
"click",
|
||||||
|
() => void openExtensionsFolder(),
|
||||||
|
);
|
||||||
|
$("#open-backups").addEventListener(
|
||||||
|
"click",
|
||||||
|
() => void openBackupsFolder(settings.dataDir),
|
||||||
|
);
|
||||||
$("#check-update").addEventListener("click", () => void checkUpdate(true));
|
$("#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 (/^Numpad\d$/.test(code)) return code;
|
||||||
if (/^F\d{1,2}$/.test(code)) return code;
|
if (/^F\d{1,2}$/.test(code)) return code;
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
|
Space: "Space",
|
||||||
Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
|
Enter: "Enter",
|
||||||
ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right",
|
Backspace: "Backspace",
|
||||||
Minus: "-", Equal: "=", Comma: ",", Period: ".", Slash: "/", Backquote: "`",
|
Delete: "Delete",
|
||||||
BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'",
|
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;
|
return map[code] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- tauri integration
|
// ---------- tauri integration
|
||||||
|
|
||||||
async function registerHotkey(old: string | null, combo: string): Promise<boolean> {
|
async function registerHotkey(
|
||||||
|
old: string | null,
|
||||||
|
combo: string,
|
||||||
|
): Promise<boolean> {
|
||||||
if (!isTauri()) return true;
|
if (!isTauri()) return true;
|
||||||
try {
|
try {
|
||||||
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
||||||
|
|
@ -534,7 +684,7 @@ async function registerHotkey(old: string | null, combo: string): Promise<boolea
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("hotkey registration failed", e);
|
logger.warn("hotkey registration failed", e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -546,14 +696,16 @@ async function applyAutostart(enabled: boolean): Promise<void> {
|
||||||
if (enabled) await auto.enable();
|
if (enabled) await auto.enable();
|
||||||
else await auto.disable();
|
else await auto.disable();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("autostart failed", e);
|
logger.warn("autostart failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
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<void> {
|
async function refreshRates(force = false): Promise<void> {
|
||||||
|
|
@ -563,7 +715,7 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
info.classList.remove("spin");
|
info.classList.remove("spin");
|
||||||
if (payload) {
|
if (payload) {
|
||||||
liveRates = Object.fromEntries(
|
liveRates = Object.fromEntries(
|
||||||
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)])
|
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]),
|
||||||
);
|
);
|
||||||
applyAllRates();
|
applyAllRates();
|
||||||
workspace.invalidateAll();
|
workspace.invalidateAll();
|
||||||
|
|
@ -577,7 +729,15 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
// ---------- market data
|
// ---------- market data
|
||||||
|
|
||||||
// Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss.
|
// 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<string, number> = {};
|
let marketPrices: Record<string, number> = {};
|
||||||
let marketFetchedAt = 0;
|
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<void> {
|
async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
||||||
const dates = new Set<string>();
|
const dates = new Set<string>();
|
||||||
for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!);
|
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;
|
let fetched = false;
|
||||||
if (datesToFetch.length > 0) {
|
if (datesToFetch.length > 0) {
|
||||||
const results = await fetchHistoricalRatesBatch(datesToFetch);
|
const results = await fetchHistoricalRatesBatch(datesToFetch);
|
||||||
|
|
@ -628,12 +790,18 @@ async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fetched) { workspace.invalidateAll(); editor.refresh(); }
|
if (fetched) {
|
||||||
|
workspace.invalidateAll();
|
||||||
|
editor.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarketInfo(): void {
|
function renderMarketInfo(): void {
|
||||||
const el = $("#market-info") as HTMLElement;
|
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.style.display = "";
|
||||||
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
||||||
el.title = t("refreshRates");
|
el.title = t("refreshRates");
|
||||||
|
|
@ -669,7 +837,8 @@ function agoText(unixSec: number): string {
|
||||||
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
||||||
if (s < 90) return t("justNow");
|
if (s < 90) return t("justNow");
|
||||||
if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
|
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)));
|
return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -691,13 +860,21 @@ let modalChain: Promise<unknown> = Promise.resolve();
|
||||||
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
|
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
|
||||||
calls serialize on the shared #modal singleton — a second call waits for
|
calls serialize on the shared #modal singleton — a second call waits for
|
||||||
the first to resolve rather than clobbering its handlers */
|
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));
|
const run = modalChain.then(() => showModal(msg, aLabel, bLabel));
|
||||||
modalChain = run.catch(() => {});
|
modalChain = run.catch(() => {});
|
||||||
return run;
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const modal = $("#modal");
|
const modal = $("#modal");
|
||||||
const a = $("#modal-a");
|
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
|
// a click that just refocused the window (native dialog closing, or
|
||||||
// bringing a backgrounded tray window to front) lands on the backdrop
|
// bringing a backgrounded tray window to front) lands on the backdrop
|
||||||
// too — ignore a backdrop dismiss that arrives right after refocus
|
// 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<void> {
|
async function initSettings(): Promise<void> {
|
||||||
settings = await loadSettings();
|
settings = await loadSettings();
|
||||||
if (!localStorage.getItem("summarum.langInit") && settings.language === "en") {
|
if (
|
||||||
|
!localStorage.getItem("summarum.langInit") &&
|
||||||
|
settings.language === "en"
|
||||||
|
) {
|
||||||
settings.language = detectLang();
|
settings.language = detectLang();
|
||||||
localStorage.setItem("summarum.langInit", "1");
|
localStorage.setItem("summarum.langInit", "1");
|
||||||
}
|
}
|
||||||
|
|
@ -758,19 +939,34 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
|
|
||||||
const stored = await loadAppData(settings.dataDir);
|
const stored = await loadAppData(settings.dataDir);
|
||||||
// a corrupt or foreign documents.json must not crash the boot
|
// 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;
|
data = stored;
|
||||||
} else {
|
} else {
|
||||||
const id = uid();
|
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();
|
const scripts = await loadExtensionScripts();
|
||||||
await runExtensions(engine, scripts);
|
await runExtensions(engine, scripts);
|
||||||
|
|
||||||
workspace = new Workspace(engine, () =>
|
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(
|
editor = new SumEditor(
|
||||||
|
|
@ -813,7 +1009,12 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
search = initSearch({
|
search = initSearch({
|
||||||
engine,
|
engine,
|
||||||
workspace,
|
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,
|
t,
|
||||||
onOpen: (docId, line) => {
|
onOpen: (docId, line) => {
|
||||||
if (docId !== data.activeId) switchDoc(docId);
|
if (docId !== data.activeId) switchDoc(docId);
|
||||||
|
|
@ -859,7 +1060,9 @@ function bindMiscUI(): void {
|
||||||
|
|
||||||
$("#open-search").addEventListener("click", () => search.open());
|
$("#open-search").addEventListener("click", () => search.open());
|
||||||
|
|
||||||
window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); });
|
window.addEventListener("focus", () => {
|
||||||
|
lastWindowFocusAt = performance.now();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindWindowControls(): Promise<void> {
|
async function bindWindowControls(): Promise<void> {
|
||||||
|
|
@ -884,7 +1087,11 @@ function bindSidebarUI(): void {
|
||||||
document.addEventListener("mousedown", (e) => {
|
document.addEventListener("mousedown", (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
const panel = $("#settings-panel");
|
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");
|
panel.classList.add("hidden");
|
||||||
}
|
}
|
||||||
if (!settings.sidebarVisible) return;
|
if (!settings.sidebarVisible) return;
|
||||||
|
|
@ -913,7 +1120,9 @@ function bindDividerUI(): void {
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
divider.classList.remove("dragging");
|
divider.classList.remove("dragging");
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
$<HTMLInputElement>("#set-resultswidth").value = String(settings.resultsWidth);
|
$<HTMLInputElement>("#set-resultswidth").value = String(
|
||||||
|
settings.resultsWidth,
|
||||||
|
);
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", onMove);
|
window.addEventListener("mousemove", onMove);
|
||||||
|
|
@ -956,7 +1165,9 @@ function bindExportUI(): void {
|
||||||
});
|
});
|
||||||
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
|
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
|
||||||
exportMenu.addEventListener("click", async (e) => {
|
exportMenu.addEventListener("click", async (e) => {
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action]");
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
||||||
|
"[data-action]",
|
||||||
|
);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
exportMenu.classList.add("hidden");
|
exportMenu.classList.add("hidden");
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
|
|
@ -978,7 +1189,10 @@ function bindExportUI(): void {
|
||||||
const isSum = action === "save-sum";
|
const isSum = action === "save-sum";
|
||||||
const content = isSum ? editor.getText() : editor.getSheetWithResults();
|
const content = isSum ? editor.getText() : editor.getSheetWithResults();
|
||||||
try {
|
try {
|
||||||
const saved = await invoke<boolean>("write_text_file", { contents: content, isSum });
|
const saved = await invoke<boolean>("write_text_file", {
|
||||||
|
contents: content,
|
||||||
|
isSum,
|
||||||
|
});
|
||||||
if (saved) {
|
if (saved) {
|
||||||
toast(t("saved"));
|
toast(t("saved"));
|
||||||
}
|
}
|
||||||
|
|
@ -1028,11 +1242,15 @@ async function bindLifecycle(): Promise<void> {
|
||||||
// update check above only ever runs once at boot — recheck periodically
|
// update check above only ever runs once at boot — recheck periodically
|
||||||
// so a long-lived process still notices new releases; re-read the setting
|
// 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
|
// 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<void> {
|
async function initUI(): Promise<void> {
|
||||||
|
|
||||||
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
||||||
bindSettingsUI();
|
bindSettingsUI();
|
||||||
renderDocList();
|
renderDocList();
|
||||||
|
|
@ -1066,7 +1284,11 @@ async function checkUpdate(manual = false): Promise<void> {
|
||||||
if (manual) toast(t("upToDate"));
|
if (manual) toast(t("upToDate"));
|
||||||
return;
|
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;
|
if (choice !== "a") return;
|
||||||
toast(t("updateInstalling"));
|
toast(t("updateInstalling"));
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,157 +1,219 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { logger } from "./logger";
|
||||||
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', () => ({
|
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(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('storage', () => {
|
describe("storage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.stubGlobal('window', {});
|
vi.stubGlobal("window", {});
|
||||||
vi.stubGlobal('localStorage', {
|
vi.stubGlobal("localStorage", {
|
||||||
getItem: vi.fn(),
|
getItem: vi.fn(),
|
||||||
setItem: vi.fn(),
|
setItem: vi.fn(),
|
||||||
});
|
});
|
||||||
vi.stubGlobal('console', { warn: vi.fn() });
|
vi.stubGlobal("console", { warn: vi.fn() });
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isTauri', () => {
|
describe("isTauri", () => {
|
||||||
it('returns false when not in Tauri', () => {
|
it("returns false when not in Tauri", () => {
|
||||||
expect(isTauri()).toBe(false);
|
expect(isTauri()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true when in Tauri', () => {
|
it("returns true when in Tauri", () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
expect(isTauri()).toBe(true);
|
expect(isTauri()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadSettings', () => {
|
describe("loadSettings", () => {
|
||||||
it('loads from localStorage when not in Tauri', async () => {
|
it("loads from localStorage when not in Tauri", async () => {
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ precision: 5 }));
|
vi.mocked(localStorage.getItem).mockReturnValue(
|
||||||
|
JSON.stringify({ precision: 5 }),
|
||||||
|
);
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.settings');
|
expect(localStorage.getItem).toHaveBeenCalledWith("summarum.settings");
|
||||||
expect(settings.precision).toBe(5);
|
expect(settings.precision).toBe(5);
|
||||||
expect(settings.theme).toBe(defaultSettingsData.theme);
|
expect(settings.theme).toBe(defaultSettingsData.theme);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads via invoke when in Tauri', async () => {
|
it("loads via invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 }));
|
vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 }));
|
||||||
const settings = await loadSettings();
|
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.precision).toBe(8);
|
||||||
expect(settings.theme).toBe(defaultSettingsData.theme);
|
expect(settings.theme).toBe(defaultSettingsData.theme);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
it("falls back to defaultSettingsData if localStorage throws", async () => {
|
||||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
vi.mocked(localStorage.getItem).mockImplementation(() => {
|
||||||
|
throw new Error("localStorage error");
|
||||||
|
});
|
||||||
const settings = await loadSettings();
|
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);
|
expect(settings).toEqual(defaultSettingsData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to defaultSettingsData if invoke throws', async () => {
|
it("falls back to defaultSettingsData if invoke throws", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error("invoke error"));
|
||||||
const settings = await loadSettings();
|
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);
|
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);
|
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
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");
|
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||||
const settings = await loadSettings();
|
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);
|
expect(settings).toEqual(defaultSettingsData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('saveSettings', () => {
|
describe("saveSettings", () => {
|
||||||
it('saves to localStorage when not in Tauri', async () => {
|
it("saves to localStorage when not in Tauri", async () => {
|
||||||
const settings = { ...defaultSettingsData, precision: 10 };
|
const settings = { ...defaultSettingsData, precision: 10 };
|
||||||
await saveSettings(settings);
|
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 () => {
|
it("saves via invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
const settings = { ...defaultSettingsData, precision: 12 };
|
const settings = { ...defaultSettingsData, precision: 12 };
|
||||||
await saveSettings(settings);
|
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', () => {
|
describe("loadAppData", () => {
|
||||||
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
const mockAppData = { docs: [], activeId: "1", contents: {} };
|
||||||
|
|
||||||
it('loads from localStorage when not in Tauri', async () => {
|
it("loads from localStorage when not in Tauri", async () => {
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockAppData));
|
vi.mocked(localStorage.getItem).mockReturnValue(
|
||||||
const data = await loadAppData('');
|
JSON.stringify(mockAppData),
|
||||||
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.documents');
|
);
|
||||||
|
const data = await loadAppData("");
|
||||||
|
expect(localStorage.getItem).toHaveBeenCalledWith("summarum.documents");
|
||||||
expect(data).toEqual(mockAppData);
|
expect(data).toEqual(mockAppData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads via invoke when in Tauri', async () => {
|
it("loads via invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData("");
|
||||||
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: null });
|
expect(invoke).toHaveBeenCalledWith("load_documents", { dir: null });
|
||||||
expect(data).toEqual(mockAppData);
|
expect(data).toEqual(mockAppData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads via invoke when in Tauri with specific dataDir', async () => {
|
it("loads via invoke when in Tauri with specific dataDir", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
||||||
const data = await loadAppData('custom_dir');
|
const data = await loadAppData("custom_dir");
|
||||||
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: 'custom_dir' });
|
expect(invoke).toHaveBeenCalledWith("load_documents", {
|
||||||
|
dir: "custom_dir",
|
||||||
|
});
|
||||||
expect(data).toEqual(mockAppData);
|
expect(data).toEqual(mockAppData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null if localStorage throws', async () => {
|
it("returns null if localStorage throws", async () => {
|
||||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
vi.mocked(localStorage.getItem).mockImplementation(() => {
|
||||||
const data = await loadAppData('');
|
throw new Error("localStorage error");
|
||||||
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();
|
expect(data).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null if invoke throws', async () => {
|
it("returns null if invoke throws", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error("invoke error"));
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData("");
|
||||||
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
|
||||||
|
"loadAppData failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
expect(data).toBeNull();
|
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);
|
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData("");
|
||||||
expect(data).toBeNull();
|
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");
|
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData("");
|
||||||
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
|
||||||
|
"loadAppData failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
expect(data).toBeNull();
|
expect(data).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('saveAppData & flushAppData', () => {
|
describe("saveAppData & flushAppData", () => {
|
||||||
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
const mockAppData = { docs: [], activeId: "1", contents: {} };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
@ -161,8 +223,8 @@ describe('storage', () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('debounces saves and flushes correctly when not in Tauri', async () => {
|
it("debounces saves and flushes correctly when not in Tauri", async () => {
|
||||||
setDataDir('');
|
setDataDir("");
|
||||||
saveAppData(mockAppData);
|
saveAppData(mockAppData);
|
||||||
|
|
||||||
// Should not save immediately
|
// Should not save immediately
|
||||||
|
|
@ -171,13 +233,16 @@ describe('storage', () => {
|
||||||
// Fast forward past debounce time
|
// Fast forward past debounce time
|
||||||
vi.advanceTimersByTime(400);
|
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 () => {
|
it("debounces saves and flushes correctly when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
|
|
||||||
setDataDir('custom_dir');
|
setDataDir("custom_dir");
|
||||||
saveAppData(mockAppData);
|
saveAppData(mockAppData);
|
||||||
|
|
||||||
// Should not save immediately
|
// Should not save immediately
|
||||||
|
|
@ -192,17 +257,23 @@ describe('storage', () => {
|
||||||
// until it settles instead of guessing a fixed number of ticks.
|
// until it settles instead of guessing a fixed number of ticks.
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
await vi.waitFor(() => {
|
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 () => {
|
it("flushAppData clears timer and saves immediately", async () => {
|
||||||
setDataDir('');
|
setDataDir("");
|
||||||
saveAppData(mockAppData);
|
saveAppData(mockAppData);
|
||||||
|
|
||||||
await flushAppData();
|
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
|
// Advancing timers should not trigger another save
|
||||||
vi.mocked(localStorage.setItem).mockClear();
|
vi.mocked(localStorage.setItem).mockClear();
|
||||||
|
|
@ -210,208 +281,269 @@ describe('storage', () => {
|
||||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
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();
|
await flushAppData();
|
||||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles localStorage throw gracefully in flushAppData', async () => {
|
it("handles localStorage throw gracefully in flushAppData", async () => {
|
||||||
setDataDir('');
|
setDataDir("");
|
||||||
saveAppData(mockAppData);
|
saveAppData(mockAppData);
|
||||||
vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
vi.mocked(localStorage.setItem).mockImplementation(() => {
|
||||||
|
throw new Error("localStorage error");
|
||||||
|
});
|
||||||
|
|
||||||
await flushAppData();
|
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 () => {
|
it("handles invoke throw gracefully in flushAppData", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
setDataDir('');
|
setDataDir("");
|
||||||
saveAppData(mockAppData);
|
saveAppData(mockAppData);
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error("invoke error"));
|
||||||
|
|
||||||
await flushAppData();
|
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("backup and utility functions", () => {
|
||||||
describe('runBackups', () => {
|
describe("runBackups", () => {
|
||||||
it('does nothing when not in Tauri', async () => {
|
it("does nothing when not in Tauri", async () => {
|
||||||
await runBackups('dir', 10);
|
await runBackups("dir", 10);
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
it("calls invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
await runBackups('dir', 10);
|
await runBackups("dir", 10);
|
||||||
expect(invoke).toHaveBeenCalledWith('run_backups', { dir: 'dir', retentionDays: 10 });
|
expect(invoke).toHaveBeenCalledWith("run_backups", {
|
||||||
|
dir: "dir",
|
||||||
|
retentionDays: 10,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invoke throw gracefully', async () => {
|
it("handles invoke throw gracefully", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error("invoke error"));
|
||||||
await runBackups('dir', 10);
|
await runBackups("dir", 10);
|
||||||
expect(console.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
|
||||||
|
"runBackups failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('backupDeletedSheet', () => {
|
describe("backupDeletedSheet", () => {
|
||||||
it('does nothing when not in Tauri', async () => {
|
it("does nothing when not in Tauri", async () => {
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
await backupDeletedSheet("dir", "title", "contents");
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing if contents are empty', async () => {
|
it("does nothing if contents are empty", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
await backupDeletedSheet('dir', 'title', ' ');
|
await backupDeletedSheet("dir", "title", " ");
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls invoke when in Tauri with valid contents', async () => {
|
it("calls invoke when in Tauri with valid contents", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
await backupDeletedSheet("dir", "title", "contents");
|
||||||
expect(invoke).toHaveBeenCalledWith('backup_deleted_sheet', { dir: 'dir', title: 'title', contents: 'contents' });
|
expect(invoke).toHaveBeenCalledWith("backup_deleted_sheet", {
|
||||||
|
dir: "dir",
|
||||||
|
title: "title",
|
||||||
|
contents: "contents",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invoke throw gracefully', async () => {
|
it("handles invoke throw gracefully", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error("invoke error"));
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
await backupDeletedSheet("dir", "title", "contents");
|
||||||
expect(console.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
|
||||||
|
"backupDeletedSheet failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('openBackupsFolder', () => {
|
describe("openBackupsFolder", () => {
|
||||||
it('does nothing when not in Tauri', async () => {
|
it("does nothing when not in Tauri", async () => {
|
||||||
await openBackupsFolder('dir');
|
await openBackupsFolder("dir");
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
it("calls invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
await openBackupsFolder('dir');
|
await openBackupsFolder("dir");
|
||||||
expect(invoke).toHaveBeenCalledWith('open_backups_folder', { dir: 'dir' });
|
expect(invoke).toHaveBeenCalledWith("open_backups_folder", {
|
||||||
|
dir: "dir",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chooseFolder', () => {
|
describe("chooseFolder", () => {
|
||||||
it('returns null when not in Tauri', async () => {
|
it("returns null when not in Tauri", async () => {
|
||||||
expect(await chooseFolder()).toBeNull();
|
expect(await chooseFolder()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns path when in Tauri and folder is chosen', async () => {
|
it("returns path when in Tauri and folder is chosen", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockResolvedValue('chosen_path');
|
vi.mocked(invoke).mockResolvedValue("chosen_path");
|
||||||
expect(await chooseFolder()).toBe('chosen_path');
|
expect(await chooseFolder()).toBe("chosen_path");
|
||||||
expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined);
|
expect(invoke).toHaveBeenCalledWith("pick_data_dir", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when in Tauri and folder choice is cancelled', async () => {
|
it("returns null when in Tauri and folder choice is cancelled", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockResolvedValue(null);
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
expect(await chooseFolder()).toBeNull();
|
expect(await chooseFolder()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dataDirHasDocuments', () => {
|
describe("dataDirHasDocuments", () => {
|
||||||
it('returns false when not in Tauri', async () => {
|
it("returns false when not in Tauri", async () => {
|
||||||
expect(await dataDirHasDocuments('dir')).toBe(false);
|
expect(await dataDirHasDocuments("dir")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
it("calls invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockResolvedValue(true);
|
vi.mocked(invoke).mockResolvedValue(true);
|
||||||
expect(await dataDirHasDocuments('dir')).toBe(true);
|
expect(await dataDirHasDocuments("dir")).toBe(true);
|
||||||
expect(invoke).toHaveBeenCalledWith('data_dir_has_documents', { dir: 'dir' });
|
expect(invoke).toHaveBeenCalledWith("data_dir_has_documents", {
|
||||||
|
dir: "dir",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('migrateDataDir', () => {
|
describe("migrateDataDir", () => {
|
||||||
it('does nothing when not in Tauri', async () => {
|
it("does nothing when not in Tauri", async () => {
|
||||||
await migrateDataDir('old', 'new', 'move');
|
await migrateDataDir("old", "new", "move");
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
it("calls invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
await migrateDataDir('old', 'new', 'move');
|
await migrateDataDir("old", "new", "move");
|
||||||
expect(invoke).toHaveBeenCalledWith('migrate_data_dir', { oldDir: 'old', newDir: 'new', strategy: 'move' });
|
expect(invoke).toHaveBeenCalledWith("migrate_data_dir", {
|
||||||
|
oldDir: "old",
|
||||||
|
newDir: "new",
|
||||||
|
strategy: "move",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchRates', () => {
|
describe("fetchRates", () => {
|
||||||
it('uses invoke when in Tauri', async () => {
|
it("uses invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
const mockRates = { date: '2024-01-01', rates: { EUR: 0.85 }, fetchedAt: 12345 };
|
const mockRates = {
|
||||||
|
date: "2024-01-01",
|
||||||
|
rates: { EUR: 0.85 },
|
||||||
|
fetchedAt: 12345,
|
||||||
|
};
|
||||||
vi.mocked(invoke).mockResolvedValue(mockRates);
|
vi.mocked(invoke).mockResolvedValue(mockRates);
|
||||||
|
|
||||||
const result = await fetchRates(true);
|
const result = await fetchRates(true);
|
||||||
expect(invoke).toHaveBeenCalledWith('fetch_rates', { force: true });
|
expect(invoke).toHaveBeenCalledWith("fetch_rates", { force: true });
|
||||||
expect(result).toEqual(mockRates);
|
expect(result).toEqual(mockRates);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses fetch when not in Tauri', async () => {
|
it("uses fetch when not in Tauri", async () => {
|
||||||
const mockResponse = { result: 'success', time_last_update_utc: '2024-01-01', rates: { EUR: 0.85 } };
|
const mockResponse = {
|
||||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
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();
|
const result = await fetchRates();
|
||||||
expect(fetch).toHaveBeenCalledWith('https://open.er-api.com/v6/latest/USD');
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
expect(result?.date).toBe('2024-01-01');
|
"https://open.er-api.com/v6/latest/USD",
|
||||||
|
);
|
||||||
|
expect(result?.date).toBe("2024-01-01");
|
||||||
expect(result?.rates).toEqual({ EUR: 0.85 });
|
expect(result?.rates).toEqual({ EUR: 0.85 });
|
||||||
expect(result?.fetchedAt).toBeTypeOf('number');
|
expect(result?.fetchedAt).toBeTypeOf("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null if fetch fails', async () => {
|
it("returns null if fetch fails", async () => {
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
|
||||||
const result = await fetchRates();
|
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();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null if API result is not success', async () => {
|
it("returns null if API result is not success", async () => {
|
||||||
const mockResponse = { result: 'error', 'error-type': 'unsupported-code' };
|
const mockResponse = {
|
||||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
result: "error",
|
||||||
|
"error-type": "unsupported-code",
|
||||||
|
};
|
||||||
|
vi.mocked(fetch).mockResolvedValue({
|
||||||
|
json: vi.fn().mockResolvedValue(mockResponse),
|
||||||
|
} as unknown as Response);
|
||||||
const result = await fetchRates();
|
const result = await fetchRates();
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchHistoricalRates', () => {
|
describe("fetchHistoricalRates", () => {
|
||||||
it('uses invoke when in Tauri', async () => {
|
it("uses invoke when in Tauri", async () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal("window", { __TAURI_INTERNALS__: {} });
|
||||||
const mockRates = { EUR: 0.85 };
|
const mockRates = { EUR: 0.85 };
|
||||||
vi.mocked(invoke).mockResolvedValue(mockRates);
|
vi.mocked(invoke).mockResolvedValue(mockRates);
|
||||||
|
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
const result = await fetchHistoricalRates("2024-01-01");
|
||||||
expect(invoke).toHaveBeenCalledWith('fetch_historical_rates', { date: '2024-01-01' });
|
expect(invoke).toHaveBeenCalledWith("fetch_historical_rates", {
|
||||||
|
date: "2024-01-01",
|
||||||
|
});
|
||||||
expect(result).toEqual(mockRates);
|
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
|
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');
|
const result = await fetchHistoricalRates("2024-01-01");
|
||||||
expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2024-01-01?from=USD');
|
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
|
expect(result).toEqual({ USD: 1, EUR: 0.85, GBP: 0.75 }); // USD: 1 added, negative ignored
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null if fetch fails', async () => {
|
it("returns null if fetch fails", async () => {
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
const result = await fetchHistoricalRates("2024-01-01");
|
||||||
expect(console.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
|
||||||
|
"fetchHistoricalRates failed",
|
||||||
|
"2024-01-01",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null if rates missing in response', async () => {
|
it("returns null if rates missing in response", async () => {
|
||||||
const mockResponse = { amount: 1, base: 'USD', date: '2024-01-01' }; // missing rates
|
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);
|
vi.mocked(fetch).mockResolvedValue({
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
json: vi.fn().mockResolvedValue(mockResponse),
|
||||||
|
} as unknown as Response);
|
||||||
|
const result = await fetchHistoricalRates("2024-01-01");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
108
src/storage.ts
108
src/storage.ts
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { logger } from "./logger";
|
||||||
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
||||||
|
|
||||||
export interface DocMeta {
|
export interface DocMeta {
|
||||||
|
|
@ -65,7 +66,10 @@ export function isTauri(): boolean {
|
||||||
return "__TAURI_INTERNALS__" in window;
|
return "__TAURI_INTERNALS__" in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
async function invoke<T>(
|
||||||
|
cmd: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
return invoke<T>(cmd, args);
|
return invoke<T>(cmd, args);
|
||||||
}
|
}
|
||||||
|
|
@ -79,14 +83,15 @@ export async function loadSettings(): Promise<SettingsData> {
|
||||||
: localStorage.getItem("summarum.settings");
|
: localStorage.getItem("summarum.settings");
|
||||||
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("loadSettings failed", e);
|
logger.warn("loadSettings failed", e);
|
||||||
}
|
}
|
||||||
return { ...defaultSettingsData };
|
return { ...defaultSettingsData };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(s: SettingsData): Promise<void> {
|
export async function saveSettings(s: SettingsData): Promise<void> {
|
||||||
const raw = JSON.stringify(s, null, 2);
|
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);
|
else localStorage.setItem("summarum.settings", raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +104,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
|
||||||
: localStorage.getItem("summarum.documents");
|
: localStorage.getItem("summarum.documents");
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) return JSON.parse(raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("loadAppData failed", e);
|
logger.warn("loadAppData failed", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -129,10 +134,14 @@ export async function flushAppData(): Promise<void> {
|
||||||
const raw = JSON.stringify(pendingData);
|
const raw = JSON.stringify(pendingData);
|
||||||
pendingData = null;
|
pendingData = null;
|
||||||
try {
|
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);
|
else localStorage.setItem("summarum.documents", raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("saveAppData failed", e);
|
logger.warn("saveAppData failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,26 +149,40 @@ export async function flushAppData(): Promise<void> {
|
||||||
export async function onAppQuit(cb: () => Promise<void>): Promise<void> {
|
export async function onAppQuit(cb: () => Promise<void>): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
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
|
// ---------- backups & data folder
|
||||||
|
|
||||||
export async function runBackups(dataDir: string, retentionDays: number): Promise<void> {
|
export async function runBackups(
|
||||||
|
dataDir: string,
|
||||||
|
retentionDays: number,
|
||||||
|
): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
try {
|
try {
|
||||||
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("runBackups failed", e);
|
logger.warn("runBackups failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function backupDeletedSheet(dataDir: string, title: string, contents: string): Promise<void> {
|
export async function backupDeletedSheet(
|
||||||
|
dataDir: string,
|
||||||
|
title: string,
|
||||||
|
contents: string,
|
||||||
|
): Promise<void> {
|
||||||
if (!isTauri() || !contents.trim()) return;
|
if (!isTauri() || !contents.trim()) return;
|
||||||
try {
|
try {
|
||||||
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
await invoke("backup_deleted_sheet", {
|
||||||
|
dir: dirArg(dataDir),
|
||||||
|
title,
|
||||||
|
contents,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("backupDeletedSheet failed", e);
|
logger.warn("backupDeletedSheet failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,9 +203,17 @@ export async function dataDirHasDocuments(dir: string): Promise<boolean> {
|
||||||
|
|
||||||
export type MigrateStrategy = "move" | "overwrite" | "use_existing";
|
export type MigrateStrategy = "move" | "overwrite" | "use_existing";
|
||||||
|
|
||||||
export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise<void> {
|
export async function migrateDataDir(
|
||||||
|
oldDir: string,
|
||||||
|
newDir: string,
|
||||||
|
strategy: MigrateStrategy,
|
||||||
|
): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy });
|
await invoke("migrate_data_dir", {
|
||||||
|
oldDir: dirArg(oldDir),
|
||||||
|
newDir,
|
||||||
|
strategy,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- rates
|
// ---------- rates
|
||||||
|
|
@ -202,40 +233,53 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
||||||
const res = await fetch("https://open.er-api.com/v6/latest/USD");
|
const res = await fetch("https://open.er-api.com/v6/latest/USD");
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json?.result === "success") {
|
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) {
|
} catch (e) {
|
||||||
console.warn("fetchRates failed", e);
|
logger.warn("fetchRates failed", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- historical rates
|
// ---------- historical rates
|
||||||
|
|
||||||
export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record<string, Record<string, number>>> {
|
export async function fetchHistoricalRatesBatch(
|
||||||
|
dates: string[],
|
||||||
|
): Promise<Record<string, Record<string, number>>> {
|
||||||
if (dates.length === 0) return {};
|
if (dates.length === 0) return {};
|
||||||
try {
|
try {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
return await invoke<Record<string, Record<string, number>>>("fetch_historical_rates_batch", { dates });
|
return await invoke<Record<string, Record<string, number>>>(
|
||||||
|
"fetch_historical_rates_batch",
|
||||||
|
{ dates },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const results: Record<string, Record<string, number>> = {};
|
const results: Record<string, Record<string, number>> = {};
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
dates.map(async (date) => {
|
dates.map(async (date) => {
|
||||||
const rates = await fetchHistoricalRates(date);
|
const rates = await fetchHistoricalRates(date);
|
||||||
if (rates) results[date] = rates;
|
if (rates) results[date] = rates;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("fetchHistoricalRatesBatch failed", e);
|
logger.warn("fetchHistoricalRatesBatch failed", e);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHistoricalRates(date: string): Promise<Record<string, number> | null> {
|
export async function fetchHistoricalRates(
|
||||||
|
date: string,
|
||||||
|
): Promise<Record<string, number> | null> {
|
||||||
try {
|
try {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
return await invoke<Record<string, number>>("fetch_historical_rates", { date });
|
return await invoke<Record<string, number>>("fetch_historical_rates", {
|
||||||
|
date,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`);
|
const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|
@ -246,7 +290,7 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("fetchHistoricalRates failed", date, e);
|
logger.warn("fetchHistoricalRates failed", date, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,24 +304,30 @@ export async function writeImageFile(dataBase64: string): Promise<boolean> {
|
||||||
|
|
||||||
// ---------- market data
|
// ---------- market data
|
||||||
|
|
||||||
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {
|
export async function fetchMarketData(
|
||||||
|
symbols: string[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
if (!isTauri() || symbols.length === 0) return {};
|
if (!isTauri() || symbols.length === 0) return {};
|
||||||
try {
|
try {
|
||||||
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
return await invoke<Record<string, number>>("fetch_market_data", {
|
||||||
|
symbols,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("fetchMarketData failed", e);
|
logger.warn("fetchMarketData failed", e);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- extensions
|
// ---------- extensions
|
||||||
|
|
||||||
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
export async function loadExtensionScripts(): Promise<
|
||||||
|
{ name: string; code: string }[]
|
||||||
|
> {
|
||||||
if (!isTauri()) return [];
|
if (!isTauri()) return [];
|
||||||
try {
|
try {
|
||||||
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("loadExtensions failed", e);
|
logger.warn("loadExtensions failed", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +364,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
|
||||||
const content = await invoke<string>("read_text_file", { path });
|
const content = await invoke<string>("read_text_file", { path });
|
||||||
cb(content);
|
cb(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("file drop rejected", path, e);
|
logger.warn("file drop rejected", path, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,39 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { logger } from "./logger";
|
||||||
import { checkForUpdate } from './updater';
|
|
||||||
import * as storage from './storage';
|
|
||||||
import type { Update } from '@tauri-apps/plugin-updater';
|
|
||||||
|
|
||||||
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(),
|
isTauri: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tauri-apps/plugin-updater', () => ({
|
vi.mock("@tauri-apps/plugin-updater", () => ({
|
||||||
check: vi.fn(),
|
check: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tauri-apps/api/app', () => ({
|
vi.mock("@tauri-apps/api/app", () => ({
|
||||||
getVersion: vi.fn(),
|
getVersion: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tauri-apps/plugin-process', () => ({
|
vi.mock("@tauri-apps/plugin-process", () => ({
|
||||||
relaunch: vi.fn(),
|
relaunch: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('checkForUpdate', () => {
|
describe("checkForUpdate", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
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);
|
vi.mocked(storage.isTauri).mockReturnValue(false);
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
|
|
@ -34,44 +41,49 @@ describe('checkForUpdate', () => {
|
||||||
|
|
||||||
it('should return "error" if dynamic import or check throws', async () => {
|
it('should return "error" if dynamic import or check throws', async () => {
|
||||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
vi.mocked(storage.isTauri).mockReturnValue(true);
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
vi.mocked(check).mockRejectedValue(new Error('Network error'));
|
vi.mocked(check).mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
expect(result).toBe('error');
|
expect(result).toBe("error");
|
||||||
expect(console.warn).toHaveBeenCalledWith('update check failed', expect.any(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);
|
vi.mocked(storage.isTauri).mockReturnValue(true);
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
const { getVersion } = await import('@tauri-apps/api/app');
|
const { getVersion } = await import("@tauri-apps/api/app");
|
||||||
|
|
||||||
vi.mocked(check).mockResolvedValue(null);
|
vi.mocked(check).mockResolvedValue(null);
|
||||||
vi.mocked(getVersion).mockResolvedValue('1.0.0');
|
vi.mocked(getVersion).mockResolvedValue("1.0.0");
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
expect(result).toBeNull();
|
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);
|
vi.mocked(storage.isTauri).mockReturnValue(true);
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
|
|
||||||
const mockUpdate = {
|
const mockUpdate = {
|
||||||
version: '1.1.0',
|
version: "1.1.0",
|
||||||
currentVersion: '1.0.0',
|
currentVersion: "1.0.0",
|
||||||
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
|
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as Update;
|
} as unknown as Update;
|
||||||
vi.mocked(check).mockResolvedValue(mockUpdate);
|
vi.mocked(check).mockResolvedValue(mockUpdate);
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
if (result !== 'error' && result !== null) {
|
if (result !== "error" && result !== null) {
|
||||||
expect(result.version).toBe('1.1.0');
|
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();
|
await result.install();
|
||||||
expect(mockUpdate.downloadAndInstall).toHaveBeenCalled();
|
expect(mockUpdate.downloadAndInstall).toHaveBeenCalled();
|
||||||
expect(relaunch).toHaveBeenCalled();
|
expect(relaunch).toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { logger } from "./logger";
|
||||||
import { isTauri } from "./storage";
|
import { isTauri } from "./storage";
|
||||||
|
|
||||||
interface AvailableUpdate {
|
interface AvailableUpdate {
|
||||||
|
|
@ -10,7 +11,9 @@ interface AvailableUpdate {
|
||||||
* itself failed (offline, updater/signature error); running outside Tauri
|
* itself failed (offline, updater/signature error); running outside Tauri
|
||||||
* (`vite dev`) also reports null.
|
* (`vite dev`) also reports null.
|
||||||
*/
|
*/
|
||||||
export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"> {
|
export async function checkForUpdate(): Promise<
|
||||||
|
AvailableUpdate | null | "error"
|
||||||
|
> {
|
||||||
if (!isTauri()) return null;
|
if (!isTauri()) return null;
|
||||||
try {
|
try {
|
||||||
const { check } = await import("@tauri-apps/plugin-updater");
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
|
|
@ -19,10 +22,14 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
||||||
// visible in devtools console — the fastest way to tell "already on
|
// visible in devtools console — the fastest way to tell "already on
|
||||||
// the latest published release" apart from "the check silently failed"
|
// the latest published release" apart from "the check silently failed"
|
||||||
const { getVersion } = await import("@tauri-apps/api/app");
|
const { getVersion } = await import("@tauri-apps/api/app");
|
||||||
console.info(`no update: running ${await getVersion()}, that's the latest published release`);
|
logger.info(
|
||||||
|
`no update: running ${await getVersion()}, that's the latest published release`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.info(`update available: ${update.currentVersion} -> ${update.version}`);
|
logger.info(
|
||||||
|
`update available: ${update.currentVersion} -> ${update.version}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
version: update.version,
|
version: update.version,
|
||||||
async install() {
|
async install() {
|
||||||
|
|
@ -32,7 +39,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("update check failed", e);
|
logger.warn("update check failed", e);
|
||||||
return "error";
|
return "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue