diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index cf2b1f3..868a00d 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -3,20 +3,17 @@ import * as settingsDb from '../storage/db/settings.js'; import * as sourcesDb from '../storage/db/sources.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; -import * as stocksDb from '../storage/db/stocks.js'; -import * as bookmarksDb from '../storage/db/bookmarks.js'; -import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { totalStorageBytes } from '../storage/media/index.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import { pollSourceNow } from '../ingestion/poller.js'; import { logger, listLogs } from '../storage/db/logs.js'; import * as telegramClient from '../telegram/client.js'; -import { geocodeLocation } from '../weather/client.js'; -import { pollWeatherNow } from '../weather/poller.js'; -import { pollStocksNow } from '../stocks/poller.js'; -import { browseCurrencies, fetchCurrentLeague } from '../poe2/client.js'; -import { pollPoe2Now } from '../poe2/poller.js'; +import { loadedWidgets } from '../widgets/registry.js'; +import { installUploadedWidget } from '../widgets/install.js'; +import { uninstallWidget } from '../widgets/uninstall.js'; +import type { GlobalSettings } from '../storage/db/types.js'; // Not part of GlobalSettings itself (nothing to persist) — computed fresh on every // settings read/write so the Retention tab's "currently using" line and usage bar @@ -43,21 +40,24 @@ export async function registerAdminRoutes(app: FastifyInstance) { if (body.weather) { // Poll immediately rather than waiting for the next scheduler tick (up to 45 // minutes) — the admin just changed the location/unit and expects to see it reflected. - pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`)); + loadedWidgets + .get('weather') + ?.poll?.run() + .catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`)); } if (body.widgets) { // Re-enabling a widget (see the Widgets tab) should show fresh data right away - // instead of waiting out its normal cadence (up to 45m/15m/1h) — scheduler.ts - // skips polling entirely while a widget is disabled, so there's nothing recent - // to fall back on otherwise. - if (body.widgets.weather && !before.widgets.weather) { - pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`)); - } - if (body.widgets.stocks && !before.widgets.stocks) { - pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); - } - if (body.widgets.poe2 && !before.widgets.poe2) { - pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); + // instead of waiting out its normal cadence — scheduler.ts skips polling + // entirely while a widget is disabled, so there's nothing recent to fall back + // on otherwise. Generic over every loaded widget with a poll hook, rather than + // one hardcoded branch per widget. + for (const id of Object.keys(body.widgets) as (keyof GlobalSettings['widgets'])[]) { + if (body.widgets[id] && !before.widgets[id]) { + loadedWidgets + .get(id) + ?.poll?.run() + .catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`)); + } } } return { ...settings, categoryPriority: categoriesDb.listCategories() }; @@ -231,106 +231,27 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.code(200).send(telegramClient.getStatus()); }); - // --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) --- - app.get('/api/admin/weather/geocode', async (req, reply) => { - const { query } = req.query as { query?: string }; - if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' }); - try { - return await geocodeLocation(query.trim()); - } catch (err) { - return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` }); - } + // Per-widget routes (weather geocode, stocks CRUD, bookmarks CRUD, poe2 browse/ + // watchlist) are registered by each widget's own plugin — see widgets/registry.ts's + // generic registerAdminRoutes loop in index.ts. What's left here is the + // upload/delete lifecycle for pluggable widgets themselves. + + // --- Pluggable widgets (upload/list/delete — see widgets/install.ts, uninstall.ts) --- + app.get('/api/admin/widgets', async () => installedWidgetsDb.listInstalled()); + + app.post('/api/admin/widgets', async (req, reply) => { + const { manifest, files } = req.body as { manifest?: unknown; files?: unknown }; + const result = await installUploadedWidget(manifest, files); + if (!result.ok) return reply.code(400).send({ error: result.error }); + return reply.code(201).send({ id: result.id }); }); - // --- Stocks --- - app.get('/api/admin/stocks', async () => stocksDb.listStockTickers()); - - app.post('/api/admin/stocks', async (req, reply) => { - const { label, symbol } = req.body as { label?: string; symbol?: string }; - if (!label || !label.trim() || !symbol || !symbol.trim()) { - return reply.code(400).send({ error: 'label and symbol are required' }); - } - const created = stocksDb.createStockTicker(label.trim(), symbol.trim()); - // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, - // and refreshes every existing ticker's price too. - pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); - return reply.code(201).send(created); - }); - - app.patch('/api/admin/stocks/:id', async (req, reply) => { + app.delete('/api/admin/widgets/:id', async (req, reply) => { const { id } = req.params as { id: string }; - const updated = stocksDb.updateStockTicker(id, req.body as any); - if (!updated) return reply.code(404).send({ error: 'not found' }); - return updated; - }); - - app.delete('/api/admin/stocks/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - stocksDb.deleteStockTicker(id); - return reply.code(204).send(); - }); - - // --- Bookmarks --- - app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks()); - - app.post('/api/admin/bookmarks', async (req, reply) => { - const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean }; - if (!name || !name.trim() || !url || !url.trim()) { - return reply.code(400).send({ error: 'name and url are required' }); - } - const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate); - return reply.code(201).send(created); - }); - - app.patch('/api/admin/bookmarks/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - const updated = bookmarksDb.updateBookmark(id, req.body as any); - if (!updated) return reply.code(404).send({ error: 'not found' }); - return updated; - }); - - app.delete('/api/admin/bookmarks/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - bookmarksDb.deleteBookmark(id); - return reply.code(204).send(); - }); - - // --- PoE2 (league is always auto-detected, never admin-set — see poe2/poller.ts) --- - app.get('/api/admin/poe2/browse', async (_req, reply) => { - try { - const league = await fetchCurrentLeague(); - return await browseCurrencies(league.id); - } catch (err) { - return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` }); - } - }); - - app.get('/api/admin/poe2/watchlist', async () => poe2WatchlistDb.listWatchlist()); - - app.post('/api/admin/poe2/watchlist', async (req, reply) => { - const { base, quote } = req.body as { - base?: { currencyId?: string; name?: string }; - quote?: { currencyId?: string; name?: string }; - }; - if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) { - return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' }); - } - if (base.currencyId === quote.currencyId) { - return reply.code(400).send({ error: 'Base and quote currencies must be different' }); - } - const created = poe2WatchlistDb.addWatchlistEntry( - { currencyId: base.currencyId, name: base.name }, - { currencyId: quote.currencyId, name: quote.name } - ); - // Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap, - // and refreshes every existing entry's rate too. - pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); - return reply.code(201).send(created); - }); - - app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - poe2WatchlistDb.removeWatchlistEntry(id); + const widget = installedWidgetsDb.getInstalled(id); + if (!widget) return reply.code(404).send({ error: 'not found' }); + if (widget.source === 'builtin') return reply.code(400).send({ error: 'built-in widgets cannot be deleted' }); + await uninstallWidget(id); return reply.code(204).send(); }); diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index e2a0898..c31c6d5 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -4,9 +4,6 @@ import * as tagsDb from '../storage/db/tags.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; import * as settingsDb from '../storage/db/settings.js'; -import * as stocksDb from '../storage/db/stocks.js'; -import * as bookmarksDb from '../storage/db/bookmarks.js'; -import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; import { hasPrivateAccess } from './privateAccess.js'; export async function registerPublicRoutes(app: FastifyInstance) { @@ -70,19 +67,7 @@ export async function registerPublicRoutes(app: FastifyInstance) { return { ...widgets, order: widgetOrder }; }); - // Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel. - app.get('/api/weather', async () => settingsDb.getSettings().weather); - - app.get('/api/stocks', async () => stocksDb.listStockTickers()); - - app.get('/api/bookmarks', async (req) => { - const bookmarks = bookmarksDb.listBookmarks(); - if (hasPrivateAccess(req)) return bookmarks; - return bookmarks.filter((b) => !b.isPrivate); - }); - - app.get('/api/poe2', async () => { - const { leagueName, updatedAt } = settingsDb.getSettings().poe2; - return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() }; - }); + // Per-widget public routes (GET /api/weather, /api/stocks, /api/bookmarks, /api/poe2) + // are registered by each widget's own plugin — see widgets/registry.ts's generic + // registerPublicRoutes loop in index.ts. } diff --git a/backend/src/bookmarks/plugin.ts b/backend/src/bookmarks/plugin.ts new file mode 100644 index 0000000..bcd546b --- /dev/null +++ b/backend/src/bookmarks/plugin.ts @@ -0,0 +1,47 @@ +import type { WidgetPlugin } from '../widgets/types.js'; +import * as bookmarksDb from '../storage/db/bookmarks.js'; +import { hasPrivateAccess } from '../api/privateAccess.js'; + +// Built-in sidebar "Bookmarks" widget — thin wrapper so it funnels into the same +// route-registration loop as pluggable widgets (see widgets/registry.ts); no poll (purely +// admin-curated links) and no migrate()/uninstall() since its schema isn't moving. Not +// going through the upload/delete lifecycle — see widgets/types.ts. +export const bookmarksPlugin: WidgetPlugin = { + id: 'bookmarks', + displayName: 'Bookmarks', + version: '1.0.0', + + registerPublicRoutes(app) { + app.get('/api/bookmarks', async (req) => { + const bookmarks = bookmarksDb.listBookmarks(); + if (hasPrivateAccess(req)) return bookmarks; + return bookmarks.filter((b) => !b.isPrivate); + }); + }, + + registerAdminRoutes(app) { + app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks()); + + app.post('/api/admin/bookmarks', async (req, reply) => { + const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean }; + if (!name || !name.trim() || !url || !url.trim()) { + return reply.code(400).send({ error: 'name and url are required' }); + } + const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/bookmarks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = bookmarksDb.updateBookmark(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/bookmarks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + bookmarksDb.deleteBookmark(id); + return reply.code(204).send(); + }); + } +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 6fbe6d2..e3bfb93 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,7 @@ import { registerPrivateAccess, privateAccessConfigured } from './api/privateAcc import { startScheduler } from './queue/scheduler.js'; import { initFromSavedSession } from './telegram/client.js'; import { logger } from './storage/db/logs.js'; +import { loadAllWidgets, loadedWidgets } from './widgets/registry.js'; const PORT = Number(process.env.PORT) || 4000; const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; @@ -35,6 +36,7 @@ async function main() { migrate(); printApiKeyBanner(); await initFromSavedSession(); + await loadAllWidgets(); const app = Fastify({ logger: false }); @@ -74,6 +76,15 @@ async function main() { await registerAdminRoutes(app); await registerPrivateAccess(app); + // Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its + // own routes here rather than being hardcoded into public.ts/admin.ts. Runs after + // registerAuth so any /api/admin/* route a widget registers is gated by the same + // X-Api-Key preHandler automatically. + for (const plugin of loadedWidgets.values()) { + plugin.registerPublicRoutes?.(app); + plugin.registerAdminRoutes?.(app); + } + // Fastify's own logger is off (see below) — without this, an unhandled exception // in any route handler produces a bare 500 with zero trace anywhere, including the // admin panel's own Logs tab. This is what "Save failed" with no log entry was. diff --git a/backend/src/poe2/poller.ts b/backend/src/poe2/poller.ts deleted file mode 100644 index e6cd503..0000000 --- a/backend/src/poe2/poller.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; -import * as settingsDb from '../storage/db/settings.js'; -import { logger } from '../storage/db/logs.js'; -import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; - -const DAY_MS = 24 * 60 * 60_000; - -function pctChange(current: number, past: number | null): number | null { - if (past === null || past === 0) return null; - return ((current - past) / past) * 100; -} - -// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a pair -// (see api/admin.ts) — always re-detects the current challenge league fresh (cheap, guarantees -// correctness across league rotations with no separate staleness logic), then one overview -// request covers the whole watchlist. A pair whose base or quote currency is no longer traded -// this league gets its own lastError, it never aborts the rest of the batch. -export async function pollPoe2Now(): Promise { - let league; - try { - league = await fetchCurrentLeague(); - } catch (err) { - logger.error('poe2', `League lookup failed: ${(err as Error).message}`); - return; - } - - const entries = poe2WatchlistDb.listWatchlist(); - if (entries.length === 0) { - const { poe2 } = settingsDb.getSettings(); - settingsDb.updateSettings({ - poe2: { ...poe2, leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() } - }); - return; - } - - try { - const valuesById = await fetchCurrencyValues(league.id); - const now = new Date(); - const nowIso = now.toISOString(); - const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); - - for (const entry of entries) { - const baseValue = valuesById.get(entry.baseCurrencyId); - const quoteValue = valuesById.get(entry.quoteCurrencyId); - if (baseValue === undefined || quoteValue === undefined) { - poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league'); - continue; - } - - const rate = baseValue / quoteValue; - const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h)); - poe2WatchlistDb.recordRate(entry.id, rate, nowIso); - poe2WatchlistDb.markPolled(entry.id, rate, change24h, null); - } - - poe2WatchlistDb.pruneOldHistory(); - settingsDb.updateSettings({ - poe2: { leagueId: league.id, leagueName: league.name, updatedAt: nowIso } - }); - } catch (err) { - logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`); - } -} diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts index 76dd754..c20a905 100644 --- a/backend/src/queue/scheduler.ts +++ b/backend/src/queue/scheduler.ts @@ -4,17 +4,48 @@ import { runEventRecaps } from './eventsRecap.js'; import { runRetentionSweep } from './retention.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import * as settingsDb from '../storage/db/settings.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; import { logger } from '../storage/db/logs.js'; -import { pollWeatherNow } from '../weather/poller.js'; -import { pollStocksNow } from '../stocks/poller.js'; -import { pollPoe2Now } from '../poe2/poller.js'; +import { loadedWidgets } from '../widgets/registry.js'; +import type { WidgetPlugin } from '../widgets/types.js'; const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency const SYNTHESIS_TICK_MS = 60_000; const RETENTION_TICK_MS = 60 * 60_000; // hourly -const WEATHER_TICK_MS = 45 * 60_000; -const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather -const POE2_TICK_MS = 60 * 60_000; // poe.ninja's own overview data doesn't refresh faster than hourly, so polling more often than this just re-fetches the same numbers + +// Per-widget setInterval handles, keyed by widget id — lets a single widget's polling be +// started/stopped independently (on live upload/delete, or an enable toggle) without +// touching any other widget's interval. Exported so widgets/install.ts and +// widgets/uninstall.ts can drive it directly. +export const widgetIntervals = new Map(); + +// Starts (or re-starts) polling for one widget — an immediate poll if it's currently +// enabled (unlike RSS sources, whose "due" check makes a brand-new source eligible on the +// very next 1-minute tick, a widget has no such shortcut; without this the sidebar would +// sit empty for up to a full poll interval after every restart or fresh install), then a +// recurring interval that re-checks the enabled flag on every tick — so disabling a widget +// stops the actual external polling, not just hides it in the sidebar. +export function startWidgetPolling(plugin: WidgetPlugin) { + if (!plugin.poll) return; + stopWidgetPolling(plugin.id); + + if (installedWidgetsDb.getInstalled(plugin.id)?.enabled) { + plugin.poll.run().catch((err) => logger.error(plugin.id, `Initial poll failed: ${(err as Error).message}`)); + } + const handle = setInterval(() => { + if (!installedWidgetsDb.getInstalled(plugin.id)?.enabled) return; + plugin.poll!.run().catch((err) => logger.error(plugin.id, `Poll tick failed: ${(err as Error).message}`)); + }, plugin.poll.intervalMs); + widgetIntervals.set(plugin.id, handle); +} + +export function stopWidgetPolling(id: string) { + const handle = widgetIntervals.get(id); + if (handle) { + clearInterval(handle); + widgetIntervals.delete(id); + } +} export function startScheduler() { const provider = () => { @@ -66,37 +97,12 @@ export function startScheduler() { } }, RETENTION_TICK_MS); - // Immediate first call for all three — unlike RSS sources (whose "due" check makes a - // brand-new source eligible on the very next 1-minute tick), weather/stocks/poe2 have - // no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after - // every restart. Each is also gated on its Widgets-tab enabled flag (see - // admin/settings' consolidated Widgets tab) — disabling a widget stops these external - // calls entirely rather than just hiding the sidebar box, so there's no pointless - // polling for something nobody's looking at. Re-enabling it triggers an immediate - // poll instead (see admin.ts's PATCH /api/admin/settings), same as this initial call. - if (settingsDb.getSettings().widgets.weather) { - pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`)); + for (const plugin of loadedWidgets.values()) { + startWidgetPolling(plugin); } - setInterval(() => { - if (!settingsDb.getSettings().widgets.weather) return; - pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`)); - }, WEATHER_TICK_MS); - if (settingsDb.getSettings().widgets.stocks) { - pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`)); - } - setInterval(() => { - if (!settingsDb.getSettings().widgets.stocks) return; - pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`)); - }, STOCKS_TICK_MS); - - if (settingsDb.getSettings().widgets.poe2) { - pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`)); - } - setInterval(() => { - if (!settingsDb.getSettings().widgets.poe2) return; - pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`)); - }, POE2_TICK_MS); - - logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m, poe2 every 1h'); + logger.info( + 'scheduler', + `Started: poll every 1m, synthesis every 1m, retention every 1h, ${loadedWidgets.size} widget(s) polling on their own intervals` + ); } diff --git a/backend/src/stocks/plugin.ts b/backend/src/stocks/plugin.ts new file mode 100644 index 0000000..bfab96b --- /dev/null +++ b/backend/src/stocks/plugin.ts @@ -0,0 +1,54 @@ +import type { WidgetPlugin } from '../widgets/types.js'; +import * as stocksDb from '../storage/db/stocks.js'; +import { logger } from '../storage/db/logs.js'; +import { pollStocksNow } from './poller.js'; + +// Built-in sidebar "Stocks" widget — thin wrapper so it funnels into the same +// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts); its +// tickers stay in the dedicated stock_tickers table exactly as before. Not going through +// the upload/delete lifecycle — see widgets/types.ts; no migrate()/uninstall() since its +// schema isn't moving. +export const stocksPlugin: WidgetPlugin = { + id: 'stocks', + displayName: 'Stocks', + version: '1.0.0', + + poll: { + // Per admin spec — stock prices move faster than weather. + intervalMs: 15 * 60_000, + run: pollStocksNow + }, + + registerPublicRoutes(app) { + app.get('/api/stocks', async () => stocksDb.listStockTickers()); + }, + + registerAdminRoutes(app) { + app.get('/api/admin/stocks', async () => stocksDb.listStockTickers()); + + app.post('/api/admin/stocks', async (req, reply) => { + const { label, symbol } = req.body as { label?: string; symbol?: string }; + if (!label || !label.trim() || !symbol || !symbol.trim()) { + return reply.code(400).send({ error: 'label and symbol are required' }); + } + const created = stocksDb.createStockTicker(label.trim(), symbol.trim()); + // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, + // and refreshes every existing ticker's price too. + pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/stocks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = stocksDb.updateStockTicker(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/stocks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + stocksDb.deleteStockTicker(id); + return reply.code(204).send(); + }); + } +}; diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 2de9caa..692ba01 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -33,6 +33,18 @@ export function migrate() { db.exec('DROP TABLE IF EXISTS poe2_watchlist;'); } + // PoE2 moved into the pluggable-widget system (backend/src/widgets/poe2/) and its + // schema now follows the widget__ naming convention its plugin.migrate() owns — + // a plain rename (metadata-only in SQLite, no row copy) rather than grandfathering + // the old names in via an exceptions list, since it's cheap and PoE2 is meant to be + // the reference implementation of the convention it establishes. + const hasOldPoe2Table = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='poe2_watchlist'`).get(); + if (hasOldPoe2Table) { + db.exec('ALTER TABLE poe2_watchlist RENAME TO widget_poe2_watchlist;'); + db.exec('ALTER TABLE poe2_rate_history RENAME TO widget_poe2_rate_history;'); + db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;'); + } + db.exec(` CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, @@ -223,35 +235,32 @@ export function migrate() { created_at TEXT NOT NULL ); - -- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs - -- (see poe2/poller.ts), always against the current challenge league (auto-detected, - -- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are - -- captured at add-time from the browse picker, not re-resolved. No icon columns — - -- the UI doesn't display them. - CREATE TABLE IF NOT EXISTS poe2_watchlist ( - id TEXT PRIMARY KEY, - base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted" - base_name TEXT NOT NULL, - quote_currency_id TEXT NOT NULL, - quote_name TEXT NOT NULL, - priority_rank INTEGER NOT NULL, - last_rate REAL, - last_change_24h REAL, - last_polled_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL + -- Generic config/cache store for pluggable widgets (see widgets/types.ts, + -- storage/db/widgetKv.ts) — lets a widget stay fully prunable by widget_id alone on + -- uninstall without a bespoke table for simple key/value state. + CREATE TABLE IF NOT EXISTS widget_kv ( + widget_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, -- JSON + updated_at TEXT NOT NULL, + PRIMARY KEY (widget_id, key) ); - -- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching - -- 24h change window, so it's computed ourselves from this history (see - -- poe2/poller.ts). Pruned to the last 2 days on every poll. - CREATE TABLE IF NOT EXISTS poe2_rate_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - watchlist_id TEXT NOT NULL, - rate REAL NOT NULL, - recorded_at TEXT NOT NULL + -- Registry of installed sidebar widgets, both built-in (weather/stocks/bookmarks, + -- and poe2 as the pluggable reference implementation) and uploaded (see + -- widgets/registry.ts, widgets/install.ts). Replaces the old closed + -- widgets/widgetOrder unions on global_settings — see storage/db/settings.ts. + CREATE TABLE IF NOT EXISTS installed_widgets ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + source TEXT NOT NULL, -- 'builtin' | 'uploaded' + code_path TEXT NOT NULL, -- builtin: identifying module path segment; uploaded: on-disk install dir under ./data/ + enabled INTEGER NOT NULL DEFAULT 1, + priority_rank INTEGER NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + owned_tables TEXT NOT NULL DEFAULT '[]', -- JSON array, self-reported by the plugin — used by the uninstall safety-net sweep + installed_at TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_poe2_rate_history_watchlist ON poe2_rate_history(watchlist_id, recorded_at); -- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public -- via is_private (same private-access lock feature as categories.is_private). @@ -417,4 +426,58 @@ export function migrate() { ); } } + + // One-time seed of the installed_widgets registry from whatever the pre-registry + // install had (upgrade path) or sensible defaults (fresh install) — see + // widgets/registry.ts, storage/db/installedWidgets.ts. Raw SQL rather than importing + // installedWidgets.ts here to avoid a circular import (it imports `db` from this + // file). + const widgetCount = db.prepare('SELECT COUNT(*) as c FROM installed_widgets').get() as { c: number }; + if (widgetCount.c === 0) { + const settingsRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any; + const order: string[] = settingsRow + ? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]') + : ['weather', 'stocks', 'poe2', 'bookmarks']; + const displayNames: Record = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' }; + const codePaths: Record = { weather: 'weather', stocks: 'stocks', bookmarks: 'bookmarks', poe2: 'widgets/poe2' }; + const ownedTables: Record = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] }; + const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true); + const insertWidget = db.prepare( + `INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, owned_tables, installed_at) + VALUES (?, ?, 'builtin', ?, ?, ?, ?, ?)` + ); + const installedAt = new Date().toISOString(); + order.forEach((id, i) => { + insertWidget.run( + id, + displayNames[id] ?? id, + codePaths[id] ?? id, + enabledOf(id) ? 1 : 0, + i + 1, + JSON.stringify(ownedTables[id] ?? []), + installedAt + ); + }); + } + + // PoE2's league cache (previously bare global_settings columns) moves into the + // generic widget_kv store so it's prunable via the same deleteAllKv('poe2') path as + // everything else the widget owns, rather than needing bespoke column-nulling logic + // on uninstall. One-time copy, guarded on the widget_kv row not already existing. + const poe2CacheRow = db.prepare('SELECT poe2_league_id, poe2_league_name, poe2_updated_at FROM global_settings WHERE id = 1').get() as + | { poe2_league_id: string | null; poe2_league_name: string | null; poe2_updated_at: string | null } + | undefined; + const hasPoe2Kv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'poe2' AND key = 'leagueCache'").get(); + if (poe2CacheRow?.poe2_league_id && !hasPoe2Kv) { + db.prepare( + `INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('poe2', 'leagueCache', ?, ?)` + ).run( + JSON.stringify({ + leagueId: poe2CacheRow.poe2_league_id, + leagueName: poe2CacheRow.poe2_league_name, + updatedAt: poe2CacheRow.poe2_updated_at + }), + new Date().toISOString() + ); + } } diff --git a/backend/src/storage/db/installedWidgets.ts b/backend/src/storage/db/installedWidgets.ts new file mode 100644 index 0000000..64a49c2 --- /dev/null +++ b/backend/src/storage/db/installedWidgets.ts @@ -0,0 +1,70 @@ +import { db } from './index.js'; +import type { InstalledWidget } from './types.js'; + +function rowToWidget(row: any): InstalledWidget { + return { + id: row.id, + displayName: row.display_name, + source: row.source, + codePath: row.code_path, + enabled: !!row.enabled, + priorityRank: row.priority_rank, + version: row.version, + ownedTables: JSON.parse(row.owned_tables), + installedAt: row.installed_at + }; +} + +export function listInstalled(): InstalledWidget[] { + return (db.prepare('SELECT * FROM installed_widgets ORDER BY priority_rank').all() as any[]).map(rowToWidget); +} + +export function getInstalled(id: string): InstalledWidget | null { + const row = db.prepare('SELECT * FROM installed_widgets WHERE id = ?').get(id); + return row ? rowToWidget(row) : null; +} + +export function insertWidget(widget: { + id: string; + displayName: string; + source: 'builtin' | 'uploaded'; + codePath: string; + enabled?: boolean; + priorityRank?: number; + version?: string; + ownedTables?: string[]; +}): InstalledWidget { + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM installed_widgets').get() as { m: number }; + const priorityRank = widget.priorityRank ?? maxRank.m + 1; + const installedAt = new Date().toISOString(); + db.prepare( + `INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, installed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + widget.id, + widget.displayName, + widget.source, + widget.codePath, + widget.enabled === false ? 0 : 1, + priorityRank, + widget.version ?? '1.0.0', + JSON.stringify(widget.ownedTables ?? []), + installedAt + ); + return getInstalled(widget.id)!; +} + +export function setEnabled(id: string, enabled: boolean) { + db.prepare('UPDATE installed_widgets SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id); +} + +// Ids in display order — every id must already exist as a row; unlisted ids keep their +// current rank (mirrors the old widget_order JSON array's "admin-sortable" behavior). +export function reorder(ids: string[]) { + const stmt = db.prepare('UPDATE installed_widgets SET priority_rank = ? WHERE id = ?'); + ids.forEach((id, i) => stmt.run(i + 1, id)); +} + +export function deleteInstalled(id: string) { + db.prepare('DELETE FROM installed_widgets WHERE id = ?').run(id); +} diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index 604422e..07d2cbd 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -1,7 +1,39 @@ import { db } from './index.js'; import type { GlobalSettings } from './types.js'; +import * as installedWidgetsDb from './installedWidgets.js'; +import { getKv, setKv } from './widgetKv.js'; + +const BUILTIN_WIDGET_IDS = ['weather', 'stocks', 'bookmarks', 'poe2'] as const; + +interface Poe2LeagueCache { + leagueId: string | null; + leagueName: string | null; + updatedAt: string | null; +} + +// widgets/widgetOrder are computed from the installed_widgets registry (see +// storage/db/installedWidgets.ts, widgets/registry.ts) rather than stored as their own +// global_settings columns — the registry is the single source of truth for enable state +// and ordering for every widget, built-in or uploaded. Only the four built-in ids are +// reflected here since GlobalSettings.widgets/widgetOrder are closed unions the frontend +// depends on (uploaded widgets have no frontend representation yet). +function widgetsAndOrder(): Pick { + const installed = installedWidgetsDb.listInstalled(); + const byId = new Map(installed.map((w) => [w.id, w])); + const widgets = { + weather: !!byId.get('weather')?.enabled, + stocks: !!byId.get('stocks')?.enabled, + bookmarks: !!byId.get('bookmarks')?.enabled, + poe2: !!byId.get('poe2')?.enabled + }; + const widgetOrder = installed.map((w) => w.id).filter((id): id is (typeof BUILTIN_WIDGET_IDS)[number] => + (BUILTIN_WIDGET_IDS as readonly string[]).includes(id) + ); + return { widgets, widgetOrder }; +} function rowToSettings(row: any): GlobalSettings { + const poe2Cache = getKv('poe2', 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null }; return { mergeStrictness: row.merge_strictness, holdBeforePublishMinutes: row.hold_before_publish_minutes, @@ -16,13 +48,7 @@ function rowToSettings(row: any): GlobalSettings { fxtwitterBaseUrl: row.fxtwitter_base_url, nitterInstanceUrl: row.nitter_instance_url, telegramMediaMode: row.telegram_media_mode, - widgets: { - weather: !!row.widget_weather_enabled, - stocks: !!row.widget_stocks_enabled, - bookmarks: !!row.widget_bookmarks_enabled, - poe2: !!row.widget_poe2_enabled - }, - widgetOrder: JSON.parse(row.widget_order), + ...widgetsAndOrder(), retention: { publishedArticleMaxAgeDays: row.published_article_max_age_days, rawItemMaxAgeDays: row.raw_item_max_age_days, @@ -44,11 +70,7 @@ function rowToSettings(row: any): GlobalSettings { alerts: JSON.parse(row.weather_alerts), updatedAt: row.weather_updated_at }, - poe2: { - leagueId: row.poe2_league_id, - leagueName: row.poe2_league_name, - updatedAt: row.poe2_updated_at - } + poe2: poe2Cache }; } @@ -68,6 +90,19 @@ export function updateSettings(patch: Partial): GlobalSettings { poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }, widgets: { ...current.widgets, ...(patch.widgets ?? {}) } }; + + if (patch.widgets) { + for (const id of BUILTIN_WIDGET_IDS) { + if (patch.widgets[id] !== undefined) installedWidgetsDb.setEnabled(id, patch.widgets[id]); + } + } + if (patch.widgetOrder) { + installedWidgetsDb.reorder(patch.widgetOrder); + } + if (patch.poe2) { + setKv('poe2', 'leagueCache', merged.poe2); + } + // Named params (rather than positional `?`) so this list can be reordered or // extended without the column list and the bound-values list silently drifting // out of sync — node:sqlite binds each by its `$name` key, not position. @@ -80,16 +115,12 @@ export function updateSettings(patch: Partial): GlobalSettings { ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models, nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url, telegram_media_mode=$telegram_media_mode, - widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled, - widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled, - widget_order=$widget_order, published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days, storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit, weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude, weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit, weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily, - weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at, - poe2_league_id=$poe2_league_id, poe2_league_name=$poe2_league_name, poe2_updated_at=$poe2_updated_at + weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at WHERE id = 1` ).run({ $merge_strictness: merged.mergeStrictness, @@ -105,11 +136,6 @@ export function updateSettings(patch: Partial): GlobalSettings { $fxtwitter_base_url: merged.fxtwitterBaseUrl, $nitter_instance_url: merged.nitterInstanceUrl, $telegram_media_mode: merged.telegramMediaMode, - $widget_weather_enabled: merged.widgets.weather ? 1 : 0, - $widget_stocks_enabled: merged.widgets.stocks ? 1 : 0, - $widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0, - $widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0, - $widget_order: JSON.stringify(merged.widgetOrder), $published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays, $raw_item_max_age_days: merged.retention.rawItemMaxAgeDays, $storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0, @@ -125,10 +151,7 @@ export function updateSettings(patch: Partial): GlobalSettings { $weather_hourly: JSON.stringify(merged.weather.hourly), $weather_daily: JSON.stringify(merged.weather.daily), $weather_alerts: JSON.stringify(merged.weather.alerts), - $weather_updated_at: merged.weather.updatedAt, - $poe2_league_id: merged.poe2.leagueId, - $poe2_league_name: merged.poe2.leagueName, - $poe2_updated_at: merged.poe2.updatedAt + $weather_updated_at: merged.weather.updatedAt }); return getSettings(); } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 82836f4..b2b8751 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -256,6 +256,21 @@ export interface Poe2WatchlistEntry { createdAt: string; } +/** A row in `installed_widgets` — the registry of both built-in and uploaded sidebar widgets (see widgets/registry.ts). */ +export interface InstalledWidget { + id: string; + displayName: string; + source: 'builtin' | 'uploaded'; + /** Builtin: the module's identifying path segment (e.g. 'weather', 'widgets/poe2'). Uploaded: the on-disk install directory, e.g. './data/widgets-installed/'. */ + codePath: string; + enabled: boolean; + priorityRank: number; + version: string; + /** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */ + ownedTables: string[]; + installedAt: string; +} + export interface Bookmark { id: string; name: string; diff --git a/backend/src/storage/db/widgetKv.ts b/backend/src/storage/db/widgetKv.ts new file mode 100644 index 0000000..b2a3bf2 --- /dev/null +++ b/backend/src/storage/db/widgetKv.ts @@ -0,0 +1,24 @@ +import { db } from './index.js'; + +// Generic config/cache store for pluggable widgets (see widgets/types.ts) — a widget with +// simple needs (weather-shaped: "last fetched value + timestamp") uses this instead of +// getting bolted-on global_settings columns, so it stays fully prunable by widget_id alone +// on uninstall (see widgets/uninstall.ts) without any host-side schema change. + +export function getKv(widgetId: string, key: string): T | null { + const row = db.prepare('SELECT value FROM widget_kv WHERE widget_id = ? AND key = ?').get(widgetId, key) as + | { value: string } + | undefined; + return row ? (JSON.parse(row.value) as T) : null; +} + +export function setKv(widgetId: string, key: string, value: unknown) { + db.prepare( + `INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(widget_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ).run(widgetId, key, JSON.stringify(value), new Date().toISOString()); +} + +export function deleteAllKv(widgetId: string) { + db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId); +} diff --git a/backend/src/weather/plugin.ts b/backend/src/weather/plugin.ts new file mode 100644 index 0000000..ee8ed77 --- /dev/null +++ b/backend/src/weather/plugin.ts @@ -0,0 +1,36 @@ +import type { WidgetPlugin } from '../widgets/types.js'; +import * as settingsDb from '../storage/db/settings.js'; +import { geocodeLocation } from './client.js'; +import { pollWeatherNow } from './poller.js'; + +// Built-in sidebar "Weather" widget — thin wrapper so it funnels into the same +// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts), while +// its config/cache stay on global_settings' weather_* columns exactly as before. Not going +// through the upload/delete lifecycle — see widgets/types.ts and the plan's "built-in vs +// pluggable" split; no migrate()/uninstall() since its schema isn't moving. +export const weatherPlugin: WidgetPlugin = { + id: 'weather', + displayName: 'Weather', + version: '1.0.0', + + poll: { + intervalMs: 45 * 60_000, + run: pollWeatherNow + }, + + registerPublicRoutes(app) { + app.get('/api/weather', async () => settingsDb.getSettings().weather); + }, + + registerAdminRoutes(app) { + app.get('/api/admin/weather/geocode', async (req, reply) => { + const { query } = req.query as { query?: string }; + if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' }); + try { + return await geocodeLocation(query.trim()); + } catch (err) { + return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` }); + } + }); + } +}; diff --git a/backend/src/widgets/install.ts b/backend/src/widgets/install.ts new file mode 100644 index 0000000..b8a34be --- /dev/null +++ b/backend/src/widgets/install.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { loadUploadedWidget } from './registry.js'; +import { startWidgetPolling } from '../queue/scheduler.js'; +import { validateManifest, type WidgetManifest } from './manifest.js'; + +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; + +export type InstallResult = { ok: true; id: string } | { ok: false; error: string }; + +// Installs and hot-loads a widget uploaded live to the running backend (see +// api/admin.ts's POST /api/admin/widgets) — writes its files under ./data/, never +// dist/ or src/, so it survives a rebuild/redeploy of the core app. Its migrate() +// runs and its poll interval (if declared) starts immediately, with no restart +// required. NOTE: its registerPublicRoutes/registerAdminRoutes, if declared, do NOT +// take effect until the next restart — Fastify throws "instance is already +// listening" if you try to add a route after app.listen() has resolved, and there's +// no supported way around that short of a much larger request-dispatch redesign. A +// live-installed widget's data/poll side works immediately; its custom HTTP routes +// don't until the process restarts (see widgets/registry.ts's startup discovery, +// which re-registers everything, routes included, on every boot). +export async function installUploadedWidget(manifest: unknown, files: unknown): Promise { + const validationError = validateManifest(manifest, files); + if (validationError) return { ok: false, error: validationError }; + const m = manifest as WidgetManifest; + const f = files as Record; + + if (installedWidgetsDb.getInstalled(m.id)) { + return { ok: false, error: `widget "${m.id}" is already installed` }; + } + + const dir = path.join(WIDGETS_INSTALLED_DIR, m.id); + fs.mkdirSync(dir, { recursive: true }); + for (const [relPath, content] of Object.entries(f)) { + const filePath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + } + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2), 'utf8'); + + const plugin = await loadUploadedWidget(m.id, dir); + if (!plugin) { + fs.rmSync(dir, { recursive: true, force: true }); + return { ok: false, error: "widget failed to load — check its entry file's default export satisfies WidgetPlugin" }; + } + + installedWidgetsDb.insertWidget({ + id: m.id, + displayName: m.displayName, + source: 'uploaded', + codePath: dir, + version: m.version, + ownedTables: plugin.ownedTables ?? [] + }); + + startWidgetPolling(plugin); + return { ok: true, id: m.id }; +} diff --git a/backend/src/widgets/manifest.ts b/backend/src/widgets/manifest.ts new file mode 100644 index 0000000..540e49a --- /dev/null +++ b/backend/src/widgets/manifest.ts @@ -0,0 +1,39 @@ +const ID_PATTERN = /^[a-z0-9-]{1,40}$/; +const RESERVED_IDS = new Set(['weather', 'stocks', 'bookmarks', 'poe2']); +const MAX_TOTAL_BYTES = 5 * 1024 * 1024; + +export interface WidgetManifest { + id: string; + displayName: string; + version: string; + /** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */ + entry: string; +} + +// Upload body shape: { manifest, files: { "index.mjs": "", ... } } — plain +// JSON rather than a literal zip, so no archive/multipart dependency is needed (see +// widgets/install.ts). Returns a human-readable error string, or null if valid. +export function validateManifest(manifest: unknown, files: unknown): string | null { + if (!manifest || typeof manifest !== 'object') return 'manifest is required'; + const m = manifest as Record; + if (typeof m.id !== 'string' || !ID_PATTERN.test(m.id)) return 'manifest.id must match /^[a-z0-9-]{1,40}$/'; + if (RESERVED_IDS.has(m.id)) return `id "${m.id}" is reserved for a built-in widget`; + if (typeof m.displayName !== 'string' || !m.displayName.trim()) return 'manifest.displayName is required'; + if (typeof m.version !== 'string' || !m.version.trim()) return 'manifest.version is required'; + if (typeof m.entry !== 'string' || !m.entry.trim()) return 'manifest.entry is required'; + + if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object'; + const entries = Object.entries(files as Record); + if (entries.length === 0) return 'files must be a non-empty object'; + if (!(m.entry in (files as Record))) return `entry "${m.entry as string}" not found in files`; + + let totalBytes = 0; + for (const [relPath, content] of entries) { + if (typeof content !== 'string') return `files["${relPath}"] must be a string`; + if (relPath.startsWith('/') || relPath.split('/').some((seg) => seg === '..')) return `invalid file path "${relPath}"`; + totalBytes += Buffer.byteLength(content, 'utf8'); + } + if (totalBytes > MAX_TOTAL_BYTES) return `bundle exceeds ${MAX_TOTAL_BYTES}-byte limit`; + + return null; +} diff --git a/backend/src/poe2/client.ts b/backend/src/widgets/poe2/client.ts similarity index 90% rename from backend/src/poe2/client.ts rename to backend/src/widgets/poe2/client.ts index d3755db..74ef227 100644 --- a/backend/src/poe2/client.ts +++ b/backend/src/widgets/poe2/client.ts @@ -1,5 +1,5 @@ // poe.ninja's public PoE2 economy API — free, no account or API key required. This is the -// only file that talks to it; poller.ts orchestrates when/how results get saved, same +// only file that talks to it; poll.ts orchestrates when/how results get saved, same // separation as backend/src/telegram/ and backend/src/weather/ keep between the raw client // and their callers. // @@ -13,7 +13,7 @@ // change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is // expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate // is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues -// below and poe2/poller.ts, which self-computes change from its own polling history instead. +// below and poll.ts, which self-computes change from its own polling history instead. const BASE_URL = 'https://poe.ninja'; export interface LeagueInfo { @@ -57,8 +57,9 @@ async function fetchCurrencyOverview(leagueId: string): Promise { const { lines, items } = await fetchCurrencyOverview(leagueId); const nameById = new Map(items.map((item) => [item.id, item.name])); diff --git a/backend/src/storage/db/poe2Watchlist.ts b/backend/src/widgets/poe2/db.ts similarity index 75% rename from backend/src/storage/db/poe2Watchlist.ts rename to backend/src/widgets/poe2/db.ts index beba793..8cd6500 100644 --- a/backend/src/storage/db/poe2Watchlist.ts +++ b/backend/src/widgets/poe2/db.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { db } from './index.js'; -import type { Poe2WatchlistEntry } from './types.js'; +import { db } from '../../storage/db/index.js'; +import type { Poe2WatchlistEntry } from '../../storage/db/types.js'; interface CurrencyRef { currencyId: string; @@ -24,21 +24,21 @@ function rowToEntry(row: any): Poe2WatchlistEntry { } export function listWatchlist(): Poe2WatchlistEntry[] { - const rows = db.prepare('SELECT * FROM poe2_watchlist ORDER BY priority_rank').all(); + const rows = db.prepare('SELECT * FROM widget_poe2_watchlist ORDER BY priority_rank').all(); return rows.map(rowToEntry); } -// No update() — currencies are picked from a live browse list (see poe2/client.ts's +// No update() — currencies are picked from a live browse list (see widgets/poe2/client.ts's // browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the // rare "picked the wrong one" case. export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry { const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}` .replace(/-+/g, '-') .replace(/(^-|-$)/g, ''); - const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM poe2_watchlist').get() as { m: number }; + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_poe2_watchlist').get() as { m: number }; const createdAt = new Date().toISOString(); db.prepare( - `INSERT INTO poe2_watchlist + `INSERT INTO widget_poe2_watchlist (id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt); @@ -58,14 +58,14 @@ export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2Wa } export function removeWatchlistEntry(id: string) { - db.prepare('DELETE FROM poe2_rate_history WHERE watchlist_id = ?').run(id); - db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id); + db.prepare('DELETE FROM widget_poe2_rate_history WHERE watchlist_id = ?').run(id); + db.prepare('DELETE FROM widget_poe2_watchlist WHERE id = ?').run(id); } -// One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed +// One snapshot per poll (see widgets/poe2/poll.ts) — the raw material 24h change is computed // from, since poe.ninja itself doesn't expose per-pair rates or a matching change window. export function recordRate(watchlistId: string, rate: number, recordedAt: string) { - db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run( + db.prepare('INSERT INTO widget_poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run( watchlistId, rate, recordedAt @@ -77,14 +77,14 @@ export function recordRate(watchlistId: string, rate: number, recordedAt: string // than fabricating a 0% figure. export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null { const row = db - .prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1') + .prepare('SELECT rate FROM widget_poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1') .get(watchlistId, cutoffIso) as { rate: number } | undefined; return row ? row.rate : null; } export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) { db.prepare( - `UPDATE poe2_watchlist + `UPDATE widget_poe2_watchlist SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ? WHERE id = ?` ).run(rate, change24h, new Date().toISOString(), error, id); @@ -94,5 +94,5 @@ export function markPolled(id: string, rate: number | null, change24h: number | // poll to be briefly late without losing the data point it needs. export function pruneOldHistory() { const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); - db.prepare('DELETE FROM poe2_rate_history WHERE recorded_at < ?').run(cutoff); + db.prepare('DELETE FROM widget_poe2_rate_history WHERE recorded_at < ?').run(cutoff); } diff --git a/backend/src/widgets/poe2/plugin.ts b/backend/src/widgets/poe2/plugin.ts new file mode 100644 index 0000000..92623df --- /dev/null +++ b/backend/src/widgets/poe2/plugin.ts @@ -0,0 +1,107 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { WidgetPlugin } from '../types.js'; +import { deleteAllKv } from '../../storage/db/widgetKv.js'; +import { logger } from '../../storage/db/logs.js'; +import * as poe2Db from './db.js'; +import { browseCurrencies, fetchCurrentLeague } from './client.js'; +import { pollPoe2Now, getLeagueCache } from './poll.js'; + +const OWNED_TABLES = ['widget_poe2_watchlist', 'widget_poe2_rate_history']; + +// Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs, always +// against the current challenge league (auto-detected, no admin config). The pilot +// implementation of the pluggable-widget system (see widgets/types.ts) — every other +// widget still ships in-process, but this one exercises the full migrate/poll/routes/ +// uninstall lifecycle a live-uploaded widget would also go through. +export const poe2Plugin: WidgetPlugin = { + id: 'poe2', + displayName: 'PoE2', + version: '1.0.0', + ownedTables: OWNED_TABLES, + + migrate(db: DatabaseSync) { + db.exec(` + CREATE TABLE IF NOT EXISTS widget_poe2_watchlist ( + id TEXT PRIMARY KEY, + base_currency_id TEXT NOT NULL, + base_name TEXT NOT NULL, + quote_currency_id TEXT NOT NULL, + quote_name TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + last_rate REAL, + last_change_24h REAL, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS widget_poe2_rate_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + watchlist_id TEXT NOT NULL, + rate REAL NOT NULL, + recorded_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_widget_poe2_rate_history_watchlist ON widget_poe2_rate_history(watchlist_id, recorded_at); + `); + }, + + poll: { + // poe.ninja's own overview data doesn't refresh faster than hourly, so polling + // more often than this just re-fetches the same numbers. + intervalMs: 60 * 60_000, + run: pollPoe2Now + }, + + registerPublicRoutes(app) { + app.get('/api/poe2', async () => { + const { leagueName, updatedAt } = getLeagueCache(); + return { leagueName, updatedAt, entries: poe2Db.listWatchlist() }; + }); + }, + + registerAdminRoutes(app) { + // League is always auto-detected, never admin-set. + app.get('/api/admin/poe2/browse', async (_req, reply) => { + try { + const league = await fetchCurrentLeague(); + return await browseCurrencies(league.id); + } catch (err) { + return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` }); + } + }); + + app.get('/api/admin/poe2/watchlist', async () => poe2Db.listWatchlist()); + + app.post('/api/admin/poe2/watchlist', async (req, reply) => { + const { base, quote } = req.body as { + base?: { currencyId?: string; name?: string }; + quote?: { currencyId?: string; name?: string }; + }; + if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) { + return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' }); + } + if (base.currencyId === quote.currencyId) { + return reply.code(400).send({ error: 'Base and quote currencies must be different' }); + } + const created = poe2Db.addWatchlistEntry( + { currencyId: base.currencyId, name: base.name }, + { currencyId: quote.currencyId, name: quote.name } + ); + // Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap, + // and refreshes every existing entry's rate too. + pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); + return reply.code(201).send(created); + }); + + app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + poe2Db.removeWatchlistEntry(id); + return reply.code(204).send(); + }); + }, + + uninstall(db: DatabaseSync) { + db.exec('DROP TABLE IF EXISTS widget_poe2_rate_history;'); + db.exec('DROP TABLE IF EXISTS widget_poe2_watchlist;'); + deleteAllKv('poe2'); + } +}; diff --git a/backend/src/widgets/poe2/poll.ts b/backend/src/widgets/poe2/poll.ts new file mode 100644 index 0000000..86804a9 --- /dev/null +++ b/backend/src/widgets/poe2/poll.ts @@ -0,0 +1,74 @@ +import * as poe2Db from './db.js'; +import { getKv, setKv } from '../../storage/db/widgetKv.js'; +import { logger } from '../../storage/db/logs.js'; +import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; + +const DAY_MS = 24 * 60 * 60_000; +const WIDGET_ID = 'poe2'; + +interface LeagueCache { + leagueId: string | null; + leagueName: string | null; + updatedAt: string | null; +} + +// Cache of what the last poll learned about the current league — no admin-set config +// (unlike weather): the league is always auto-detected, so this is purely a cache. Lives +// in the generic widget_kv store (see storage/db/widgetKv.ts) so it's prunable via the +// same deleteAllKv('poe2') path as everything else this widget owns. +export function getLeagueCache(): LeagueCache { + return getKv(WIDGET_ID, 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null }; +} + +function pctChange(current: number, past: number | null): number | null { + if (past === null || past === 0) return null; + return ((current - past) / past) * 100; +} + +// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the +// admin adds a pair (see plugin.ts's admin routes) — always re-detects the current +// challenge league fresh (cheap, guarantees correctness across league rotations with no +// separate staleness logic), then one overview request covers the whole watchlist. A pair +// whose base or quote currency is no longer traded this league gets its own lastError, it +// never aborts the rest of the batch. +export async function pollPoe2Now(): Promise { + let league; + try { + league = await fetchCurrentLeague(); + } catch (err) { + logger.error('poe2', `League lookup failed: ${(err as Error).message}`); + return; + } + + const entries = poe2Db.listWatchlist(); + if (entries.length === 0) { + setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() }); + return; + } + + try { + const valuesById = await fetchCurrencyValues(league.id); + const now = new Date(); + const nowIso = now.toISOString(); + const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); + + for (const entry of entries) { + const baseValue = valuesById.get(entry.baseCurrencyId); + const quoteValue = valuesById.get(entry.quoteCurrencyId); + if (baseValue === undefined || quoteValue === undefined) { + poe2Db.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league'); + continue; + } + + const rate = baseValue / quoteValue; + const change24h = pctChange(rate, poe2Db.rateAtOrBefore(entry.id, cutoff24h)); + poe2Db.recordRate(entry.id, rate, nowIso); + poe2Db.markPolled(entry.id, rate, change24h, null); + } + + poe2Db.pruneOldHistory(); + setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: nowIso }); + } catch (err) { + logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`); + } +} diff --git a/backend/src/widgets/registry.ts b/backend/src/widgets/registry.ts new file mode 100644 index 0000000..a45931d --- /dev/null +++ b/backend/src/widgets/registry.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { db } from '../storage/db/index.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { logger } from '../storage/db/logs.js'; +import type { WidgetPlugin } from './types.js'; +import { sweepOrphanedWidgetData } from './sweep.js'; +import { weatherPlugin } from '../weather/plugin.js'; +import { stocksPlugin } from '../stocks/plugin.js'; +import { bookmarksPlugin } from '../bookmarks/plugin.js'; +import { poe2Plugin } from './poe2/plugin.js'; + +// Both built-in and uploaded widgets funnel into this one map — scheduler dispatch and +// route registration (see index.ts, queue/scheduler.ts) iterate it generically instead of +// hardcoding a block per widget, mirroring ingestion/poller.ts's +// Record dispatch, just keyed by a dynamic string id. +export const loadedWidgets = new Map(); + +function registerBuiltinWidget(plugin: WidgetPlugin) { + plugin.migrate?.(db); + loadedWidgets.set(plugin.id, plugin); +} + +// Called once at startup (see index.ts, after migrate() and before route registration / +// startScheduler()) and again after a live upload (see widgets/install.ts) to pick up just +// the newly-installed one without reloading everything else. +export async function loadAllWidgets(): Promise { + loadedWidgets.clear(); + + registerBuiltinWidget(weatherPlugin); + registerBuiltinWidget(stocksPlugin); + registerBuiltinWidget(bookmarksPlugin); + registerBuiltinWidget(poe2Plugin); + + for (const row of installedWidgetsDb.listInstalled().filter((w) => w.source === 'uploaded')) { + await loadUploadedWidget(row.id, row.codePath); + } + + sweepOrphanedWidgetData( + db, + installedWidgetsDb.listInstalled().map((w) => w.id) + ); +} + +// A widget whose code fails to load logs an error and is simply absent from +// loadedWidgets — scheduler/route registration skip an id with no entry. Its +// installed_widgets row stays, so it's still visible and deletable from the admin side +// (see widgets/uninstall.ts, which doesn't require the widget's own code to be loadable). +export async function loadUploadedWidget(id: string, codePath: string): Promise { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(codePath, 'manifest.json'), 'utf8')) as { entry: string }; + const entryUrl = pathToFileURL(path.join(codePath, manifest.entry)).href; + const mod = await import(entryUrl); + const plugin: WidgetPlugin = mod.default; + if (!plugin || typeof plugin.id !== 'string') { + throw new Error('module has no default-exported WidgetPlugin'); + } + if (plugin.id !== id) { + throw new Error(`plugin id "${plugin.id}" does not match installed id "${id}"`); + } + plugin.migrate?.(db); + loadedWidgets.set(id, plugin); + return plugin; + } catch (err) { + logger.error('widgets', `Failed to load uploaded widget "${id}": ${(err as Error).message}`); + return null; + } +} diff --git a/backend/src/widgets/sweep.ts b/backend/src/widgets/sweep.ts new file mode 100644 index 0000000..0184ad1 --- /dev/null +++ b/backend/src/widgets/sweep.ts @@ -0,0 +1,48 @@ +import type { DatabaseSync } from 'node:sqlite'; + +// Host-side safety net for widget data pruning (see widgets/types.ts's uninstall contract +// and the plan's "data isolation strategy"). This alone is sufficient to fully clean a +// widget's data even with zero cooperation from its own code — a widget's own uninstall() +// hook (if present) is an optimization/extension point, not a requirement for correctness. + +function ownedTablesForId(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []): string[] { + const prefix = `widget_${widgetId}_`; + const rows = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '\\'`) + .all(`${prefix.replace(/_/g, '\\_')}%`) as { name: string }[]; + return Array.from(new Set([...rows.map((r) => r.name), ...extraOwnedTables])); +} + +// Drops every table owned by a single widget (by the widget__ naming convention, plus +// any self-reported extraOwnedTables for a grandfathered name), and clears its widget_kv +// rows. Safe to call even if the widget's own code is missing/broken/never uninstall()ed. +export function sweepWidgetData(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []) { + for (const table of ownedTablesForId(db, widgetId, extraOwnedTables)) { + db.exec(`DROP TABLE IF EXISTS "${table}";`); + } + db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId); +} + +// Full-registry sweep, run once at every startup after the registry loads (see +// widgets/registry.ts) — catches anything left behind by a process crash mid-uninstall, or +// a manually-edited installed_widgets table, that the per-widget sweep above never got a +// chance to run for. +export function sweepOrphanedWidgetData(db: DatabaseSync, knownWidgetIds: string[]) { + const known = new Set(knownWidgetIds); + const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'widget\\_%' ESCAPE '\\'`).all() as { + name: string; + }[]; + for (const { name } of tables) { + if (name === 'widget_kv') continue; // the shared kv table itself, not a per-widget owned table + const id = name.slice('widget_'.length).split('_')[0]; + if (!known.has(id)) { + db.exec(`DROP TABLE IF EXISTS "${name}";`); + } + } + const kvWidgetIds = db.prepare('SELECT DISTINCT widget_id FROM widget_kv').all() as { widget_id: string }[]; + const orphanedKvIds = kvWidgetIds.map((r) => r.widget_id).filter((id) => !known.has(id)); + if (orphanedKvIds.length > 0) { + const placeholders = orphanedKvIds.map(() => '?').join(','); + db.prepare(`DELETE FROM widget_kv WHERE widget_id IN (${placeholders})`).run(...orphanedKvIds); + } +} diff --git a/backend/src/widgets/types.ts b/backend/src/widgets/types.ts new file mode 100644 index 0000000..b445d2b --- /dev/null +++ b/backend/src/widgets/types.ts @@ -0,0 +1,31 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { FastifyInstance } from 'fastify'; + +/** + * A widget's own lifecycle hooks — deliberately all-optional (unlike + * ingestion/adapters/base.ts's SourceAdapter, which has one mandatory fetch()) because + * unlike source adapters, widgets don't share a common downstream table or data shape + * to normalize into; each is a self-contained module that opts into only what it needs. + */ +export interface WidgetPlugin { + /** Stable id — also the table-name (`widget__*`) and directory-name namespace. Validated on install: /^[a-z0-9-]{1,40}$/ */ + id: string; + displayName: string; + version: string; + + /** Widget-owned schema. Must be idempotent (CREATE TABLE IF NOT EXISTS style), same contract as the host's own migrate(). */ + migrate?(db: DatabaseSync): void; + + /** Self-reported table names, for the uninstall safety-net sweep — belt-and-suspenders alongside the widget__ naming convention (see widgets/sweep.ts). */ + ownedTables?: string[]; + + /** Host owns the setInterval/enable-gating (see queue/scheduler.ts); the plugin just does the fetch-and-persist work. */ + poll?: { intervalMs: number; run(): Promise }; + + /** Called once at load time (startup, or immediately after a live upload). Admin routes registered here are auto-gated by the existing X-Api-Key preHandler (see api/auth.ts), since it matches on any /api/admin/* path. */ + registerPublicRoutes?(app: FastifyInstance): void; + registerAdminRoutes?(app: FastifyInstance): void; + + /** Best-effort cleanup, called before the host's safety-net sweep on delete. Should not assume its own migrate() succeeded or that external APIs are reachable — the sweep is the real guarantee, this is just an extension point. */ + uninstall?(db: DatabaseSync): void; +} diff --git a/backend/src/widgets/uninstall.ts b/backend/src/widgets/uninstall.ts new file mode 100644 index 0000000..46f5b1a --- /dev/null +++ b/backend/src/widgets/uninstall.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { db } from '../storage/db/index.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { logger } from '../storage/db/logs.js'; +import { loadedWidgets } from './registry.js'; +import { sweepWidgetData } from './sweep.js'; +import { stopWidgetPolling } from '../queue/scheduler.js'; + +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; +const WIDGETS_DATA_DIR = process.env.WIDGETS_DATA_DIR || './data/widgets-data'; + +// Fully removes an uploaded widget — safe to call even if its code is already +// broken/missing (the plugin.uninstall() step is best-effort; sweepWidgetData is the real +// guarantee, matching every table/kv row/on-disk file regardless of the widget's own +// cooperation). Callers (see api/admin.ts's DELETE /api/admin/widgets/:id) are +// responsible for rejecting built-in widgets before calling this. +export async function uninstallWidget(id: string): Promise { + stopWidgetPolling(id); + + const plugin = loadedWidgets.get(id); + if (plugin?.uninstall) { + try { + plugin.uninstall(db); + } catch (err) { + logger.error('widgets', `uninstall() hook failed for "${id}": ${(err as Error).message}`); + } + } + + const row = installedWidgetsDb.getInstalled(id); + sweepWidgetData(db, id, row?.ownedTables ?? []); + + fs.rmSync(path.join(WIDGETS_INSTALLED_DIR, id), { recursive: true, force: true }); + fs.rmSync(path.join(WIDGETS_DATA_DIR, id), { recursive: true, force: true }); + + loadedWidgets.delete(id); + installedWidgetsDb.deleteInstalled(id); +}