RU math phrases, half/полтора constants, export button

- vocab: EN+RU aliases for abs/log/ln/round; RU already had sqrt/cbrt
- engine: "half"/"a half"/"one half"/"половина" → 0.5; "one and a half"/"полтора"/"полторы" → 1.5 as named constants; widen const union in parser/tokenizer/evaluator/registry
- export: ⤓ button in statusbar dropdown — copy sheet, save .sum, export .txt, print/PDF; write_text_file Tauri command; print media CSS hides chrome
This commit is contained in:
Horizontal42 2026-06-24 00:27:06 +03:00
parent db37218b66
commit b0c46c390c
11 changed files with 150 additions and 5 deletions

View file

@ -30,6 +30,15 @@
<div id="statusbar"> <div id="statusbar">
<span id="rates-info" title=""></span> <span id="rates-info" title=""></span>
<span class="spacer"></span> <span class="spacer"></span>
<div id="export-wrap">
<button id="export-btn" title="Export">⤓</button>
<div id="export-menu" class="hidden">
<button data-action="copy" data-i18n="exportCopy">Copy sheet</button>
<button data-action="save-sum" data-i18n="exportSum">Save as .sum</button>
<button data-action="export-txt" data-i18n="exportTxt">Export as text</button>
<button data-action="print" data-i18n="exportPrint">Print / PDF</button>
</div>
</div>
<span id="total-line" title=""></span> <span id="total-line" title=""></span>
</div> </div>
</main> </main>

View file

@ -37,6 +37,11 @@ fn safe_name(name: &str) -> bool {
!name.is_empty() && !name.contains(['/', '\\', ':']) && !name.contains("..") !name.is_empty() && !name.contains(['/', '\\', ':']) && !name.contains("..")
} }
#[tauri::command]
fn write_text_file(path: String, contents: String) -> Result<(), String> {
write_atomic(std::path::Path::new(&path), &contents)
}
/// write via a temp file + rename so a crash mid-write cannot corrupt the target /// write via a temp file + rename so a crash mid-write cannot corrupt the target
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> { fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let tmp = path.with_extension("tmp"); let tmp = path.with_extension("tmp");
@ -521,7 +526,8 @@ fn main() {
get_launch_file, get_launch_file,
read_text_file, read_text_file,
exit_app, exit_app,
is_hidden_launch is_hidden_launch,
write_text_file
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();

View file

@ -71,6 +71,23 @@ describe("arithmetic", () => {
expect(calc("round(2.6)")).toBe("3"); expect(calc("round(2.6)")).toBe("3");
expect(calc("log(1000)")).toBe("3"); expect(calc("log(1000)")).toBe("3");
expect(calc("ln(1)")).toBe("0"); expect(calc("ln(1)")).toBe("0");
// russian aliases
expect(calc("квадратный корень из 16")).toBe("4");
expect(calc("кубический корень из 27")).toBe("3");
expect(calc("модуль(-5)")).toBe("5");
expect(calc("логарифм(1000)")).toBe("3");
expect(calc("натуральный логарифм(1)")).toBe("0");
expect(calc("округлить(2.6)")).toBe("3");
});
it("fraction constants", () => {
expect(calc("a half")).toBe("0.5");
expect(calc("one half")).toBe("0.5");
expect(calc("половина")).toBe("0.5");
expect(calc("a half million")).toBe("500,000");
expect(calc("one and a half")).toBe("1.5");
expect(calc("полтора")).toBe("1.5");
expect(calc("полтора миллиона")).toBe("1,500,000");
expect(calc("полторы тысячи")).toBe("1,500");
}); });
}); });

View file

@ -27,7 +27,12 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "num": case "num":
return qty(node.v, null, node.repr); return qty(node.v, null, node.repr);
case "const": case "const":
return qty(node.name === "pi" ? PI : E); switch (node.name) {
case "pi": return qty(PI);
case "e": return qty(E);
case "half": return qty(new Decimal("0.5"));
case "onehalf": return qty(new Decimal("1.5"));
}
case "var": { case "var": {
const v = ctx.vars.get(node.name); const v = ctx.vars.get(node.name);
if (!v) throw new EvalError(`unknown variable ${node.name}`); if (!v) throw new EvalError(`unknown variable ${node.name}`);

View file

@ -16,7 +16,7 @@ export type ConvTarget =
export type Node = export type Node =
| { k: "num"; v: Decimal; repr: NumeralRepr } | { k: "num"; v: Decimal; repr: NumeralRepr }
| { k: "const"; name: "pi" | "e" } | { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
| { k: "var"; name: string } | { k: "var"; name: string }
| { k: "date"; word: DateWord } | { k: "date"; word: DateWord }
| { k: "agg"; name: "sum" | "avg" | "prev" } | { k: "agg"; name: "sum" | "avg" | "prev" }

View file

@ -32,7 +32,7 @@ export type Payload =
| { t: "repr"; repr: NumeralRepr } | { t: "repr"; repr: NumeralRepr }
| { t: "special"; name: "unix" | "todate" } | { t: "special"; name: "unix" | "todate" }
| { t: "date"; word: DateWord } | { t: "date"; word: DateWord }
| { t: "const"; name: "pi" | "e" }; | { t: "const"; name: "pi" | "e" | "half" | "onehalf" };
interface PhraseEntry { interface PhraseEntry {
lexemes: string[]; // raw, as in the phrase lexemes: string[]; // raw, as in the phrase
@ -172,6 +172,10 @@ export function buildRegistry(): Registry {
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", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { 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", "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", "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 });
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
@ -231,6 +235,12 @@ export function buildRegistry(): Registry {
reg.addPhrase("π", { t: "const", name: "pi" }); reg.addPhrase("π", { t: "const", name: "pi" });
reg.addPhrase("пи", { t: "const", name: "pi" }, { caseSensitive: false }); reg.addPhrase("пи", { t: "const", name: "pi" }, { caseSensitive: false });
reg.addPhrase("e", { t: "const", name: "e" }, { caseSensitive: true }); reg.addPhrase("e", { t: "const", name: "e" }, { caseSensitive: true });
reg.addPhrase("a half", { t: "const", name: "half" }, { caseSensitive: false });
reg.addPhrase("one half", { t: "const", name: "half" }, { caseSensitive: false });
reg.addPhrase("половина", { t: "const", name: "half" }, { caseSensitive: false });
reg.addPhrase("one and a half", { t: "const", name: "onehalf" }, { caseSensitive: false });
reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false });
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false }); for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });

View file

@ -19,7 +19,7 @@ export type Token =
| { t: "scale"; mult: Decimal; start: number; end: number } | { t: "scale"; mult: Decimal; start: number; end: number }
| { t: "repr"; repr: NumeralRepr; start: number; end: number } | { t: "repr"; repr: NumeralRepr; start: number; end: number }
| { t: "date"; word: DateWord; start: number; end: number } | { t: "date"; word: DateWord; start: number; end: number }
| { t: "const"; name: "pi" | "e"; start: number; end: number } | { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
| { t: "lparen"; start: number; end: number } | { t: "lparen"; start: number; end: number }
| { t: "rparen"; start: number; end: number } | { t: "rparen"; start: number; end: number }
| { t: "semicolon"; start: number; end: number } | { t: "semicolon"; start: number; end: number }

View file

@ -423,6 +423,10 @@ export const EN: VocabLocale = {
"plus.variants": "with,plus,and", "plus.variants": "with,plus,and",
"prev.variants": "prev", "prev.variants": "prev",
"sqrt.variants": "sqrt,square root", "sqrt.variants": "sqrt,square root",
"abs.variants": "absolute value,absolute",
"ln.variants": "natural log,natural logarithm",
"log.variants": "logarithm",
"round.variants": "rounded",
"sum.variants": "sum,total" "sum.variants": "sum,total"
}, },
"Percent": { "Percent": {
@ -889,6 +893,10 @@ export const RU: VocabLocale = {
"plus.variants": "и,плюс,вместе с", "plus.variants": "и,плюс,вместе с",
"prev.variants": "пред.,пред", "prev.variants": "пред.,пред",
"sqrt.variants": "квадратный корень,кв корень,кв. корень", "sqrt.variants": "квадратный корень,кв корень,кв. корень",
"abs.variants": "модуль,абсолютное значение",
"ln.variants": "натуральный логарифм,нат. логарифм",
"log.variants": "логарифм",
"round.variants": "округлить,округление",
"sum.variants": "всего,сумма,итог,итого" "sum.variants": "всего,сумма,итог,итого"
}, },
"Percent": { "Percent": {

View file

@ -42,6 +42,11 @@ const dict: Record<string, Record<string, string>> = {
folderError: "Could not change the data folder", folderError: "Could not change the data folder",
hotkeyFailed: "Hotkey is taken by another app", hotkeyFailed: "Hotkey is taken by another app",
decimalsep: "Decimal separator", decimalsep: "Decimal separator",
exportCopy: "Copy sheet",
exportSum: "Save as .sum",
exportTxt: "Export as text",
exportPrint: "Print / PDF",
saved: "Saved",
}, },
ru: { ru: {
settings: "Настройки", settings: "Настройки",
@ -84,6 +89,11 @@ const dict: Record<string, Record<string, string>> = {
folderError: "Не удалось сменить папку данных", folderError: "Не удалось сменить папку данных",
hotkeyFailed: "Хоткей занят другой программой", hotkeyFailed: "Хоткей занят другой программой",
decimalsep: "Десятичный разделитель", decimalsep: "Десятичный разделитель",
exportCopy: "Скопировать лист",
exportSum: "Сохранить как .sum",
exportTxt: "Экспорт в текст",
exportPrint: "Печать / PDF",
saved: "Сохранено",
}, },
}; };

View file

@ -594,6 +594,41 @@ async function boot(): Promise<void> {
} }
}); });
// export button + dropdown
const exportBtn = $("#export-btn");
const exportMenu = $("#export-menu");
exportBtn.addEventListener("click", (e) => {
e.stopPropagation();
exportMenu.classList.toggle("hidden");
});
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
exportMenu.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action]");
if (!btn) return;
exportMenu.classList.add("hidden");
const action = btn.dataset.action;
if (action === "copy") {
void navigator.clipboard.writeText(editor.getSheetWithResults());
toast(t("copied"));
} else if (action === "print") {
window.print();
} else if (isTauri()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { invoke } = await import("@tauri-apps/api/core");
const isSum = action === "save-sum";
const path = await save({
filters: isSum
? [{ name: "Summarum Sheet", extensions: ["sum"] }]
: [{ name: "Text file", extensions: ["txt"] }],
});
if (path) {
const content = isSum ? editor.getText() : editor.getSheetWithResults();
await invoke("write_text_file", { path, contents: content });
toast(t("saved"));
}
}
});
$("#rates-info").addEventListener("click", () => void refreshRates(true)); $("#rates-info").addEventListener("click", () => void refreshRates(true));
setInterval(renderRatesInfo, 60_000); setInterval(renderRatesInfo, 60_000);

View file

@ -188,6 +188,51 @@ body {
#total-line:hover { text-decoration: underline; } #total-line:hover { text-decoration: underline; }
#total-line .total-label { color: var(--fg-muted); font-family: var(--font); margin-right: 8px; } #total-line .total-label { color: var(--fg-muted); font-family: var(--font); margin-right: 8px; }
/* ---------- export */
#export-wrap { position: relative; }
#export-btn {
background: none;
border: none;
cursor: pointer;
color: var(--fg-muted);
font-size: 15px;
padding: 0 4px;
line-height: 1;
}
#export-btn:hover { color: var(--fg); }
#export-menu {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
background: var(--toolbar-bg);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
min-width: 160px;
padding: 4px 0;
z-index: 100;
}
#export-menu.hidden { display: none; }
#export-menu button {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 7px 14px;
font-size: 13px;
color: var(--fg);
cursor: pointer;
font-family: var(--font);
}
#export-menu button:hover { background: var(--hover); }
@media print {
#titlebar, #statusbar, #sidebar { display: none !important; }
#content { display: block; }
#editor-wrap { overflow: visible; height: auto; }
}
/* ---------- editor + results */ /* ---------- editor + results */
#editor-wrap { position: relative; flex: 1; overflow: hidden; --results-width: 42%; } #editor-wrap { position: relative; flex: 1; overflow: hidden; --results-width: 42%; }
#editor { height: 100%; } #editor { height: 100%; }