diff --git a/index.html b/index.html
index 7d51072..bc9b7ff 100644
--- a/index.html
+++ b/index.html
@@ -30,6 +30,7 @@
+
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 3cb99a2..163b874 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -202,6 +202,87 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result,
+}
+
+#[tauri::command]
+async fn fetch_market_data(
+ app: AppHandle,
+ symbols: Vec,
+) -> Result, String> {
+ let cache_path = data_dir(&app).join("market.json");
+
+ // try cache first
+ if let Ok(raw) = fs::read_to_string(&cache_path) {
+ if let Ok(cache) = serde_json::from_str::(&raw) {
+ if now_secs().saturating_sub(cache.fetched_at) < MARKET_TTL_SECS {
+ let syms: std::collections::HashSet<&str> = symbols.iter().map(|s| s.as_str()).collect();
+ let hit: std::collections::HashMap = cache
+ .prices
+ .into_iter()
+ .filter(|(k, _)| syms.contains(k.as_str()))
+ .collect();
+ if !hit.is_empty() {
+ return Ok(hit);
+ }
+ }
+ }
+ }
+
+ if symbols.is_empty() {
+ return Ok(std::collections::HashMap::new());
+ }
+
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(15))
+ .user_agent("Mozilla/5.0")
+ .build()
+ .map_err(|e| e.to_string())?;
+
+ let joined = symbols.join(",");
+ let url = format!(
+ "https://query1.finance.yahoo.com/v7/finance/quote?symbols={}",
+ joined
+ );
+ 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 mut prices = std::collections::HashMap::new();
+ if let Some(results) = body["quoteResponse"]["result"].as_array() {
+ for item in results {
+ let sym = item["symbol"].as_str().unwrap_or("").to_string();
+ // regularMarketPrice is in the quote currency (USD for US stocks)
+ if let Some(price) = item["regularMarketPrice"].as_f64() {
+ if price > 0.0 && !sym.is_empty() {
+ prices.insert(sym, price);
+ }
+ }
+ }
+ }
+
+ if !prices.is_empty() {
+ let cache = MarketCache { fetched_at: now_secs(), prices: prices.clone() };
+ if let Ok(raw) = serde_json::to_string(&cache) {
+ write_atomic(&cache_path, &raw).ok();
+ }
+ }
+
+ Ok(prices)
+}
+
// ---------- documents in the (possibly custom) data folder
#[tauri::command]
@@ -523,6 +604,7 @@ fn main() {
data_dir_has_documents,
migrate_data_dir,
fetch_rates,
+ fetch_market_data,
load_extensions,
open_extensions_folder,
get_launch_file,
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 2299cbd..452f3c6 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"
+ "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"
}
},
"bundle": {
diff --git a/src/i18n.ts b/src/i18n.ts
index 2e1a5f3..85fb69e 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -62,6 +62,7 @@ const dict: Record> = {
unpin: "Unpin",
moveUp: "Move up",
moveDown: "Move down",
+ marketUpdated: "Market data updated",
},
ru: {
settings: "Настройки",
@@ -124,6 +125,7 @@ const dict: Record> = {
unpin: "Открепить",
moveUp: "Выше",
moveDown: "Ниже",
+ marketUpdated: "Рыночные данные обновлены",
},
};
diff --git a/src/main.ts b/src/main.ts
index 26f5988..a832280 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, loadExtensionScripts, openExtensionsFolder, isTauri,
+ fetchRates, fetchMarketData, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
chooseFolder, dataDirHasDocuments, migrateDataDir,
@@ -30,6 +30,7 @@ let data: AppData;
let lastResults: LineResult[] = [];
let selectedRange: [number, number] | null = null;
let ratesFetchedAt = 0;
+let liveRates: Record = {};
function uid(): string {
return Math.random().toString(36).slice(2, 10);
@@ -529,7 +530,10 @@ async function refreshRates(force = false): Promise {
const payload = await fetchRates(force);
info.classList.remove("spin");
if (payload) {
- engine.setRates(payload.rates);
+ liveRates = Object.fromEntries(
+ Object.entries(payload.rates).map(([k, v]) => [k, Number(v)])
+ );
+ applyAllRates();
editor.refresh();
ratesFetchedAt = payload.fetchedAt;
if (force) toast(t("ratesUpdated"));
@@ -537,6 +541,45 @@ async function refreshRates(force = false): Promise {
renderRatesInfo();
}
+// ---------- market data
+
+const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META", "SBER", "GAZP"];
+
+let marketPrices: Record = {};
+let marketFetchedAt = 0;
+
+function applyAllRates(): void {
+ const marketRates: Record = {};
+ for (const [sym, price] of Object.entries(marketPrices)) {
+ if (price > 0) marketRates[sym] = 1 / price;
+ }
+ engine.setRates({ ...liveRates, ...marketRates });
+}
+
+async function refreshMarket(force = false): Promise {
+ if (!isTauri()) return;
+ const info = $("#market-info") as HTMLElement;
+ info.style.display = "";
+ info.textContent = "Market…";
+ const prices = await fetchMarketData(MARKET_SYMBOLS);
+ if (Object.keys(prices).length > 0) {
+ marketPrices = prices;
+ marketFetchedAt = Math.floor(Date.now() / 1000);
+ applyAllRates();
+ editor.refresh();
+ if (force) toast(t("marketUpdated"));
+ }
+ renderMarketInfo();
+}
+
+function renderMarketInfo(): void {
+ const el = $("#market-info") as HTMLElement;
+ if (marketFetchedAt === 0) { el.style.display = "none"; return; }
+ el.style.display = "";
+ el.textContent = `Market: ${agoText(marketFetchedAt)} ↺`;
+ el.title = t("refreshRates");
+}
+
// ---------- status bar
/** total of the selection (≥2 lines) or of the whole sheet */
@@ -839,7 +882,9 @@ async function boot(): Promise {
});
$("#rates-info").addEventListener("click", () => void refreshRates(true));
+ $("#market-info").addEventListener("click", () => void refreshMarket(true));
setInterval(renderRatesInfo, 60_000);
+ setInterval(renderMarketInfo, 60_000);
void onFileDrop((content) => newDoc(content));
@@ -849,6 +894,8 @@ async function boot(): Promise {
void onAppQuit(() => flushAppData());
void refreshRates();
setInterval(() => void refreshRates(), 60 * 60 * 1000);
+ void refreshMarket();
+ setInterval(() => void refreshMarket(), 15 * 60 * 1000);
const launched = await getLaunchFile();
if (launched) newDoc(launched);
diff --git a/src/storage.ts b/src/storage.ts
index d4cf55d..0f6b882 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -205,6 +205,18 @@ export async function fetchRates(force = false): Promise {
return null;
}
+// ---------- market data
+
+export async function fetchMarketData(symbols: string[]): Promise> {
+ if (!isTauri() || symbols.length === 0) return {};
+ try {
+ return await invoke>("fetch_market_data", { symbols });
+ } catch (e) {
+ console.warn("fetchMarketData failed", e);
+ return {};
+ }
+}
+
// ---------- extensions
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {