From 532c3265ad8dc1901ef604ec57a43a2cb7394647 Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:58:54 +0300 Subject: [PATCH] add block aggregates count/min/max/product, Ctrl+W close sheet --- src/engine/engine.test.ts | 15 +++++++++++++++ src/engine/evaluator.ts | 17 ++++++++++++++++- src/engine/parser.ts | 2 +- src/engine/registry.ts | 6 +++++- src/engine/vocab-data.ts | 12 ++++++++++-- src/i18n.ts | 6 ++++++ src/main.ts | 22 ++++++++++++++++++++++ 7 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 2e93492..7c14be7 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -231,6 +231,21 @@ describe("variables and document context", () => { it("prev", () => { expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]); }); + it("count", () => { + expect(calcDoc("10\n20\n30\ncount")).toEqual(["10", "20", "30", "3"]); + expect(calcDoc("10\n20\n\n30\ncount")).toEqual(["10", "20", null, "30", "1"]); + }); + it("min / max", () => { + expect(calcDoc("10\n20\n5\nmin")).toEqual(["10", "20", "5", "5"]); + expect(calcDoc("10\n20\n5\nmax")).toEqual(["10", "20", "5", "20"]); + }); + it("min / max with units", () => { + expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual(["10 m", "5 m", "20 m", "5 m"]); + expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual(["10 m", "5 m", "20 m", "20 m"]); + }); + it("product", () => { + expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]); + }); it("headers split blocks and produce no result", () => { expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]); }); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 34aec2f..ebc79d0 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -395,7 +395,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value { return { ...date, ms, hasTime }; } -function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value { +function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product", ctx: EvalCtx): Value { const { lineValues, lineKinds, index } = ctx.line; if (name === "prev") { for (let i = index - 1; i >= 0; i--) { @@ -416,6 +416,21 @@ function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value { } if (block.length === 0) throw new EvalError("nothing to aggregate"); block.reverse(); + + if (name === "count") return qty(new Decimal(block.length)); + + if (name === "min" || name === "max") { + const refDim = block[0].unit?.dimension ?? null; + const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim); + return compatible.reduce((best, q) => + (name === "min" ? toBase(q).lt(toBase(best)) : toBase(q).gt(toBase(best))) ? q : best + ); + } + + if (name === "product") { + return qty(block.reduce((acc, q) => acc.mul(q.value), new Decimal(1))); + } + let acc = block[0]; let counted = 1; for (let i = 1; i < block.length; i++) { diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 9b50e54..d284437 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -19,7 +19,7 @@ export type Node = | { k: "const"; name: "pi" | "e" | "half" | "onehalf" } | { k: "var"; name: string } | { k: "date"; word: DateWord } - | { k: "agg"; name: "sum" | "avg" | "prev" } + | { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" } | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node } | { k: "pctop"; op: PctOp; l: Node; r: Node } diff --git a/src/engine/registry.ts b/src/engine/registry.ts index 83bb562..0122f1a 100644 --- a/src/engine/registry.ts +++ b/src/engine/registry.ts @@ -27,7 +27,7 @@ export type Payload = | { t: "pctop"; op: PctOp } | { t: "percent" } | { t: "func"; name: string } - | { t: "agg"; name: "sum" | "avg" | "prev" } + | { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" } | { t: "scale"; mult: Decimal } | { t: "repr"; repr: NumeralRepr } | { t: "special"; name: "unix" | "todate" } @@ -170,6 +170,10 @@ export function buildRegistry(): Registry { for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false }); diff --git a/src/engine/vocab-data.ts b/src/engine/vocab-data.ts index 2663d95..5de8097 100644 --- a/src/engine/vocab-data.ts +++ b/src/engine/vocab-data.ts @@ -427,7 +427,11 @@ export const EN: VocabLocale = { "ln.variants": "natural log,natural logarithm", "log.variants": "logarithm", "round.variants": "rounded", - "sum.variants": "sum,total" + "sum.variants": "sum,total", + "count.variants": "count,how many", + "min.variants": "min,minimum,smallest", + "max.variants": "max,maximum,largest", + "product.variants": "product" }, "Percent": { "as_a_percent_of.variants": "as a % of,as a percent of", @@ -899,7 +903,11 @@ export const RU: VocabLocale = { "ln.variants": "натуральный логарифм,нат. логарифм", "log.variants": "логарифм", "round.variants": "округлить,округление", - "sum.variants": "всего,сумма,итог,итого" + "sum.variants": "всего,сумма,итог,итого", + "count.variants": "количество,кол-во,счёт,число строк", + "min.variants": "минимум,мин,наименьшее", + "max.variants": "максимум,макс,наибольшее", + "product.variants": "произведение,произв" }, "Percent": { "as_a_percent_of.variants": "как процент от,как процент", diff --git a/src/i18n.ts b/src/i18n.ts index d1f812d..6dcc77b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -55,6 +55,9 @@ const dict: Record> = { updateFailed: "Update failed", searchPlaceholder: "Search all sheets…", searchEmpty: "No matches", + closeSheet: "Close this sheet?", + close: "Close", + cancel: "Cancel", }, ru: { settings: "Настройки", @@ -110,6 +113,9 @@ const dict: Record> = { updateFailed: "Не удалось обновить", searchPlaceholder: "Поиск по всем листам…", searchEmpty: "Ничего не найдено", + closeSheet: "Закрыть этот лист?", + close: "Закрыть", + cancel: "Отмена", }, }; diff --git a/src/main.ts b/src/main.ts index 230faf8..dc9f2fe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,6 +111,24 @@ function newDoc(content = ""): void { switchDoc(id); } +async function closeActiveDoc(): Promise { + if (data.docs.length <= 1) return; + const doc = data.docs.find((d) => d.id === data.activeId); + if (!doc) return; + const content = data.contents[doc.id] ?? ""; + if (content.trim()) { + const ans = await askModal(t("closeSheet"), t("close"), t("cancel")); + if (ans !== "a") return; + } + const idx = data.docs.findIndex((d) => d.id === doc.id); + void backupDeletedSheet(settings.dataDir, doc.title, content); + delete data.contents[doc.id]; + data.docs = data.docs.filter((d) => d.id !== doc.id); + const nextId = data.docs[idx] ? data.docs[idx].id : data.docs[idx - 1]?.id ?? data.docs[0].id; + switchDoc(nextId); + saveAppData(data); +} + // ---------- search all sheets interface SearchHit { @@ -682,6 +700,10 @@ async function boot(): Promise { e.preventDefault(); newDoc(); } + if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "w") { + e.preventDefault(); + void closeActiveDoc(); + } if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") { e.preventDefault(); $("#toggle-sidebar").click();