mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 15:42:01 +03:00
historical rates: 1000 USD in EUR on 2024-01-01 via frankfurter.app
This commit is contained in:
parent
d5f458dce3
commit
fbeca06f2e
9 changed files with 159 additions and 11 deletions
|
|
@ -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<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
|
||||
|
||||
#[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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface EvalCtx {
|
|||
reg: Registry;
|
||||
vars: Map<string, Value>;
|
||||
line: LineCtx;
|
||||
/** date → { code → rate-per-usd } */
|
||||
historicalRates?: Map<string, Map<string, number>>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, Value>();
|
||||
private historicalRates = new Map<string, Map<string, number>>();
|
||||
|
||||
constructor(settings: Partial<EngineSettings> = {}) {
|
||||
this.settings = { ...defaultSettings, ...settings };
|
||||
|
|
@ -49,10 +49,19 @@ export class SumEngine {
|
|||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
24
src/main.ts
24
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<void> {
|
|||
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 {
|
||||
const el = $("#market-info") as HTMLElement;
|
||||
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
|
||||
|
|
@ -770,6 +789,7 @@ async function boot(): Promise<void> {
|
|||
}
|
||||
renderDocList();
|
||||
saveAppData(data);
|
||||
void fetchNeededHistoricalRates(text);
|
||||
},
|
||||
onCopy(text) {
|
||||
void navigator.clipboard.writeText(text);
|
||||
|
|
|
|||
|
|
@ -205,6 +205,27 @@ export async function fetchRates(force = false): Promise<RatesPayload | 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
|
||||
|
||||
export async function writeImageFile(path: string, dataBase64: string): Promise<void> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue