Full widget parity, /api/widget/<id> route namespace, live sidebar rendering
Weather, Stocks, and Bookmarks move into backend/src/widgets/<id>/ alongside PoE2, each a full WidgetPlugin rather than a thin wrapper: their tables are renamed to the widget_<id>_ convention (stock_tickers, bookmarks) or moved off global_settings entirely into the generic widget_kv store (weather's location/unit config and forecast cache). Every widget's routes move under a consistent /api/widget/<id> (public) and /api/admin/widget/<id> (admin) namespace, replacing the previous flat /api/weather, /api/admin/poe2/browse, etc. The registry-management routes (list/upload/enable/delete any widget) stay at /api/admin/widgets since they address the collection, not one widget's own data. This also closes the gap where an uploaded widget had backend data plumbing but no visible presence anywhere: a widget's poll() can now publish to a generic GET /api/widget/:id/report feed (registered once, works for any id with zero per-widget route registration, so it's live immediately after upload with no restart); the sidebar renders it via a new GenericWidgetCard, or via a new DynamicWidgetSlot that dynamic-imports an optional pre-built vanilla-JS frontend bundle the widget can ship (served from a new /widget-assets/:id/* route) — plain browser import(), not blocked by SvelteKit's ahead-of-time Svelte compilation the way raw .svelte source would be. A widget's own custom API routes still need a restart to register (a hard Fastify limitation), but its data/poll/report and any custom frontend UI now work fully live. The admin Widgets tab gained an upload form and a list of installed pluggable widgets with enable/delete. Verified against a copy of the real dev DB (all three renames + the weather config/cache migration fire once and are idempotent on a second boot) and through a real browser: uploaded a widget with both a report-driven poll and a custom frontend bundle, confirmed it renders live in the sidebar with no backend restart, then deleted it and confirmed it disappears along with all of its data (table/kv rows/on-disk files). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -37,14 +37,6 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const before = settingsDb.getSettings();
|
||||
const settings = withStorageUsed(settingsDb.updateSettings(body));
|
||||
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.
|
||||
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 — scheduler.ts skips polling
|
||||
@@ -246,6 +238,26 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
return reply.code(201).send({ id: result.id });
|
||||
});
|
||||
|
||||
app.patch('/api/admin/widgets/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { enabled } = req.body as { enabled?: boolean };
|
||||
const widget = installedWidgetsDb.getInstalled(id);
|
||||
if (!widget) return reply.code(404).send({ error: 'not found' });
|
||||
if (typeof enabled === 'boolean') {
|
||||
installedWidgetsDb.setEnabled(id, enabled);
|
||||
// Re-enabling should show fresh data right away rather than waiting out the
|
||||
// widget's own poll interval — same "immediate poll on enable" behavior the
|
||||
// 4 built-ins get via PATCH /api/admin/settings above.
|
||||
if (enabled && !widget.enabled) {
|
||||
loadedWidgets
|
||||
.get(id)
|
||||
?.poll?.run()
|
||||
.catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`));
|
||||
}
|
||||
}
|
||||
return installedWidgetsDb.getInstalled(id);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/widgets/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const widget = installedWidgetsDb.getInstalled(id);
|
||||
|
||||
@@ -4,6 +4,9 @@ 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 installedWidgetsDb from '../storage/db/installedWidgets.js';
|
||||
import { getKv } from '../storage/db/widgetKv.js';
|
||||
import type { WidgetReport } from '../widgets/report.js';
|
||||
import { hasPrivateAccess } from './privateAccess.js';
|
||||
|
||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
@@ -59,15 +62,34 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
return categories.filter((c) => !c.isPrivate);
|
||||
});
|
||||
|
||||
// Per-widget enable flags + display order — see the admin panel's consolidated
|
||||
// "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these
|
||||
// flags (see scheduler.ts); Sidebar.svelte renders in exactly this order.
|
||||
// Per-widget enable flags + display order for the 4 built-ins — see the admin panel's
|
||||
// consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on
|
||||
// these flags (see scheduler.ts); Sidebar.svelte renders in exactly this order. The
|
||||
// `pluggable` array lists enabled *uploaded* widgets separately (their ids aren't part
|
||||
// of the closed weather|stocks|bookmarks|poe2 union `order` uses) — see
|
||||
// widgets/registry.ts, Sidebar.svelte's GenericWidgetCard/DynamicWidgetSlot.
|
||||
app.get('/api/widgets', async () => {
|
||||
const { widgets, widgetOrder } = settingsDb.getSettings();
|
||||
return { ...widgets, order: widgetOrder };
|
||||
const pluggable = installedWidgetsDb
|
||||
.listInstalled()
|
||||
.filter((w) => w.source === 'uploaded' && w.enabled)
|
||||
.map((w) => ({ id: w.id, displayName: w.displayName, frontendEntry: w.frontendEntry }));
|
||||
return { ...widgets, order: widgetOrder, pluggable };
|
||||
});
|
||||
|
||||
// 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
|
||||
// Generic live-data feed any widget (built-in or uploaded) can publish to via
|
||||
// setKv(id, 'report', ...) in its own poll.run() — see widgets/report.ts. Registered
|
||||
// once here rather than per-widget, so it works for a widget uploaded after this
|
||||
// process started, with zero new route registration (Fastify refuses routes added
|
||||
// after app.listen(), so this is what makes "poll live -> sidebar shows it live" work
|
||||
// for an uploaded widget without a restart).
|
||||
app.get('/api/widget/:id/report', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!installedWidgetsDb.getInstalled(id)?.enabled) return reply.code(404).send();
|
||||
return { data: getKv<WidgetReport>(id, 'report') };
|
||||
});
|
||||
|
||||
// Per-widget public routes (GET /api/widget/weather, /stocks, /bookmarks, /poe2) are
|
||||
// registered by each widget's own plugin — see widgets/registry.ts's generic
|
||||
// registerPublicRoutes loop in index.ts.
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { loadAllWidgets, loadedWidgets } from './widgets/registry.js';
|
||||
const PORT = Number(process.env.PORT) || 4000;
|
||||
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
|
||||
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
|
||||
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
|
||||
|
||||
function printApiKeyBanner() {
|
||||
const line = '='.repeat(64);
|
||||
@@ -103,6 +104,26 @@ async function main() {
|
||||
return reply.send(fs.createReadStream(filePath));
|
||||
});
|
||||
|
||||
// A widget's optional pre-built frontend bundle (see widgets/manifest.ts's
|
||||
// frontendEntry) — one generic wildcard route rather than one per widget, so it works
|
||||
// for a widget uploaded after this process started, with no restart (unlike a
|
||||
// widget's own custom API routes, which do need one — see widgets/install.ts).
|
||||
// Explicit Content-Type is required here (unlike /media/:filename above) — browsers
|
||||
// reject a dynamically-imported module whose response isn't served as a JS MIME
|
||||
// type. The wildcard also lets a bundle's own relative imports (e.g. `import
|
||||
// './helper.mjs'`) resolve automatically, since the browser requests those against
|
||||
// this same route.
|
||||
app.get('/widget-assets/:id/*', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const rel = (req.params as { '*': string })['*'];
|
||||
if (rel.includes('..')) return reply.code(400).send();
|
||||
const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel);
|
||||
if (!fs.existsSync(filePath)) return reply.code(404).send();
|
||||
if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript');
|
||||
else if (rel.endsWith('.css')) reply.type('text/css');
|
||||
return reply.send(fs.createReadStream(filePath));
|
||||
});
|
||||
|
||||
// Static "/media/proxy" and "/media/telegram-proxy" take priority over the
|
||||
// "/media/:filename" param route above regardless of registration order
|
||||
// (find-my-way, Fastify's router, always prefers a static segment over a parametric
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -45,6 +45,20 @@ export function migrate() {
|
||||
db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;');
|
||||
}
|
||||
|
||||
// Stocks and Bookmarks moved into the pluggable-widget system too (backend/src/widgets/
|
||||
// stocks/, widgets/bookmarks/) — same plain-rename treatment as PoE2 above, now owned by
|
||||
// each widget's own plugin.migrate() instead of this file's CREATE TABLE block.
|
||||
const hasOldStockTickersTable = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='stock_tickers'`).get();
|
||||
if (hasOldStockTickersTable) {
|
||||
db.exec('ALTER TABLE stock_tickers RENAME TO widget_stocks_tickers;');
|
||||
}
|
||||
// "bookmarks" is generic enough to collide with something else entirely — check for the
|
||||
// old bookmarks shape specifically (its distinctive is_private column) before renaming.
|
||||
const oldBookmarksCols = db.prepare(`PRAGMA table_info(bookmarks)`).all() as { name: string }[];
|
||||
if (oldBookmarksCols.some((c) => c.name === 'is_private')) {
|
||||
db.exec('ALTER TABLE bookmarks RENAME TO widget_bookmarks_items;');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -220,21 +234,6 @@ export function migrate() {
|
||||
poe2_updated_at TEXT
|
||||
);
|
||||
|
||||
-- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see
|
||||
-- stocks/poller.ts). Price/change/poll-state live directly on the row, same as
|
||||
-- sources.last_polled_at, rather than a separate quote-cache table.
|
||||
CREATE TABLE IF NOT EXISTS stock_tickers (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD"
|
||||
priority_rank INTEGER NOT NULL,
|
||||
last_price REAL,
|
||||
last_change_percent 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.
|
||||
@@ -259,20 +258,10 @@ export function migrate() {
|
||||
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
|
||||
frontend_entry TEXT, -- relative path to an optional pre-built browser JS bundle, served from /widget-assets/<id>/*
|
||||
installed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
|
||||
-- via is_private (same private-access lock feature as categories.is_private).
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
is_private INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
|
||||
-- the resulting login session. Deliberately its own table, not part of
|
||||
-- global_settings, so these encrypted blobs never ride along in the generic
|
||||
@@ -382,22 +371,8 @@ export function migrate() {
|
||||
`ALTER TABLE global_settings ADD COLUMN widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]'`
|
||||
);
|
||||
}
|
||||
|
||||
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
|
||||
// fresh install — the admin can remove/replace any of them via the Stocks tab.
|
||||
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number };
|
||||
if (tickerCount.c === 0) {
|
||||
const defaults: [string, string][] = [
|
||||
['Dow Jones', '^DJI'],
|
||||
['S&P 500', '^GSPC'],
|
||||
['Bitcoin', 'BTC-USD']
|
||||
];
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
defaults.forEach(([label, symbol], i) => {
|
||||
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
|
||||
});
|
||||
if (!hasColumn('installed_widgets', 'frontend_entry')) {
|
||||
db.exec('ALTER TABLE installed_widgets ADD COLUMN frontend_entry TEXT');
|
||||
}
|
||||
|
||||
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
||||
@@ -439,7 +414,12 @@ export function migrate() {
|
||||
? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]')
|
||||
: ['weather', 'stocks', 'poe2', 'bookmarks'];
|
||||
const displayNames: Record<string, string> = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' };
|
||||
const codePaths: Record<string, string> = { weather: 'weather', stocks: 'stocks', bookmarks: 'bookmarks', poe2: 'widgets/poe2' };
|
||||
const codePaths: Record<string, string> = {
|
||||
weather: 'widgets/weather',
|
||||
stocks: 'widgets/stocks',
|
||||
bookmarks: 'widgets/bookmarks',
|
||||
poe2: 'widgets/poe2'
|
||||
};
|
||||
const ownedTables: Record<string, string[]> = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] };
|
||||
const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true);
|
||||
const insertWidget = db.prepare(
|
||||
@@ -480,4 +460,35 @@ export function migrate() {
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
// Weather's config (admin-set location/units) and cache (last-polled forecast) move off
|
||||
// their bare global_settings columns into two widget_kv entries — same "every widget
|
||||
// owns its own data" consistency as PoE2's league cache above. Old columns left in
|
||||
// place, unread.
|
||||
const weatherRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any;
|
||||
const hasWeatherConfigKv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'weather' AND key = 'config'").get();
|
||||
if (weatherRow?.weather_location_name && !hasWeatherConfigKv) {
|
||||
const nowIso = new Date().toISOString();
|
||||
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'config', ?, ?)`).run(
|
||||
JSON.stringify({
|
||||
locationName: weatherRow.weather_location_name,
|
||||
latitude: weatherRow.weather_latitude,
|
||||
longitude: weatherRow.weather_longitude,
|
||||
unit: weatherRow.weather_unit,
|
||||
windUnit: weatherRow.weather_wind_unit,
|
||||
pressureUnit: weatherRow.weather_pressure_unit
|
||||
}),
|
||||
nowIso
|
||||
);
|
||||
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'cache', ?, ?)`).run(
|
||||
JSON.stringify({
|
||||
current: weatherRow.weather_current ? JSON.parse(weatherRow.weather_current) : null,
|
||||
hourly: JSON.parse(weatherRow.weather_hourly ?? '[]'),
|
||||
daily: JSON.parse(weatherRow.weather_daily ?? '[]'),
|
||||
alerts: JSON.parse(weatherRow.weather_alerts ?? '[]'),
|
||||
updatedAt: weatherRow.weather_updated_at
|
||||
}),
|
||||
nowIso
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function rowToWidget(row: any): InstalledWidget {
|
||||
priorityRank: row.priority_rank,
|
||||
version: row.version,
|
||||
ownedTables: JSON.parse(row.owned_tables),
|
||||
frontendEntry: row.frontend_entry ?? null,
|
||||
installedAt: row.installed_at
|
||||
};
|
||||
}
|
||||
@@ -33,13 +34,14 @@ export function insertWidget(widget: {
|
||||
priorityRank?: number;
|
||||
version?: string;
|
||||
ownedTables?: string[];
|
||||
frontendEntry?: string | null;
|
||||
}): 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, frontend_entry, installed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
widget.id,
|
||||
widget.displayName,
|
||||
@@ -49,6 +51,7 @@ export function insertWidget(widget: {
|
||||
priorityRank,
|
||||
widget.version ?? '1.0.0',
|
||||
JSON.stringify(widget.ownedTables ?? []),
|
||||
widget.frontendEntry ?? null,
|
||||
installedAt
|
||||
);
|
||||
return getInstalled(widget.id)!;
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
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
|
||||
@@ -32,8 +25,10 @@ function widgetsAndOrder(): Pick<GlobalSettings, 'widgets' | 'widgetOrder'> {
|
||||
return { widgets, widgetOrder };
|
||||
}
|
||||
|
||||
// Every widget's own config/data now lives behind its own /api/widget/<id> routes (see
|
||||
// widgets/weather/db.ts, widgets/poe2/poll.ts's league cache, etc.) — this settings blob
|
||||
// is just the scalar pipeline knobs plus the 4 builtins' enable/order flags.
|
||||
function rowToSettings(row: any): GlobalSettings {
|
||||
const poe2Cache = getKv<Poe2LeagueCache>('poe2', 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
|
||||
return {
|
||||
mergeStrictness: row.merge_strictness,
|
||||
holdBeforePublishMinutes: row.hold_before_publish_minutes,
|
||||
@@ -55,22 +50,7 @@ function rowToSettings(row: any): GlobalSettings {
|
||||
storageCapEnabled: !!row.storage_cap_enabled,
|
||||
storageCapValue: row.storage_cap_value,
|
||||
storageCapUnit: row.storage_cap_unit
|
||||
},
|
||||
weather: {
|
||||
locationName: row.weather_location_name,
|
||||
latitude: row.weather_latitude,
|
||||
longitude: row.weather_longitude,
|
||||
unit: row.weather_unit,
|
||||
windUnit: row.weather_wind_unit,
|
||||
pressureUnit: row.weather_pressure_unit,
|
||||
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
|
||||
current: row.weather_current ? JSON.parse(row.weather_current) : null,
|
||||
hourly: JSON.parse(row.weather_hourly),
|
||||
daily: JSON.parse(row.weather_daily),
|
||||
alerts: JSON.parse(row.weather_alerts),
|
||||
updatedAt: row.weather_updated_at
|
||||
},
|
||||
poe2: poe2Cache
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,8 +66,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
...patch,
|
||||
retention: { ...current.retention, ...(patch.retention ?? {}) },
|
||||
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
|
||||
weather: { ...current.weather, ...(patch.weather ?? {}) },
|
||||
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
|
||||
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
|
||||
};
|
||||
|
||||
@@ -99,9 +77,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
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
|
||||
@@ -116,11 +91,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url,
|
||||
telegram_media_mode=$telegram_media_mode,
|
||||
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
|
||||
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit
|
||||
WHERE id = 1`
|
||||
).run({
|
||||
$merge_strictness: merged.mergeStrictness,
|
||||
@@ -140,18 +111,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
|
||||
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
|
||||
$storage_cap_value: merged.retention.storageCapValue,
|
||||
$storage_cap_unit: merged.retention.storageCapUnit,
|
||||
$weather_location_name: merged.weather.locationName,
|
||||
$weather_latitude: merged.weather.latitude,
|
||||
$weather_longitude: merged.weather.longitude,
|
||||
$weather_unit: merged.weather.unit,
|
||||
$weather_wind_unit: merged.weather.windUnit,
|
||||
$weather_pressure_unit: merged.weather.pressureUnit,
|
||||
$weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null,
|
||||
$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
|
||||
$storage_cap_unit: merged.retention.storageCapUnit
|
||||
});
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
@@ -208,21 +208,6 @@ export interface Category {
|
||||
isSpillover: boolean;
|
||||
}
|
||||
|
||||
export interface WeatherHourEntry {
|
||||
time: string;
|
||||
temp: number;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface WeatherDayEntry {
|
||||
date: string;
|
||||
tempMax: number;
|
||||
tempMin: number;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface StockTicker {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -268,6 +253,8 @@ export interface InstalledWidget {
|
||||
version: string;
|
||||
/** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */
|
||||
ownedTables: string[];
|
||||
/** Relative path (within the widget's install dir) to an optional pre-built browser JS bundle, served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Null for a widget with no custom frontend (falls back to the generic report card). */
|
||||
frontendEntry: string | null;
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
@@ -314,56 +301,4 @@ export interface GlobalSettings {
|
||||
storageCapValue: number;
|
||||
storageCapUnit: 'MB' | 'GB';
|
||||
};
|
||||
/** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */
|
||||
weather: {
|
||||
locationName: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
unit: 'celsius' | 'fahrenheit';
|
||||
windUnit: 'mph' | 'kph';
|
||||
pressureUnit: 'inHg' | 'hPa';
|
||||
current: {
|
||||
temp: number;
|
||||
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
|
||||
feelsLike: number;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
/** Percent, 0-100. */
|
||||
humidity: number;
|
||||
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
|
||||
precipitationChance: number;
|
||||
/** Already in the admin's configured windUnit. */
|
||||
windSpeed: number;
|
||||
/** 8-point compass abbreviation, e.g. "NW". */
|
||||
windDirection: string;
|
||||
/** Already in the admin's configured pressureUnit. */
|
||||
pressure: number;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
} | null;
|
||||
hourly: WeatherHourEntry[];
|
||||
daily: WeatherDayEntry[];
|
||||
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */
|
||||
alerts: WeatherAlert[];
|
||||
updatedAt: string | null;
|
||||
};
|
||||
/**
|
||||
* Sidebar "PoE2" widget cache — see poe2/poller.ts. No admin-set config (unlike weather):
|
||||
* the league is always auto-detected as the current challenge league, so this is purely
|
||||
* a cache of what the last poll learned. Watchlist entries themselves live in the
|
||||
* poe2_watchlist table, not here — same split as stock_tickers vs. this settings row.
|
||||
*/
|
||||
poe2: {
|
||||
leagueId: string | null;
|
||||
leagueName: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WeatherAlert {
|
||||
id: string;
|
||||
event: string;
|
||||
headline: string;
|
||||
severity: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from './index.js';
|
||||
import type { Bookmark } from './types.js';
|
||||
import { db } from '../../storage/db/index.js';
|
||||
import type { Bookmark } from '../../storage/db/types.js';
|
||||
|
||||
function rowToBookmark(row: any): Bookmark {
|
||||
return {
|
||||
@@ -14,33 +14,33 @@ function rowToBookmark(row: any): Bookmark {
|
||||
}
|
||||
|
||||
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
|
||||
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
|
||||
// happens at the route layer (GET /api/widget/bookmarks), same as categoriesDb.listCategories().
|
||||
export function listBookmarks(): Bookmark[] {
|
||||
const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all();
|
||||
const rows = db.prepare('SELECT * FROM widget_bookmarks_items ORDER BY priority_rank').all();
|
||||
return rows.map(rowToBookmark);
|
||||
}
|
||||
|
||||
export function createBookmark(name: string, url: string, isPrivate = false): Bookmark {
|
||||
const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number };
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_bookmarks_items').get() as { m: number };
|
||||
const createdAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO widget_bookmarks_items (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt);
|
||||
return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt };
|
||||
}
|
||||
|
||||
export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null {
|
||||
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id);
|
||||
const existing = db.prepare('SELECT * FROM widget_bookmarks_items WHERE id = ?').get(id);
|
||||
if (!existing) return null;
|
||||
const current = rowToBookmark(existing);
|
||||
const merged = { ...current, ...patch };
|
||||
db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
|
||||
db.prepare('UPDATE widget_bookmarks_items SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
|
||||
merged.name, merged.url, merged.isPrivate ? 1 : 0, id
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function deleteBookmark(id: string) {
|
||||
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
|
||||
db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { WidgetPlugin } from '../types.js';
|
||||
import * as bookmarksDb from './db.js';
|
||||
import { hasPrivateAccess } from '../../api/privateAccess.js';
|
||||
|
||||
const OWNED_TABLES = ['widget_bookmarks_items'];
|
||||
|
||||
// Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public via
|
||||
// is_private (same private-access lock feature as categories.is_private). No poll (purely
|
||||
// admin-curated, no external fetch). Built-in and non-deletable, but otherwise a full
|
||||
// WidgetPlugin like an uploaded one.
|
||||
export const bookmarksPlugin: WidgetPlugin = {
|
||||
id: 'bookmarks',
|
||||
displayName: 'Bookmarks',
|
||||
version: '1.0.0',
|
||||
ownedTables: OWNED_TABLES,
|
||||
|
||||
migrate(db: DatabaseSync) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS widget_bookmarks_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
is_private INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/widget/bookmarks', async (req) => {
|
||||
const bookmarks = bookmarksDb.listBookmarks();
|
||||
if (hasPrivateAccess(req)) return bookmarks;
|
||||
return bookmarks.filter((b) => !b.isPrivate);
|
||||
});
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks());
|
||||
|
||||
app.post('/api/admin/widget/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/widget/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/widget/bookmarks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
bookmarksDb.deleteBookmark(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
},
|
||||
|
||||
uninstall(db: DatabaseSync) {
|
||||
db.exec('DROP TABLE IF EXISTS widget_bookmarks_items;');
|
||||
}
|
||||
};
|
||||
@@ -51,7 +51,8 @@ export async function installUploadedWidget(manifest: unknown, files: unknown):
|
||||
source: 'uploaded',
|
||||
codePath: dir,
|
||||
version: m.version,
|
||||
ownedTables: plugin.ownedTables ?? []
|
||||
ownedTables: plugin.ownedTables ?? [],
|
||||
frontendEntry: m.frontendEntry ?? null
|
||||
});
|
||||
|
||||
startWidgetPolling(plugin);
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface WidgetManifest {
|
||||
version: string;
|
||||
/** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */
|
||||
entry: string;
|
||||
/** Optional relative path within `files` to a pre-built browser JS bundle (plain vanilla JS, not raw Svelte source — see widgets/registry.ts's frontend-loading notes) exporting a default `{ mount(container, ctx) }`. Served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Omit for a widget with no custom UI — it falls back to the generic report card. */
|
||||
frontendEntry?: string;
|
||||
}
|
||||
|
||||
// Upload body shape: { manifest, files: { "index.mjs": "<source text>", ... } } — plain
|
||||
@@ -21,11 +23,17 @@ export function validateManifest(manifest: unknown, files: unknown): string | nu
|
||||
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 (m.frontendEntry !== undefined && (typeof m.frontendEntry !== 'string' || !m.frontendEntry.trim())) {
|
||||
return 'manifest.frontendEntry must be a non-empty string when present';
|
||||
}
|
||||
|
||||
if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object';
|
||||
const entries = Object.entries(files as Record<string, unknown>);
|
||||
if (entries.length === 0) return 'files must be a non-empty object';
|
||||
if (!(m.entry in (files as Record<string, unknown>))) return `entry "${m.entry as string}" not found in files`;
|
||||
if (m.frontendEntry !== undefined && !(m.frontendEntry in (files as Record<string, unknown>))) {
|
||||
return `frontendEntry "${m.frontendEntry as string}" not found in files`;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const [relPath, content] of entries) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const poe2Plugin: WidgetPlugin = {
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/poe2', async () => {
|
||||
app.get('/api/widget/poe2', async () => {
|
||||
const { leagueName, updatedAt } = getLeagueCache();
|
||||
return { leagueName, updatedAt, entries: poe2Db.listWatchlist() };
|
||||
});
|
||||
@@ -60,7 +60,7 @@ export const poe2Plugin: WidgetPlugin = {
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
// League is always auto-detected, never admin-set.
|
||||
app.get('/api/admin/poe2/browse', async (_req, reply) => {
|
||||
app.get('/api/admin/widget/poe2/browse', async (_req, reply) => {
|
||||
try {
|
||||
const league = await fetchCurrentLeague();
|
||||
return await browseCurrencies(league.id);
|
||||
@@ -69,9 +69,9 @@ export const poe2Plugin: WidgetPlugin = {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/poe2/watchlist', async () => poe2Db.listWatchlist());
|
||||
app.get('/api/admin/widget/poe2/watchlist', async () => poe2Db.listWatchlist());
|
||||
|
||||
app.post('/api/admin/poe2/watchlist', async (req, reply) => {
|
||||
app.post('/api/admin/widget/poe2/watchlist', async (req, reply) => {
|
||||
const { base, quote } = req.body as {
|
||||
base?: { currencyId?: string; name?: string };
|
||||
quote?: { currencyId?: string; name?: string };
|
||||
@@ -92,7 +92,7 @@ export const poe2Plugin: WidgetPlugin = {
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
|
||||
app.delete('/api/admin/widget/poe2/watchlist/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
poe2Db.removeWatchlistEntry(id);
|
||||
return reply.code(204).send();
|
||||
|
||||
@@ -6,9 +6,9 @@ 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 { 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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generic live-data shape a widget's poll.run() can publish via
|
||||
* setKv(id, 'report', report) (see storage/db/widgetKv.ts) for the sidebar's generic
|
||||
* report card to render — see api/public.ts's GET /api/widget/:id/report, registered once
|
||||
* at startup so it works for any widget id including ones uploaded after boot, with zero
|
||||
* per-widget route registration (sidesteps Fastify's "no routes after listen()" limit).
|
||||
*/
|
||||
export interface WidgetReport {
|
||||
title: string;
|
||||
headline?: { value: string; delta?: string } | null;
|
||||
rows?: { label: string; value: string }[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../storage/db/index.js';
|
||||
import type { StockTicker } from '../../storage/db/types.js';
|
||||
|
||||
function rowToTicker(row: any): StockTicker {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
symbol: row.symbol,
|
||||
priorityRank: row.priority_rank,
|
||||
lastPrice: row.last_price,
|
||||
lastChangePercent: row.last_change_percent,
|
||||
lastPolledAt: row.last_polled_at,
|
||||
lastError: row.last_error,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listStockTickers(): StockTicker[] {
|
||||
const rows = db.prepare('SELECT * FROM widget_stocks_tickers ORDER BY priority_rank').all();
|
||||
return rows.map(rowToTicker);
|
||||
}
|
||||
|
||||
export function createStockTicker(label: string, symbol: string): StockTicker {
|
||||
const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_stocks_tickers').get() as { m: number };
|
||||
const createdAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(id, label, symbol, maxRank.m + 1, createdAt);
|
||||
return {
|
||||
id, label, symbol, priorityRank: maxRank.m + 1,
|
||||
lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
|
||||
};
|
||||
}
|
||||
|
||||
export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null {
|
||||
const existing = db.prepare('SELECT * FROM widget_stocks_tickers WHERE id = ?').get(id);
|
||||
if (!existing) return null;
|
||||
const current = rowToTicker(existing);
|
||||
const merged = { ...current, ...patch };
|
||||
db.prepare('UPDATE widget_stocks_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
|
||||
return { ...merged };
|
||||
}
|
||||
|
||||
export function deleteStockTicker(id: string) {
|
||||
db.prepare('DELETE FROM widget_stocks_tickers WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
|
||||
db.prepare(
|
||||
'UPDATE widget_stocks_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
|
||||
).run(price, changePercent, new Date().toISOString(), error, id);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { WidgetPlugin } from '../types.js';
|
||||
import { logger } from '../../storage/db/logs.js';
|
||||
import * as stocksDb from './db.js';
|
||||
import { pollStocksNow } from './poll.js';
|
||||
|
||||
const OWNED_TABLES = ['widget_stocks_tickers'];
|
||||
|
||||
// Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see poll.ts).
|
||||
// Price/change/poll-state live directly on its own table, same as sources.last_polled_at,
|
||||
// rather than a separate quote-cache table. Built-in and non-deletable, but otherwise a
|
||||
// full WidgetPlugin like an uploaded one.
|
||||
export const stocksPlugin: WidgetPlugin = {
|
||||
id: 'stocks',
|
||||
displayName: 'Stocks',
|
||||
version: '1.0.0',
|
||||
ownedTables: OWNED_TABLES,
|
||||
|
||||
migrate(db: DatabaseSync) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS widget_stocks_tickers (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
last_price REAL,
|
||||
last_change_percent REAL,
|
||||
last_polled_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed a handful of sensible default tickers so the widget isn't empty on a fresh
|
||||
// install — the admin can remove/replace any of them.
|
||||
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM widget_stocks_tickers').get() as { c: number };
|
||||
if (tickerCount.c === 0) {
|
||||
const defaults: [string, string][] = [
|
||||
['Dow Jones', '^DJI'],
|
||||
['S&P 500', '^GSPC'],
|
||||
['Bitcoin', 'BTC-USD']
|
||||
];
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
defaults.forEach(([label, symbol], i) => {
|
||||
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
poll: {
|
||||
// Per admin spec — stock prices move faster than weather.
|
||||
intervalMs: 15 * 60_000,
|
||||
run: pollStocksNow
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/widget/stocks', async () => stocksDb.listStockTickers());
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
app.get('/api/admin/widget/stocks', async () => stocksDb.listStockTickers());
|
||||
|
||||
app.post('/api/admin/widget/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/widget/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/widget/stocks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
stocksDb.deleteStockTicker(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
},
|
||||
|
||||
uninstall(db: DatabaseSync) {
|
||||
db.exec('DROP TABLE IF EXISTS widget_stocks_tickers;');
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as stocksDb from '../storage/db/stocks.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import * as stocksDb from './db.js';
|
||||
import { logger } from '../../storage/db/logs.js';
|
||||
import { fetchQuotes } from './client.js';
|
||||
|
||||
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a
|
||||
// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why
|
||||
// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError,
|
||||
// it never aborts the rest of the batch.
|
||||
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
|
||||
// admin adds a ticker (see plugin.ts's admin routes) — one request per configured ticker
|
||||
// (see client.ts for why there's no batch endpoint here). A symbol Yahoo can't resolve
|
||||
// gets its own lastError, it never aborts the rest of the batch.
|
||||
export async function pollStocksNow(): Promise<void> {
|
||||
const tickers = stocksDb.listStockTickers();
|
||||
if (tickers.length === 0) return;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getKv, setKv } from '../../storage/db/widgetKv.js';
|
||||
|
||||
export interface WeatherHourEntry {
|
||||
time: string;
|
||||
temp: number;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface WeatherDayEntry {
|
||||
date: string;
|
||||
tempMax: number;
|
||||
tempMin: number;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface WeatherAlert {
|
||||
id: string;
|
||||
event: string;
|
||||
headline: string;
|
||||
severity: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface WeatherConfig {
|
||||
locationName: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
unit: 'celsius' | 'fahrenheit';
|
||||
windUnit: 'mph' | 'kph';
|
||||
pressureUnit: 'inHg' | 'hPa';
|
||||
}
|
||||
|
||||
export interface WeatherCache {
|
||||
current: {
|
||||
temp: number;
|
||||
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
|
||||
feelsLike: number;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
/** Percent, 0-100. */
|
||||
humidity: number;
|
||||
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
|
||||
precipitationChance: number;
|
||||
/** Already in the admin's configured windUnit. */
|
||||
windSpeed: number;
|
||||
/** 8-point compass abbreviation, e.g. "NW". */
|
||||
windDirection: string;
|
||||
/** Already in the admin's configured pressureUnit. */
|
||||
pressure: number;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
} | null;
|
||||
hourly: WeatherHourEntry[];
|
||||
daily: WeatherDayEntry[];
|
||||
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See client.ts's fetchActiveAlerts. */
|
||||
alerts: WeatherAlert[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const WIDGET_ID = 'weather';
|
||||
|
||||
const DEFAULT_CONFIG: WeatherConfig = {
|
||||
locationName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
unit: 'fahrenheit',
|
||||
windUnit: 'mph',
|
||||
pressureUnit: 'inHg'
|
||||
};
|
||||
|
||||
const DEFAULT_CACHE: WeatherCache = {
|
||||
current: null,
|
||||
hourly: [],
|
||||
daily: [],
|
||||
alerts: [],
|
||||
updatedAt: null
|
||||
};
|
||||
|
||||
// Config (admin-settable: location/units) and cache (poll-computed forecast) are stored as
|
||||
// two separate widget_kv keys — mirrors the singleton-row split stock_tickers/bookmarks
|
||||
// tables already had from global_settings, just via the generic kv store instead of a
|
||||
// bespoke table (this widget has no data shaped like rows, so no dedicated table is needed).
|
||||
export function getConfig(): WeatherConfig {
|
||||
return getKv<WeatherConfig>(WIDGET_ID, 'config') ?? DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<WeatherConfig>): WeatherConfig {
|
||||
const merged = { ...getConfig(), ...patch };
|
||||
setKv(WIDGET_ID, 'config', merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function getCache(): WeatherCache {
|
||||
return getKv<WeatherCache>(WIDGET_ID, 'cache') ?? DEFAULT_CACHE;
|
||||
}
|
||||
|
||||
export function setCache(patch: Partial<WeatherCache>): WeatherCache {
|
||||
const merged = { ...getCache(), ...patch };
|
||||
setKv(WIDGET_ID, 'cache', merged);
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { WidgetPlugin } from '../types.js';
|
||||
import { logger } from '../../storage/db/logs.js';
|
||||
import { geocodeLocation } from './client.js';
|
||||
import * as weatherDb from './db.js';
|
||||
import { pollWeatherNow } from './poll.js';
|
||||
|
||||
// Sidebar "Weather" widget — config (location/units) and cache (forecast) live entirely in
|
||||
// widget_kv (see db.ts); no bespoke table needed. Built-in and non-deletable (source:
|
||||
// 'builtin' in installed_widgets), but otherwise a full WidgetPlugin like an uploaded one.
|
||||
export const weatherPlugin: WidgetPlugin = {
|
||||
id: 'weather',
|
||||
displayName: 'Weather',
|
||||
version: '1.0.0',
|
||||
|
||||
poll: {
|
||||
intervalMs: 45 * 60_000,
|
||||
run: pollWeatherNow
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
// Returns config + cache together (same shape as the public route) so the admin
|
||||
// tab can show "currently showing X" status alongside the location/unit form.
|
||||
app.get('/api/admin/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
|
||||
|
||||
app.patch('/api/admin/widget/weather', async (req) => {
|
||||
weatherDb.setConfig(req.body as any);
|
||||
// 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}`));
|
||||
return { ...weatherDb.getConfig(), ...weatherDb.getCache() };
|
||||
});
|
||||
|
||||
app.get('/api/admin/widget/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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,26 +1,29 @@
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import { logger } from '../../storage/db/logs.js';
|
||||
import { fetchForecast, fetchActiveAlerts } from './client.js';
|
||||
import * as weatherDb from './db.js';
|
||||
import type { WeatherCache } from './db.js';
|
||||
|
||||
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
|
||||
// the weather location/units (see api/admin.ts) — writes straight into global_settings'
|
||||
// weather_* columns via settingsDb, same singleton-row approach as retention.
|
||||
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
|
||||
// admin changes the weather location/units (see plugin.ts's admin routes) — writes into
|
||||
// the widget's own widget_kv cache entry via weatherDb, same singleton-cache approach as
|
||||
// before, just no longer riding on global_settings.
|
||||
export async function pollWeatherNow(): Promise<void> {
|
||||
const { weather } = settingsDb.getSettings();
|
||||
if (weather.latitude === null || weather.longitude === null) {
|
||||
const config = weatherDb.getConfig();
|
||||
if (config.latitude === null || config.longitude === null) {
|
||||
// No location configured yet — not an error, just nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
let forecastUpdate: Partial<typeof weather> = {};
|
||||
const cache = weatherDb.getCache();
|
||||
let forecastUpdate: Partial<WeatherCache> = {};
|
||||
let forecastSucceeded = false;
|
||||
try {
|
||||
const { current, hourly, daily } = await fetchForecast(
|
||||
weather.latitude,
|
||||
weather.longitude,
|
||||
weather.unit,
|
||||
weather.windUnit,
|
||||
weather.pressureUnit
|
||||
config.latitude,
|
||||
config.longitude,
|
||||
config.unit,
|
||||
config.windUnit,
|
||||
config.pressureUnit
|
||||
);
|
||||
forecastUpdate = { current, hourly, daily };
|
||||
forecastSucceeded = true;
|
||||
@@ -33,20 +36,17 @@ export async function pollWeatherNow(): Promise<void> {
|
||||
// reliably (and expectedly) for every non-US location. A failure here shouldn't
|
||||
// touch the forecast update above, and unlike a stale forecast, a stale alert that's
|
||||
// since expired is worse to keep showing than none at all — clear to empty on failure.
|
||||
let alerts = weather.alerts;
|
||||
let alerts = cache.alerts;
|
||||
try {
|
||||
alerts = await fetchActiveAlerts(weather.latitude, weather.longitude);
|
||||
alerts = await fetchActiveAlerts(config.latitude, config.longitude);
|
||||
} catch (err) {
|
||||
alerts = [];
|
||||
logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
settingsDb.updateSettings({
|
||||
weather: {
|
||||
...weather,
|
||||
...forecastUpdate,
|
||||
alerts,
|
||||
updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt
|
||||
}
|
||||
weatherDb.setCache({
|
||||
...forecastUpdate,
|
||||
alerts,
|
||||
updatedAt: forecastSucceeded ? new Date().toISOString() : cache.updatedAt
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user