historical rates: 1000 USD in EUR on 2024-01-01 via frankfurter.app

This commit is contained in:
Horizontal42 2026-07-01 22:23:35 +03:00
parent d5f458dce3
commit fbeca06f2e
9 changed files with 159 additions and 11 deletions

View file

@ -315,6 +315,60 @@ async fn fetch_market_data(
Ok(prices) 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<std::collections::HashMap<String, f64>, 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::<std::collections::HashMap<String, f64>>(&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 // ---------- documents in the (possibly custom) data folder
#[tauri::command] #[tauri::command]
@ -636,6 +690,7 @@ fn main() {
data_dir_has_documents, data_dir_has_documents,
migrate_data_dir, migrate_data_dir,
fetch_rates, fetch_rates,
fetch_historical_rates,
fetch_market_data, fetch_market_data,
load_extensions, load_extensions,
open_extensions_folder, open_extensions_folder,

View file

@ -23,7 +23,7 @@
} }
], ],
"security": { "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": { "bundle": {

View file

@ -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", () => { describe("goal seek", () => {
it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33")); it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33"));
it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150")); it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150"));

View file

@ -19,6 +19,8 @@ export interface EvalCtx {
reg: Registry; reg: Registry;
vars: Map<string, Value>; vars: Map<string, Value>;
line: LineCtx; line: LineCtx;
/** date → { code → rate-per-usd } */
historicalRates?: Map<string, Map<string, number>>;
} }
const PI = new Decimal("3.14159265358979323846264338327950288419716939937510"); const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
@ -332,8 +334,16 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
return convertQ(v, target.unit); return convertQ(v, target.unit);
} }
case "currency": { case "currency": {
const unit = ctx.reg.makeCurrencyUnit(target.code); let unit;
if (!unit) throw new EvalError(`no rate for ${target.code}`); 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.kind !== "quantity") throw new EvalError("cannot convert to currency");
if (!v.unit) return qty(v.value, unit); if (!v.unit) return qty(v.value, unit);
return convertQ(v, unit); return convertQ(v, unit);

View file

@ -39,8 +39,8 @@ export interface ExtensionValue {
export class SumEngine { export class SumEngine {
reg: Registry; reg: Registry;
settings: EngineSettings; settings: EngineSettings;
/** variables defined by extensions, persist across evaluations */
private globals = new Map<string, Value>(); private globals = new Map<string, Value>();
private historicalRates = new Map<string, Map<string, number>>();
constructor(settings: Partial<EngineSettings> = {}) { constructor(settings: Partial<EngineSettings> = {}) {
this.settings = { ...defaultSettings, ...settings }; this.settings = { ...defaultSettings, ...settings };
@ -49,10 +49,19 @@ export class SumEngine {
} }
setRates(rates: Record<string, number | string>): void { setRates(rates: Record<string, number | string>): void {
// snapshot fills codes the live API doesn't return (e.g. crypto when CoinGecko fails)
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates }); this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates });
} }
setHistoricalRates(date: string, rates: Record<string, number>): void {
const m = new Map<string, number>();
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<EngineSettings>): void { updateSettings(patch: Partial<EngineSettings>): void {
this.settings = { ...this.settings, ...patch }; this.settings = { ...this.settings, ...patch };
} }
@ -98,6 +107,7 @@ export class SumEngine {
reg: this.reg, reg: this.reg,
vars, vars,
line: { lineValues, lineKinds, index: i, lineText: rawLine }, line: { lineValues, lineKinds, index: i, lineText: rawLine },
historicalRates: this.historicalRates,
}; };
try { try {
value = evaluate(parsed.expr, ctx); value = evaluate(parsed.expr, ctx);

View file

@ -7,7 +7,7 @@ import { PctOp, DateWord, BitOp } from "./registry";
export type ConvTarget = export type ConvTarget =
| { type: "unit"; unit: Unit } | { type: "unit"; unit: Unit }
| { type: "currency"; code: string } | { type: "currency"; code: string; onDate?: string }
| { type: "repr"; repr: NumeralRepr } | { type: "repr"; repr: NumeralRepr }
| { type: "tz"; words: string[] } | { type: "tz"; words: string[] }
| { type: "scale"; mult: Decimal; label: string } | { type: "scale"; mult: Decimal; label: string }
@ -115,7 +115,7 @@ class Parser {
this.i++; this.i++;
const target = this.parseTarget(); const target = this.parseTarget();
if (target) { if (target) {
items = [{ k: "conv", x: mk(), target }]; items = [{ k: "conv", x: mk(), target: this.tryHistoricalDate(target) }];
continue; continue;
} }
this.i = save + 1; // drop the conversion word, it was noise this.i = save + 1; // drop the conversion word, it was noise
@ -195,7 +195,7 @@ class Parser {
this.i = save; this.i = save;
break; 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"))) { } else if (tk?.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
this.i++; this.i++;
const r = this.parseAdd(); const r = this.parseAdd();
@ -208,6 +208,24 @@ class Parser {
return l; 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 { private parseTarget(): ConvTarget | null {
const tk = this.peek(); const tk = this.peek();
if (!tk) return null; if (!tk) return null;

View file

@ -134,10 +134,14 @@ export class Registry {
makeCurrencyUnit(code: string): Unit | null { makeCurrencyUnit(code: string): Unit | null {
const rate = this.rates.get(code); const rate = this.rates.get(code);
if (!rate) return null; if (!rate) return null;
return this.makeCurrencyUnitFromRate(code, rate);
}
makeCurrencyUnitFromRate(code: string, ratePerUsd: Decimal): Unit {
return { return {
id: code, id: code,
dimension: "currency", 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}`, format: this.currencyFormat.get(code) ?? `{} ${code}`,
}; };
} }

View file

@ -5,7 +5,7 @@ import { SumEditor } from "./ui/editor";
import { import {
AppData, DocMeta, SettingsData, defaultSettingsData, AppData, DocMeta, SettingsData, defaultSettingsData,
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings, loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
fetchRates, fetchMarketData, writeImageFile, loadExtensionScripts, openExtensionsFolder, isTauri, fetchRates, fetchMarketData, fetchHistoricalRates, writeImageFile, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop, getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
chooseFolder, dataDirHasDocuments, migrateDataDir, chooseFolder, dataDirHasDocuments, migrateDataDir,
@ -143,11 +143,13 @@ function renderDocList(): void {
function switchDoc(id: string): void { function switchDoc(id: string): void {
data.activeId = id; data.activeId = id;
editor.setText(data.contents[id] ?? ""); const text = data.contents[id] ?? "";
editor.setText(text);
syncTitleField(); syncTitleField();
renderDocList(); renderDocList();
saveAppData(data); saveAppData(data);
editor.focus(); editor.focus();
void fetchNeededHistoricalRates(text);
} }
function newDoc(content = ""): void { function newDoc(content = ""): void {
@ -572,6 +574,23 @@ async function refreshMarket(force = false): Promise<void> {
renderMarketInfo(); renderMarketInfo();
} }
// ---------- historical rates
const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi;
async function fetchNeededHistoricalRates(text: string): Promise<void> {
const dates = new Set<string>();
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 { 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; }
@ -770,6 +789,7 @@ async function boot(): Promise<void> {
} }
renderDocList(); renderDocList();
saveAppData(data); saveAppData(data);
void fetchNeededHistoricalRates(text);
}, },
onCopy(text) { onCopy(text) {
void navigator.clipboard.writeText(text); void navigator.clipboard.writeText(text);

View file

@ -205,6 +205,27 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
return null; return null;
} }
// ---------- historical rates
export async function fetchHistoricalRates(date: string): Promise<Record<string, number> | null> {
try {
if (isTauri()) {
return await invoke<Record<string, number>>("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<string, number> = { USD: 1 };
for (const [k, v] of Object.entries(json.rates as Record<string, number>)) {
if (v > 0) out[k.toUpperCase()] = v;
}
return out;
} catch (e) {
console.warn("fetchHistoricalRates failed", date, e);
return null;
}
}
// ---------- image export // ---------- image export
export async function writeImageFile(path: string, dataBase64: string): Promise<void> { export async function writeImageFile(path: string, dataBase64: string): Promise<void> {