From fbeca06f2ee49c53b77faa1ac623acec305f5433 Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:23:35 +0300 Subject: [PATCH] historical rates: 1000 USD in EUR on 2024-01-01 via frankfurter.app --- src-tauri/src/main.rs | 55 +++++++++++++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/engine/engine.test.ts | 10 +++++++ src/engine/evaluator.ts | 14 ++++++++-- src/engine/index.ts | 14 ++++++++-- src/engine/parser.ts | 24 ++++++++++++++--- src/engine/registry.ts | 6 ++++- src/main.ts | 24 +++++++++++++++-- src/storage.ts | 21 +++++++++++++++ 9 files changed, 159 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f69d915..ab961e2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -315,6 +315,60 @@ async fn fetch_market_data( Ok(prices) } +// ---------- historical rates (frankfurter.app, cached permanently per date) + +fn valid_date_str(date: &str) -> bool { + let parts: Vec<&str> = date.split('-').collect(); + parts.len() == 3 + && parts[0].len() == 4 + && parts[1].len() == 2 + && parts[2].len() == 2 + && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) +} + +#[tauri::command] +async fn fetch_historical_rates( + app: AppHandle, + date: String, +) -> Result, String> { + if !valid_date_str(&date) { + return Err("invalid date format".into()); + } + let cache_path = data_dir(&app).join(format!("rates-{}.json", date)); + if let Ok(raw) = fs::read_to_string(&cache_path) { + if let Ok(cached) = serde_json::from_str::>(&raw) { + return Ok(cached); + } + } + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string())?; + let url = format!("https://api.frankfurter.app/{}?from=USD", date); + let body: serde_json::Value = client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + let raw_rates = body["rates"].as_object().ok_or("no rates in response")?; + let mut rates = std::collections::HashMap::new(); + rates.insert("USD".to_string(), 1.0_f64); + for (code, val) in raw_rates { + if let Some(v) = val.as_f64() { + if v > 0.0 { + rates.insert(code.to_uppercase(), v); + } + } + } + if let Ok(serialized) = serde_json::to_string(&rates) { + write_atomic(&cache_path, &serialized).ok(); + } + Ok(rates) +} + // ---------- documents in the (possibly custom) data folder #[tauri::command] @@ -636,6 +690,7 @@ fn main() { data_dir_has_documents, migrate_data_dir, fetch_rates, + fetch_historical_rates, fetch_market_data, load_extensions, open_extensions_folder, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 452f3c6..3c45b29 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com" + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.app" } }, "bundle": { diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 9dc3bf1..4082618 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -302,6 +302,16 @@ describe("dates", () => { }); }); +describe("historical rates", () => { + it("in EUR on date uses injected historical rate", () => { + eng.setHistoricalRates("2024-01-01", { EUR: 0.9, USD: 1 }); + expect(calc("1000 USD in EUR on 2024-01-01")).toBe("€ 900"); + }); + it("missing historical date returns null", () => { + expect(calc("500 USD in EUR on 1800-01-01")).toBeNull(); + }); +}); + describe("goal seek", () => { it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33")); it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150")); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 2fca796..bb364f6 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -19,6 +19,8 @@ export interface EvalCtx { reg: Registry; vars: Map; line: LineCtx; + /** date → { code → rate-per-usd } */ + historicalRates?: Map>; } const PI = new Decimal("3.14159265358979323846264338327950288419716939937510"); @@ -332,8 +334,16 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value { return convertQ(v, target.unit); } case "currency": { - const unit = ctx.reg.makeCurrencyUnit(target.code); - if (!unit) throw new EvalError(`no rate for ${target.code}`); + let unit; + if (target.onDate) { + const dateRates = ctx.historicalRates?.get(target.onDate); + const rate = dateRates?.get(target.code); + if (rate === undefined) throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`); + unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate)); + } else { + unit = ctx.reg.makeCurrencyUnit(target.code); + if (!unit) throw new EvalError(`no rate for ${target.code}`); + } if (v.kind !== "quantity") throw new EvalError("cannot convert to currency"); if (!v.unit) return qty(v.value, unit); return convertQ(v, unit); diff --git a/src/engine/index.ts b/src/engine/index.ts index 9b6388b..3c77169 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -39,8 +39,8 @@ export interface ExtensionValue { export class SumEngine { reg: Registry; settings: EngineSettings; - /** variables defined by extensions, persist across evaluations */ private globals = new Map(); + private historicalRates = new Map>(); constructor(settings: Partial = {}) { this.settings = { ...defaultSettings, ...settings }; @@ -49,10 +49,19 @@ export class SumEngine { } setRates(rates: Record): void { - // snapshot fills codes the live API doesn't return (e.g. crypto when CoinGecko fails) this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates }); } + setHistoricalRates(date: string, rates: Record): void { + const m = new Map(); + for (const [k, v] of Object.entries(rates)) m.set(k.toUpperCase(), v); + this.historicalRates.set(date, m); + } + + hasHistoricalRates(date: string): boolean { + return this.historicalRates.has(date); + } + updateSettings(patch: Partial): void { this.settings = { ...this.settings, ...patch }; } @@ -98,6 +107,7 @@ export class SumEngine { reg: this.reg, vars, line: { lineValues, lineKinds, index: i, lineText: rawLine }, + historicalRates: this.historicalRates, }; try { value = evaluate(parsed.expr, ctx); diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 67005f9..effe694 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -7,7 +7,7 @@ import { PctOp, DateWord, BitOp } from "./registry"; export type ConvTarget = | { type: "unit"; unit: Unit } - | { type: "currency"; code: string } + | { type: "currency"; code: string; onDate?: string } | { type: "repr"; repr: NumeralRepr } | { type: "tz"; words: string[] } | { type: "scale"; mult: Decimal; label: string } @@ -115,7 +115,7 @@ class Parser { this.i++; const target = this.parseTarget(); if (target) { - items = [{ k: "conv", x: mk(), target }]; + items = [{ k: "conv", x: mk(), target: this.tryHistoricalDate(target) }]; continue; } this.i = save + 1; // drop the conversion word, it was noise @@ -195,7 +195,7 @@ class Parser { this.i = save; break; } - l = { k: "conv", x: l, target }; + l = { k: "conv", x: l, target: this.tryHistoricalDate(target) }; } else if (tk?.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) { this.i++; const r = this.parseAdd(); @@ -208,6 +208,24 @@ class Parser { return l; } + /** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */ + private tryHistoricalDate(target: ConvTarget): ConvTarget { + if (target.type !== "currency") return target; + const nx = this.peek(); + if (nx?.t !== "pctop" || nx.op !== "on") return target; + const save = this.i; + this.i++; + const dk = this.peek(); + if (dk?.t === "datelit") { + this.i++; + const d = new Date(dk.ms); + const onDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return { ...target, onDate }; + } + this.i = save; + return target; + } + private parseTarget(): ConvTarget | null { const tk = this.peek(); if (!tk) return null; diff --git a/src/engine/registry.ts b/src/engine/registry.ts index 7c646b2..5344538 100644 --- a/src/engine/registry.ts +++ b/src/engine/registry.ts @@ -134,10 +134,14 @@ export class Registry { makeCurrencyUnit(code: string): Unit | null { const rate = this.rates.get(code); if (!rate) return null; + return this.makeCurrencyUnitFromRate(code, rate); + } + + makeCurrencyUnitFromRate(code: string, ratePerUsd: Decimal): Unit { return { id: code, dimension: "currency", - ratio: new Decimal(1).div(rate), // USD per 1 unit + ratio: new Decimal(1).div(ratePerUsd), // USD per 1 unit format: this.currencyFormat.get(code) ?? `{} ${code}`, }; } diff --git a/src/main.ts b/src/main.ts index 8f2f027..151ed4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { SumEditor } from "./ui/editor"; import { AppData, DocMeta, SettingsData, defaultSettingsData, loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings, - fetchRates, fetchMarketData, writeImageFile, loadExtensionScripts, openExtensionsFolder, isTauri, + fetchRates, fetchMarketData, fetchHistoricalRates, writeImageFile, loadExtensionScripts, openExtensionsFolder, isTauri, getLaunchFile, onOpenFile, onFileDrop, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, @@ -143,11 +143,13 @@ function renderDocList(): void { function switchDoc(id: string): void { data.activeId = id; - editor.setText(data.contents[id] ?? ""); + const text = data.contents[id] ?? ""; + editor.setText(text); syncTitleField(); renderDocList(); saveAppData(data); editor.focus(); + void fetchNeededHistoricalRates(text); } function newDoc(content = ""): void { @@ -572,6 +574,23 @@ async function refreshMarket(force = false): Promise { renderMarketInfo(); } +// ---------- historical rates + +const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi; + +async function fetchNeededHistoricalRates(text: string): Promise { + const dates = new Set(); + for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!); + let fetched = false; + for (const date of dates) { + if (!engine.hasHistoricalRates(date)) { + const rates = await fetchHistoricalRates(date); + if (rates) { engine.setHistoricalRates(date, rates); fetched = true; } + } + } + if (fetched) editor.refresh(); +} + function renderMarketInfo(): void { const el = $("#market-info") as HTMLElement; if (marketFetchedAt === 0) { el.style.display = "none"; return; } @@ -770,6 +789,7 @@ async function boot(): Promise { } renderDocList(); saveAppData(data); + void fetchNeededHistoricalRates(text); }, onCopy(text) { void navigator.clipboard.writeText(text); diff --git a/src/storage.ts b/src/storage.ts index 6e6f37e..228d3d7 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -205,6 +205,27 @@ export async function fetchRates(force = false): Promise { return null; } +// ---------- historical rates + +export async function fetchHistoricalRates(date: string): Promise | null> { + try { + if (isTauri()) { + return await invoke>("fetch_historical_rates", { date }); + } + const res = await fetch(`https://api.frankfurter.app/${date}?from=USD`); + const json = await res.json(); + if (!json?.rates) return null; + const out: Record = { USD: 1 }; + for (const [k, v] of Object.entries(json.rates as Record)) { + if (v > 0) out[k.toUpperCase()] = v; + } + return out; + } catch (e) { + console.warn("fetchHistoricalRates failed", date, e); + return null; + } +} + // ---------- image export export async function writeImageFile(path: string, dataBase64: string): Promise {