mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 23:51:59 +03:00
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:
parent
db37218b66
commit
b0c46c390c
11 changed files with 150 additions and 5 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
10
src/i18n.ts
10
src/i18n.ts
|
|
@ -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: "Сохранено",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
35
src/main.ts
35
src/main.ts
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%; }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue