Merge pull request #20 from Salastil/development
Cleanup of dead code, UI consolidation and improvement
This commit is contained in:
@@ -38,12 +38,28 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
|||||||
categoriesDb.setCategoryOrder(body.categoryPriority);
|
categoriesDb.setCategoryOrder(body.categoryPriority);
|
||||||
delete body.categoryPriority;
|
delete body.categoryPriority;
|
||||||
}
|
}
|
||||||
|
const before = settingsDb.getSettings();
|
||||||
const settings = withStorageUsed(settingsDb.updateSettings(body));
|
const settings = withStorageUsed(settingsDb.updateSettings(body));
|
||||||
if (body.weather) {
|
if (body.weather) {
|
||||||
// Poll immediately rather than waiting for the next scheduler tick (up to 45
|
// 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.
|
// 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}`));
|
pollWeatherNow().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}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/events', async () => {
|
app.get('/api/events', async () => {
|
||||||
// Public fields only — sourceIds, cadenceTime etc. stay admin-only.
|
// Public fields only — sourceIds, keywords etc. stay admin-only.
|
||||||
return eventsDb.listEvents().map((e) => ({ id: e.id, name: e.name, active: e.active, cadence: e.cadence }));
|
return eventsDb
|
||||||
|
.listEvents()
|
||||||
|
.map((e) => ({ id: e.id, name: e.name, active: e.active, recapIntervalHours: e.recapIntervalHours, isSpillover: e.isSpillover }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories,
|
// Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories,
|
||||||
@@ -60,6 +62,14 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
|||||||
return categories.filter((c) => !c.isPrivate);
|
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.
|
||||||
|
app.get('/api/widgets', async () => {
|
||||||
|
const { widgets, widgetOrder } = settingsDb.getSettings();
|
||||||
|
return { ...widgets, order: widgetOrder };
|
||||||
|
});
|
||||||
|
|
||||||
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
|
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
|
||||||
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ const adapters: Record<Source['type'], SourceAdapter> = {
|
|||||||
telegram: telegramAdapter,
|
telegram: telegramAdapter,
|
||||||
api: apiAdapter,
|
api: apiAdapter,
|
||||||
youtube: youtubeAdapter,
|
youtube: youtubeAdapter,
|
||||||
nitter: nitterAdapter,
|
nitter: nitterAdapter
|
||||||
custom: apiAdapter
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Which source types point at a real webpage worth following for the full article,
|
// Which source types point at a real webpage worth following for the full article,
|
||||||
// as opposed to Telegram where the message itself *is* the content.
|
// as opposed to Telegram where the message itself *is* the content.
|
||||||
const FOLLOWS_LINK_FOR_FULL_ARTICLE: Source['type'][] = ['rss', 'api'];
|
const FOLLOWS_LINK_FOR_FULL_ARTICLE: Source['type'][] = ['rss', 'api'];
|
||||||
|
|
||||||
export async function pollDueSources(defaultIntervalMinutes: number): Promise<number> {
|
export async function pollDueSources(): Promise<number> {
|
||||||
const due = sourcesDb.sourcesDueForPoll(defaultIntervalMinutes);
|
const due = sourcesDb.sourcesDueForPoll();
|
||||||
let ingested = 0;
|
let ingested = 0;
|
||||||
for (const source of due) {
|
for (const source of due) {
|
||||||
ingested += await pollOne(source);
|
ingested += await pollOne(source);
|
||||||
|
|||||||
@@ -5,29 +5,14 @@ import { publishEventRecap } from '../pipeline/publish.js';
|
|||||||
import type { GlobalSettings } from '../storage/db/types.js';
|
import type { GlobalSettings } from '../storage/db/types.js';
|
||||||
import { logger } from '../storage/db/logs.js';
|
import { logger } from '../storage/db/logs.js';
|
||||||
|
|
||||||
|
// null means recaps are off for this item — e.g. one just organizing a commit or
|
||||||
|
// torrent RSS feed under its own nav entry, with nothing that needs periodically
|
||||||
|
// summarizing. Individual items still publish immediately regardless (see
|
||||||
|
// priorityQueue.ts); this only gates the periodic AI wrap-up below.
|
||||||
function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boolean {
|
function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boolean {
|
||||||
const now = new Date();
|
if (event.recapIntervalHours === null) return false;
|
||||||
const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null;
|
const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null;
|
||||||
|
return !last || Date.now() - last.getTime() >= event.recapIntervalHours * 3600_000;
|
||||||
// "Continuous" no longer means "recap every tick" — individual items matching this
|
|
||||||
// event now publish immediately regardless of cadence (see priorityQueue.ts), so the
|
|
||||||
// recap job's only remaining purpose is the periodic AI wrap-up. Treated the same as
|
|
||||||
// hourly so an ongoing event still gets occasional recaps without spamming a
|
|
||||||
// near-duplicate one on every synthesis tick.
|
|
||||||
if (event.cadence === 'continuous' || event.cadence === 'hourly') {
|
|
||||||
return !last || now.getTime() - last.getTime() >= 3600_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.cadence === 'daily') {
|
|
||||||
if (!event.cadenceTime) return false;
|
|
||||||
const [h, m] = event.cadenceTime.split(':').map(Number);
|
|
||||||
const scheduledToday = new Date(now);
|
|
||||||
scheduledToday.setHours(h, m, 0, 0);
|
|
||||||
const alreadyRecappedToday = last && last.toDateString() === now.toDateString();
|
|
||||||
return now >= scheduledToday && !alreadyRecappedToday;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { embedPendingItems } from '../pipeline/embedding.js';
|
|||||||
import { clusterItems } from '../pipeline/clustering.js';
|
import { clusterItems } from '../pipeline/clustering.js';
|
||||||
import { publishCluster, publishDirect } from '../pipeline/publish.js';
|
import { publishCluster, publishDirect } from '../pipeline/publish.js';
|
||||||
import { logger } from '../storage/db/logs.js';
|
import { logger } from '../storage/db/logs.js';
|
||||||
import type { GlobalSettings, ContentItem, TrackedEvent } from '../storage/db/types.js';
|
import type { GlobalSettings, ContentItem, TrackedEvent, Source } from '../storage/db/types.js';
|
||||||
|
|
||||||
function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
|
function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
|
||||||
const matches: T[] = [];
|
const matches: T[] = [];
|
||||||
@@ -31,8 +31,8 @@ function claimedEventId(item: ContentItem, events: TrackedEvent[]): string | nul
|
|||||||
return match?.id ?? null;
|
return match?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>): number {
|
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>, sourcesById: Map<string, Source>): number {
|
||||||
const source = sourcesDb.getSource(item.sourceId);
|
const source = sourcesById.get(item.sourceId);
|
||||||
const cats = source?.category ?? [];
|
const cats = source?.category ?? [];
|
||||||
let best = Infinity;
|
let best = Infinity;
|
||||||
for (const cat of cats) {
|
for (const cat of cats) {
|
||||||
@@ -45,6 +45,33 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>)
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared by both the passthrough (no-AI) and synthesis direct-publish paths — same
|
||||||
|
* publish-then-tag-then-log/error shape, differing only in how the success/failure
|
||||||
|
* message describes why the item skipped merging.
|
||||||
|
*/
|
||||||
|
async function publishItemsDirect(
|
||||||
|
items: ContentItem[],
|
||||||
|
settings: GlobalSettings,
|
||||||
|
activeEvents: TrackedEvent[],
|
||||||
|
describeSuccess: (item: ContentItem) => string,
|
||||||
|
failureLabel: string
|
||||||
|
): Promise<number> {
|
||||||
|
let published = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
const eventId = claimedEventId(item, activeEvents) ?? undefined;
|
||||||
|
const article = await publishDirect(item, settings, { eventId });
|
||||||
|
contentItemsDb.assignCluster([item.id], article.id);
|
||||||
|
published++;
|
||||||
|
logger.info('synthesis', `Published "${article.title}" directly (${describeSuccess(item)})`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('synthesis', `${failureLabel} for "${item.title}": ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback for when the AI service isn't reachable yet — publishes every eligible item
|
* Fallback for when the AI service isn't reachable yet — publishes every eligible item
|
||||||
* immediately rather than leaving pages empty until Ollama is set up. Unlike the AI
|
* immediately rather than leaving pages empty until Ollama is set up. Unlike the AI
|
||||||
@@ -58,28 +85,15 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
|
|||||||
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
|
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
|
||||||
if (items.length === 0) return 0;
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
|
const sourcesById = new Map(sourcesDb.listSources().map((s) => [s.id, s]));
|
||||||
const categories = categoriesDb.listCategories();
|
const categories = categoriesDb.listCategories();
|
||||||
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
|
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
|
||||||
const ranked = items
|
const ranked = items
|
||||||
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) }))
|
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName, sourcesById) }))
|
||||||
.sort((a, b) => a.rank - b.rank)
|
.sort((a, b) => a.rank - b.rank)
|
||||||
.map((r) => r.item);
|
.map((r) => r.item);
|
||||||
|
|
||||||
let published = 0;
|
return publishItemsDirect(ranked, settings, activeEvents, () => 'no AI available', 'Passthrough publish failed');
|
||||||
|
|
||||||
for (const item of ranked) {
|
|
||||||
try {
|
|
||||||
const eventId = claimedEventId(item, activeEvents) ?? undefined;
|
|
||||||
const article = await publishDirect(item, settings, { eventId });
|
|
||||||
contentItemsDb.assignCluster([item.id], article.id);
|
|
||||||
published++;
|
|
||||||
logger.info('synthesis', `Published "${article.title}" directly (no AI available)`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('synthesis', `Passthrough publish failed for "${item.title}": ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return published;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,36 +110,32 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
|
|||||||
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
|
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
|
||||||
if (items.length === 0) return 0;
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
|
// One fetch of the full source list per cycle, reused below for both the
|
||||||
|
// direct-publish partition and each item's category/type lookups — avoids a
|
||||||
|
// separate sourcesDb.getSource() round-trip per item.
|
||||||
|
const sourcesById = new Map(sourcesDb.listSources().map((s) => [s.id, s]));
|
||||||
|
|
||||||
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
|
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
|
||||||
// anything else — each is always its own article, same shape whether the AI service
|
// anything else — each is always its own article, same shape whether the AI service
|
||||||
// is up or not. Route them straight to publishDirect, same as the no-AI passthrough path.
|
// is up or not. Route them straight to publishDirect, same as the no-AI passthrough path.
|
||||||
const directPublishSourceIds = new Set(
|
const directPublishSourceIds = new Set(
|
||||||
sourcesDb
|
[...sourcesById.values()].filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram').map((s) => s.id)
|
||||||
.listSources()
|
|
||||||
.filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram')
|
|
||||||
.map((s) => s.id)
|
|
||||||
);
|
);
|
||||||
const [directItems, mergeableItems] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
|
const [directItems, mergeableItems] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
|
||||||
|
|
||||||
let publishedDirect = 0;
|
const publishedDirect = await publishItemsDirect(
|
||||||
for (const item of directItems) {
|
directItems,
|
||||||
try {
|
settings,
|
||||||
const eventId = claimedEventId(item, activeEvents) ?? undefined;
|
activeEvents,
|
||||||
const article = await publishDirect(item, settings, { eventId });
|
(item) => sourcesById.get(item.sourceId)?.type ?? 'unknown',
|
||||||
contentItemsDb.assignCluster([item.id], article.id);
|
'Direct publish failed'
|
||||||
publishedDirect++;
|
);
|
||||||
const source = sourcesDb.getSource(item.sourceId);
|
|
||||||
logger.info('synthesis', `Published "${article.title}" directly (${source?.type ?? 'unknown'})`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('synthesis', `Direct publish failed for "${item.title}": ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = categoriesDb.listCategories();
|
const categories = categoriesDb.listCategories();
|
||||||
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
|
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
|
||||||
|
|
||||||
const ranked = mergeableItems
|
const ranked = mergeableItems
|
||||||
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) }))
|
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName, sourcesById) }))
|
||||||
.sort((a, b) => a.rank - b.rank)
|
.sort((a, b) => a.rank - b.rank)
|
||||||
.map((r) => r.item);
|
.map((r) => r.item);
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export function startScheduler() {
|
|||||||
|
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = settingsDb.getSettings();
|
const ingested = await pollDueSources();
|
||||||
const ingested = await pollDueSources(settings.defaultPollIntervalMinutes);
|
|
||||||
if (ingested > 0) logger.info('scheduler', `Poll tick: ingested ${ingested} new item(s)`);
|
if (ingested > 0) logger.info('scheduler', `Poll tick: ingested ${ingested} new item(s)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('scheduler', `Poll tick failed: ${(err as Error).message}`);
|
logger.error('scheduler', `Poll tick failed: ${(err as Error).message}`);
|
||||||
@@ -70,19 +69,32 @@ export function startScheduler() {
|
|||||||
// Immediate first call for all three — unlike RSS sources (whose "due" check makes a
|
// 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
|
// 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
|
// no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after
|
||||||
// every restart.
|
// every restart. Each is also gated on its Widgets-tab enabled flag (see
|
||||||
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
|
// 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}`));
|
||||||
|
}
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
if (!settingsDb.getSettings().widgets.weather) return;
|
||||||
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
|
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
|
||||||
}, WEATHER_TICK_MS);
|
}, WEATHER_TICK_MS);
|
||||||
|
|
||||||
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
|
if (settingsDb.getSettings().widgets.stocks) {
|
||||||
|
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
|
||||||
|
}
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
if (!settingsDb.getSettings().widgets.stocks) return;
|
||||||
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
|
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
|
||||||
}, STOCKS_TICK_MS);
|
}, STOCKS_TICK_MS);
|
||||||
|
|
||||||
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
|
if (settingsDb.getSettings().widgets.poe2) {
|
||||||
|
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
|
||||||
|
}
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
if (!settingsDb.getSettings().widgets.poe2) return;
|
||||||
pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`));
|
pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`));
|
||||||
}, POE2_TICK_MS);
|
}, POE2_TICK_MS);
|
||||||
|
|
||||||
|
|||||||
@@ -143,13 +143,6 @@ export function articlesForEventSince(eventId: string, since: string): MergedArt
|
|||||||
return rows.map(rowToArticle);
|
return rows.map(rowToArticle);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function latestArticleInThread(threadId: string): MergedArticle | null {
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1')
|
|
||||||
.get(threadId);
|
|
||||||
return row ? rowToArticle(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function articlesOlderThan(days: number): MergedArticle[] {
|
export function articlesOlderThan(days: number): MergedArticle[] {
|
||||||
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||||
const rows = db.prepare('SELECT * FROM merged_articles WHERE published_at < ?').all(cutoff);
|
const rows = db.prepare('SELECT * FROM merged_articles WHERE published_at < ?').all(cutoff);
|
||||||
|
|||||||
@@ -69,15 +69,6 @@ export function unclusteredItemsExcludingSources(excludeSourceIds: string[]): Co
|
|||||||
return items.filter((i) => !excludeSourceIds.includes(i.sourceId));
|
return items.filter((i) => !excludeSourceIds.includes(i.sourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unclusteredItemsForSources(sourceIds: string[], sinceISO: string): ContentItem[] {
|
|
||||||
if (sourceIds.length === 0) return [];
|
|
||||||
const placeholders = sourceIds.map(() => '?').join(',');
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT * FROM content_items WHERE cluster_id IS NULL AND source_id IN (${placeholders}) AND fetched_at > ?`)
|
|
||||||
.all(...sourceIds, sinceISO);
|
|
||||||
return rows.map(rowToItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setEmbedding(id: string, embedding: number[]) {
|
export function setEmbedding(id: string, embedding: number[]) {
|
||||||
db.prepare('UPDATE content_items SET embedding = ? WHERE id = ?').run(JSON.stringify(embedding), id);
|
db.prepare('UPDATE content_items SET embedding = ? WHERE id = ?').run(JSON.stringify(embedding), id);
|
||||||
}
|
}
|
||||||
@@ -93,11 +84,6 @@ export function resetClusterForItems(ids: string[]) {
|
|||||||
for (const id of ids) stmt.run(id);
|
for (const id of ids) stmt.run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemsByCluster(clusterId: string): ContentItem[] {
|
|
||||||
const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id = ?').all(clusterId);
|
|
||||||
return rows.map(rowToItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function itemsOlderThan(days: number): ContentItem[] {
|
export function itemsOlderThan(days: number): ContentItem[] {
|
||||||
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||||
const rows = db.prepare('SELECT * FROM content_items WHERE fetched_at < ?').all(cutoff);
|
const rows = db.prepare('SELECT * FROM content_items WHERE fetched_at < ?').all(cutoff);
|
||||||
@@ -117,7 +103,3 @@ export function itemsForSource(sourceId: string): ContentItem[] {
|
|||||||
export function deleteContentItemsForSource(sourceId: string) {
|
export function deleteContentItemsForSource(sourceId: string) {
|
||||||
db.prepare('DELETE FROM content_items WHERE source_id = ?').run(sourceId);
|
db.prepare('DELETE FROM content_items WHERE source_id = ?').run(sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAllContentItems() {
|
|
||||||
db.prepare('DELETE FROM content_items').run();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ function rowToEvent(row: any): TrackedEvent {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
sourceIds: JSON.parse(row.source_ids),
|
sourceIds: JSON.parse(row.source_ids),
|
||||||
keywords: JSON.parse(row.keywords),
|
keywords: JSON.parse(row.keywords),
|
||||||
cadence: row.cadence,
|
recapIntervalHours: row.recap_interval_hours,
|
||||||
cadenceTime: row.cadence_time,
|
|
||||||
active: !!row.active,
|
active: !!row.active,
|
||||||
|
isSpillover: !!row.is_spillover,
|
||||||
retentionOverrideDays: row.retention_override_days,
|
retentionOverrideDays: row.retention_override_days,
|
||||||
lastRecapAt: row.last_recap_at,
|
lastRecapAt: row.last_recap_at,
|
||||||
createdAt: row.created_at
|
createdAt: row.created_at
|
||||||
@@ -52,7 +52,7 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
|
|||||||
const id = `evt-${randomUUID()}`;
|
const id = `evt-${randomUUID()}`;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO tracked_events (id, name, description, source_ids, keywords, cadence, cadence_time, active, retention_override_days, last_recap_at, created_at)
|
`INSERT INTO tracked_events (id, name, description, source_ids, keywords, recap_interval_hours, active, is_spillover, retention_override_days, last_recap_at, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
@@ -60,9 +60,9 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
|
|||||||
input.description ?? '',
|
input.description ?? '',
|
||||||
JSON.stringify(input.sourceIds ?? []),
|
JSON.stringify(input.sourceIds ?? []),
|
||||||
JSON.stringify(input.keywords ?? []),
|
JSON.stringify(input.keywords ?? []),
|
||||||
input.cadence ?? 'continuous',
|
input.recapIntervalHours ?? null,
|
||||||
input.cadenceTime ?? null,
|
|
||||||
input.active === false ? 0 : 1,
|
input.active === false ? 0 : 1,
|
||||||
|
input.isSpillover ? 1 : 0,
|
||||||
input.retentionOverrideDays ?? null,
|
input.retentionOverrideDays ?? null,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -74,15 +74,15 @@ export function updateEvent(id: string, patch: Partial<TrackedEvent>): TrackedEv
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const merged = { ...existing, ...patch };
|
const merged = { ...existing, ...patch };
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, cadence=?, cadence_time=?, active=?, retention_override_days=?, last_recap_at=? WHERE id=?`
|
`UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, recap_interval_hours=?, active=?, is_spillover=?, retention_override_days=?, last_recap_at=? WHERE id=?`
|
||||||
).run(
|
).run(
|
||||||
merged.name,
|
merged.name,
|
||||||
merged.description,
|
merged.description,
|
||||||
JSON.stringify(merged.sourceIds),
|
JSON.stringify(merged.sourceIds),
|
||||||
JSON.stringify(merged.keywords),
|
JSON.stringify(merged.keywords),
|
||||||
merged.cadence,
|
merged.recapIntervalHours,
|
||||||
merged.cadenceTime,
|
|
||||||
merged.active ? 1 : 0,
|
merged.active ? 1 : 0,
|
||||||
|
merged.isSpillover ? 1 : 0,
|
||||||
merged.retentionOverrideDays,
|
merged.retentionOverrideDays,
|
||||||
merged.lastRecapAt,
|
merged.lastRecapAt,
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function migrate() {
|
|||||||
CREATE TABLE IF NOT EXISTS sources (
|
CREATE TABLE IF NOT EXISTS sources (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL, -- rss | api | telegram | custom
|
type TEXT NOT NULL, -- rss | api | telegram | youtube | nitter
|
||||||
category TEXT NOT NULL DEFAULT '[]', -- JSON array
|
category TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||||
url TEXT,
|
url TEXT,
|
||||||
config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders
|
config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders
|
||||||
@@ -130,9 +130,9 @@ export function migrate() {
|
|||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
source_ids TEXT NOT NULL DEFAULT '[]', -- JSON
|
source_ids TEXT NOT NULL DEFAULT '[]', -- JSON
|
||||||
keywords TEXT NOT NULL DEFAULT '[]', -- JSON string array — empty means "match everything from source_ids"
|
keywords TEXT NOT NULL DEFAULT '[]', -- JSON string array — empty means "match everything from source_ids"
|
||||||
cadence TEXT NOT NULL DEFAULT 'continuous',
|
recap_interval_hours INTEGER, -- hours between AI recaps; NULL = recaps off for this item
|
||||||
cadence_time TEXT,
|
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_spillover INTEGER NOT NULL DEFAULT 0,
|
||||||
retention_override_days INTEGER,
|
retention_override_days INTEGER,
|
||||||
last_recap_at TEXT,
|
last_recap_at TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
@@ -168,7 +168,6 @@ export function migrate() {
|
|||||||
CREATE TABLE IF NOT EXISTS global_settings (
|
CREATE TABLE IF NOT EXISTS global_settings (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
|
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
|
||||||
merge_strictness INTEGER NOT NULL DEFAULT 3,
|
merge_strictness INTEGER NOT NULL DEFAULT 3,
|
||||||
default_poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
|
|
||||||
hold_before_publish_minutes INTEGER NOT NULL DEFAULT 30,
|
hold_before_publish_minutes INTEGER NOT NULL DEFAULT 30,
|
||||||
tag_dedup_threshold REAL NOT NULL DEFAULT 0.82,
|
tag_dedup_threshold REAL NOT NULL DEFAULT 0.82,
|
||||||
tag_expiry_days INTEGER NOT NULL DEFAULT 21,
|
tag_expiry_days INTEGER NOT NULL DEFAULT 21,
|
||||||
@@ -185,6 +184,11 @@ export function migrate() {
|
|||||||
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
|
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
|
||||||
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
|
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
|
||||||
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
|
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
|
||||||
|
widget_weather_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
widget_poe2_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]', -- JSON array, admin-sortable via the Widgets tab
|
||||||
weather_location_name TEXT,
|
weather_location_name TEXT,
|
||||||
weather_latitude REAL,
|
weather_latitude REAL,
|
||||||
weather_longitude REAL,
|
weather_longitude REAL,
|
||||||
@@ -200,7 +204,6 @@ export function migrate() {
|
|||||||
weather_updated_at TEXT, -- ISO timestamp, NULL pre-first-poll
|
weather_updated_at TEXT, -- ISO timestamp, NULL pre-first-poll
|
||||||
poe2_league_id TEXT,
|
poe2_league_id TEXT,
|
||||||
poe2_league_name TEXT,
|
poe2_league_name TEXT,
|
||||||
poe2_primary_currency_name TEXT, -- unused since the watchlist moved to arbitrary currency pairs (no single "quoted in" currency anymore) — column kept rather than dropped, SQLite ALTER TABLE can't drop columns without a full table rebuild
|
|
||||||
poe2_updated_at TEXT
|
poe2_updated_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -326,6 +329,12 @@ export function migrate() {
|
|||||||
if (!hasColumn('tracked_events', 'keywords')) {
|
if (!hasColumn('tracked_events', 'keywords')) {
|
||||||
db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'");
|
db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'");
|
||||||
}
|
}
|
||||||
|
if (!hasColumn('tracked_events', 'is_spillover')) {
|
||||||
|
db.exec('ALTER TABLE tracked_events ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
if (!hasColumn('tracked_events', 'recap_interval_hours')) {
|
||||||
|
db.exec('ALTER TABLE tracked_events ADD COLUMN recap_interval_hours INTEGER');
|
||||||
|
}
|
||||||
if (!hasColumn('merged_articles', 'is_recap')) {
|
if (!hasColumn('merged_articles', 'is_recap')) {
|
||||||
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
|
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
||||||
@@ -347,9 +356,19 @@ export function migrate() {
|
|||||||
if (!hasColumn('global_settings', 'poe2_league_id')) {
|
if (!hasColumn('global_settings', 'poe2_league_id')) {
|
||||||
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_id TEXT');
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_id TEXT');
|
||||||
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_name TEXT');
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_name TEXT');
|
||||||
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_primary_currency_name TEXT');
|
|
||||||
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_updated_at TEXT');
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_updated_at TEXT');
|
||||||
}
|
}
|
||||||
|
if (!hasColumn('global_settings', 'widget_weather_enabled')) {
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN widget_weather_enabled INTEGER NOT NULL DEFAULT 1');
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN widget_stocks_enabled INTEGER NOT NULL DEFAULT 1');
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1');
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN widget_poe2_enabled INTEGER NOT NULL DEFAULT 1');
|
||||||
|
}
|
||||||
|
if (!hasColumn('global_settings', 'widget_order')) {
|
||||||
|
db.exec(
|
||||||
|
`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
|
// 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.
|
// fresh install — the admin can remove/replace any of them via the Stocks tab.
|
||||||
@@ -368,19 +387,6 @@ export function migrate() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stocks switched data providers from Stooq (walled off behind a proof-of-work
|
|
||||||
// challenge) to Yahoo Finance, which uses different symbol syntax — rewrites only
|
|
||||||
// rows still holding exactly one of the three old Stooq-format default symbols we
|
|
||||||
// ourselves seeded, never touching a symbol the admin typed in themselves.
|
|
||||||
const stooqToYahooSymbols: [string, string][] = [
|
|
||||||
['^dji', '^DJI'],
|
|
||||||
['^spx', '^GSPC'],
|
|
||||||
['btcusd', 'BTC-USD']
|
|
||||||
];
|
|
||||||
for (const [oldSymbol, newSymbol] of stooqToYahooSymbols) {
|
|
||||||
db.prepare('UPDATE stock_tickers SET symbol = ? WHERE symbol = ?').run(newSymbol, oldSymbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
||||||
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
||||||
// filterable tag: it's the homepage view, now scoped to only the articles whose
|
// filterable tag: it's the homepage view, now scoped to only the articles whose
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { GlobalSettings } from './types.js';
|
|||||||
function rowToSettings(row: any): GlobalSettings {
|
function rowToSettings(row: any): GlobalSettings {
|
||||||
return {
|
return {
|
||||||
mergeStrictness: row.merge_strictness,
|
mergeStrictness: row.merge_strictness,
|
||||||
defaultPollIntervalMinutes: row.default_poll_interval_minutes,
|
|
||||||
holdBeforePublishMinutes: row.hold_before_publish_minutes,
|
holdBeforePublishMinutes: row.hold_before_publish_minutes,
|
||||||
tagDedupThreshold: row.tag_dedup_threshold,
|
tagDedupThreshold: row.tag_dedup_threshold,
|
||||||
tagExpiryDays: row.tag_expiry_days,
|
tagExpiryDays: row.tag_expiry_days,
|
||||||
@@ -16,6 +15,13 @@ function rowToSettings(row: any): GlobalSettings {
|
|||||||
nitterMediaMode: row.nitter_media_mode,
|
nitterMediaMode: row.nitter_media_mode,
|
||||||
fxtwitterBaseUrl: row.fxtwitter_base_url,
|
fxtwitterBaseUrl: row.fxtwitter_base_url,
|
||||||
telegramMediaMode: row.telegram_media_mode,
|
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),
|
||||||
retention: {
|
retention: {
|
||||||
publishedArticleMaxAgeDays: row.published_article_max_age_days,
|
publishedArticleMaxAgeDays: row.published_article_max_age_days,
|
||||||
rawItemMaxAgeDays: row.raw_item_max_age_days,
|
rawItemMaxAgeDays: row.raw_item_max_age_days,
|
||||||
@@ -58,54 +64,68 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
|||||||
retention: { ...current.retention, ...(patch.retention ?? {}) },
|
retention: { ...current.retention, ...(patch.retention ?? {}) },
|
||||||
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
|
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
|
||||||
weather: { ...current.weather, ...(patch.weather ?? {}) },
|
weather: { ...current.weather, ...(patch.weather ?? {}) },
|
||||||
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }
|
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
|
||||||
|
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
|
||||||
};
|
};
|
||||||
|
// 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.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE global_settings SET
|
`UPDATE global_settings SET
|
||||||
merge_strictness=?, default_poll_interval_minutes=?, hold_before_publish_minutes=?,
|
merge_strictness=$merge_strictness,
|
||||||
tag_dedup_threshold=?, tag_expiry_days=?, follow_up_min_hours_since_last=?, follow_up_min_new_sources=?,
|
hold_before_publish_minutes=$hold_before_publish_minutes,
|
||||||
ai_service_host=?, ai_service_port=?, selected_models=?,
|
tag_dedup_threshold=$tag_dedup_threshold, tag_expiry_days=$tag_expiry_days,
|
||||||
nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?,
|
follow_up_min_hours_since_last=$follow_up_min_hours_since_last, follow_up_min_new_sources=$follow_up_min_new_sources,
|
||||||
published_article_max_age_days=?, raw_item_max_age_days=?,
|
ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models,
|
||||||
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
|
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, telegram_media_mode=$telegram_media_mode,
|
||||||
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
|
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
|
||||||
weather_wind_unit=?, weather_pressure_unit=?,
|
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
|
||||||
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?,
|
widget_order=$widget_order,
|
||||||
poe2_league_id=?, poe2_league_name=?, poe2_updated_at=?
|
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
|
||||||
WHERE id = 1`
|
WHERE id = 1`
|
||||||
).run(
|
).run({
|
||||||
merged.mergeStrictness,
|
$merge_strictness: merged.mergeStrictness,
|
||||||
merged.defaultPollIntervalMinutes,
|
$hold_before_publish_minutes: merged.holdBeforePublishMinutes,
|
||||||
merged.holdBeforePublishMinutes,
|
$tag_dedup_threshold: merged.tagDedupThreshold,
|
||||||
merged.tagDedupThreshold,
|
$tag_expiry_days: merged.tagExpiryDays,
|
||||||
merged.tagExpiryDays,
|
$follow_up_min_hours_since_last: merged.followUpMinHoursSinceLast,
|
||||||
merged.followUpMinHoursSinceLast,
|
$follow_up_min_new_sources: merged.followUpMinNewSources,
|
||||||
merged.followUpMinNewSources,
|
$ai_service_host: merged.aiServiceHost,
|
||||||
merged.aiServiceHost,
|
$ai_service_port: merged.aiServicePort,
|
||||||
merged.aiServicePort,
|
$selected_models: JSON.stringify(merged.selectedModels),
|
||||||
JSON.stringify(merged.selectedModels),
|
$nitter_media_mode: merged.nitterMediaMode,
|
||||||
merged.nitterMediaMode,
|
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
|
||||||
merged.fxtwitterBaseUrl,
|
$telegram_media_mode: merged.telegramMediaMode,
|
||||||
merged.telegramMediaMode,
|
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
|
||||||
merged.retention.publishedArticleMaxAgeDays,
|
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
|
||||||
merged.retention.rawItemMaxAgeDays,
|
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
|
||||||
merged.retention.storageCapEnabled ? 1 : 0,
|
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
|
||||||
merged.retention.storageCapValue,
|
$widget_order: JSON.stringify(merged.widgetOrder),
|
||||||
merged.retention.storageCapUnit,
|
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
|
||||||
merged.weather.locationName,
|
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
|
||||||
merged.weather.latitude,
|
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
|
||||||
merged.weather.longitude,
|
$storage_cap_value: merged.retention.storageCapValue,
|
||||||
merged.weather.unit,
|
$storage_cap_unit: merged.retention.storageCapUnit,
|
||||||
merged.weather.windUnit,
|
$weather_location_name: merged.weather.locationName,
|
||||||
merged.weather.pressureUnit,
|
$weather_latitude: merged.weather.latitude,
|
||||||
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
|
$weather_longitude: merged.weather.longitude,
|
||||||
JSON.stringify(merged.weather.hourly),
|
$weather_unit: merged.weather.unit,
|
||||||
JSON.stringify(merged.weather.daily),
|
$weather_wind_unit: merged.weather.windUnit,
|
||||||
JSON.stringify(merged.weather.alerts),
|
$weather_pressure_unit: merged.weather.pressureUnit,
|
||||||
merged.weather.updatedAt,
|
$weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null,
|
||||||
merged.poe2.leagueId,
|
$weather_hourly: JSON.stringify(merged.weather.hourly),
|
||||||
merged.poe2.leagueName,
|
$weather_daily: JSON.stringify(merged.weather.daily),
|
||||||
merged.poe2.updatedAt
|
$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
|
||||||
|
});
|
||||||
return getSettings();
|
return getSettings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,12 +89,11 @@ export function markPolled(id: string, error: string | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sources due for polling right now, based on their own interval (or the global default). */
|
/** Sources due for polling right now, based on their own interval. */
|
||||||
export function sourcesDueForPoll(defaultIntervalMinutes: number): Source[] {
|
export function sourcesDueForPoll(): Source[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return listEnabledSources().filter((s) => {
|
return listEnabledSources().filter((s) => {
|
||||||
if (!s.lastPolledAt) return true;
|
if (!s.lastPolledAt) return true;
|
||||||
const interval = (s.pollIntervalMinutes || defaultIntervalMinutes) * 60_000;
|
return now - new Date(s.lastPolledAt).getTime() >= s.pollIntervalMinutes * 60_000;
|
||||||
return now - new Date(s.lastPolledAt).getTime() >= interval;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,6 @@ export function listActiveTags(): Tag[] {
|
|||||||
return rows.map(rowToTag);
|
return rows.map(rowToTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTagsByIds(ids: string[]): Tag[] {
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
|
||||||
const rows = db.prepare(`SELECT * FROM tags WHERE id IN (${placeholders})`).all(...ids);
|
|
||||||
return rows.map(rowToTag);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cosineSimilarity(a: number[], b: number[]): number {
|
function cosineSimilarity(a: number[], b: number[]): number {
|
||||||
if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0;
|
if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0;
|
||||||
let dot = 0,
|
let dot = 0,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export interface Source {
|
export interface Source {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter' | 'custom';
|
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter';
|
||||||
category: string[];
|
category: string[];
|
||||||
url: string | null;
|
url: string | null;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
@@ -178,9 +178,11 @@ export interface TrackedEvent {
|
|||||||
sourceIds: string[];
|
sourceIds: string[];
|
||||||
/** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds", the original behavior. */
|
/** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds", the original behavior. */
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
|
/** Hours between AI recaps, or null to turn recaps off entirely — e.g. an item that's just organizing a commit or torrent RSS feed under one nav entry, with nothing that needs periodically summarizing. Individual articles still publish immediately either way (see priorityQueue.ts); this only gates eventsRecap.ts's periodic wrap-up. */
|
||||||
cadenceTime: string | null;
|
recapIntervalHours: 1 | 3 | 6 | 12 | 24 | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** Collapses into the "More »" nav tab instead of getting its own top-level tab — same idea as Category.isSpillover. */
|
||||||
|
isSpillover: boolean;
|
||||||
retentionOverrideDays: number | null;
|
retentionOverrideDays: number | null;
|
||||||
lastRecapAt: string | null;
|
lastRecapAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -265,7 +267,6 @@ export interface Bookmark {
|
|||||||
|
|
||||||
export interface GlobalSettings {
|
export interface GlobalSettings {
|
||||||
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
||||||
defaultPollIntervalMinutes: number;
|
|
||||||
holdBeforePublishMinutes: number;
|
holdBeforePublishMinutes: number;
|
||||||
tagDedupThreshold: number;
|
tagDedupThreshold: number;
|
||||||
tagExpiryDays: number;
|
tagExpiryDays: number;
|
||||||
@@ -280,6 +281,15 @@ export interface GlobalSettings {
|
|||||||
fxtwitterBaseUrl: string;
|
fxtwitterBaseUrl: string;
|
||||||
/** How Telegram message media (attached photos/videos, channel avatars) is served — see pipeline/publish.ts's resolveTelegramMedia. No "direct" option: Telegram has no public hotlinkable media URL, bytes only come from the authenticated MTProto session. */
|
/** How Telegram message media (attached photos/videos, channel avatars) is served — see pipeline/publish.ts's resolveTelegramMedia. No "direct" option: Telegram has no public hotlinkable media URL, bytes only come from the authenticated MTProto session. */
|
||||||
telegramMediaMode: 'self-host' | 'proxy';
|
telegramMediaMode: 'self-host' | 'proxy';
|
||||||
|
/** Per-widget enable flags — see admin/settings' consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers (scheduler.ts) are gated on these too, not just sidebar visibility; Bookmarks has no poller so its flag only affects the sidebar. */
|
||||||
|
widgets: {
|
||||||
|
weather: boolean;
|
||||||
|
stocks: boolean;
|
||||||
|
bookmarks: boolean;
|
||||||
|
poe2: boolean;
|
||||||
|
};
|
||||||
|
/** Sidebar widget display order, admin-sortable via the Widgets tab's up/down arrows — mirrored exactly by Sidebar.svelte. */
|
||||||
|
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
||||||
retention: {
|
retention: {
|
||||||
publishedArticleMaxAgeDays: number | null;
|
publishedArticleMaxAgeDays: number | null;
|
||||||
rawItemMaxAgeDays: number | null;
|
rawItemMaxAgeDays: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Homefeed program review — July 2026
|
||||||
|
|
||||||
|
Scope: dead code, inefficiency, and UI/system consistency across the whole app
|
||||||
|
(backend + frontend, public site + admin panel), as requested after a long run of
|
||||||
|
incremental feature work. Findings below are left as originally written (historical
|
||||||
|
record); the "Resolved since this report" section tracks what's since been acted on.
|
||||||
|
|
||||||
|
## Resolved since this report
|
||||||
|
|
||||||
|
- **1.1** `ArticleCard.svelte` — deleted.
|
||||||
|
- **1.2** All five zero-call-site exports (`getTagsByIds`, `unclusteredItemsForSources`,
|
||||||
|
`itemsByCluster`, `deleteAllContentItems`, `latestArticleInThread`) — removed.
|
||||||
|
- **1.3** `poe2_primary_currency_name` — dropped from the schema entirely (CREATE TABLE
|
||||||
|
literal + the ALTER TABLE migration line), not just left as an inert column.
|
||||||
|
- **Inefficiencies** — the unconditional `stooqToYahooSymbols` startup rewrite
|
||||||
|
(long-since a no-op) was deleted outright; `settings.ts:updateSettings` now binds
|
||||||
|
named `$column` parameters instead of 33 positional `?`s, removing the
|
||||||
|
reorder-and-silently-corrupt risk; `runPassthroughCycle`/`runSynthesisCycle`'s
|
||||||
|
direct-publish logic now share one `publishItemsDirect` helper; the per-item
|
||||||
|
`sourcesDb.getSource()` N+1 in the synthesis tick was replaced with a single
|
||||||
|
`sourcesDb.listSources()` map reused for both the direct-publish partition and each
|
||||||
|
item's category-rank lookup. (`settings.ts`'s remaining structural size and
|
||||||
|
`migrate()`'s 410-line mixed-idiom growth were left as-is — real fixes but a larger
|
||||||
|
refactor than this pass warranted.)
|
||||||
|
- **`'custom'` source type** — removed entirely: dropped from the `Source['type']`
|
||||||
|
union (backend + frontend), the poller's adapter map, and the admin add-source
|
||||||
|
dropdown. It had no adapter of its own (silently aliased to the API adapter) and no
|
||||||
|
admin-facing purpose distinct from `api`.
|
||||||
|
|
||||||
|
Everything above was verified via `tsc --noEmit`/`svelte-check` (0 errors on both
|
||||||
|
sides), a full build of both packages, and a live smoke test against a fresh DB
|
||||||
|
(migrate() + a settings roundtrip through the new named-param query, confirming the
|
||||||
|
column is gone and unrelated fields are untouched).
|
||||||
|
|
||||||
|
See the **staleness/refresh recommendation** delivered separately in conversation —
|
||||||
|
that item was discussed, not changed, per the request to advise rather than implement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dead code
|
||||||
|
|
||||||
|
### 1.1 Frontend
|
||||||
|
- `frontend/src/lib/components/ArticleCard.svelte` — zero references anywhere under
|
||||||
|
`frontend/src` (confirmed via `grep -rn "ArticleCard" frontend/src`, zero matches
|
||||||
|
outside its own file). Superseded by `ArticleListRow.svelte`. Safe to delete.
|
||||||
|
|
||||||
|
### 1.2 Backend
|
||||||
|
Exported functions with no call sites found anywhere in `backend/src`:
|
||||||
|
- `backend/src/storage/db/tags.ts:31` — `getTagsByIds`
|
||||||
|
- `backend/src/storage/db/contentItems.ts:72` — `unclusteredItemsForSources`
|
||||||
|
- `backend/src/storage/db/contentItems.ts:96` — `itemsByCluster`
|
||||||
|
- `backend/src/storage/db/contentItems.ts:121` — `deleteAllContentItems`
|
||||||
|
- `backend/src/storage/db/articles.ts:146` — `latestArticleInThread`
|
||||||
|
|
||||||
|
### 1.3 Dead settings field
|
||||||
|
- `global_settings.poe2_primary_currency_name` — left over from before PoE2 moved to
|
||||||
|
per-pair tracking (base/quote currencies with a directly-computed rate, no longer
|
||||||
|
"everything quoted in one reference currency"). The column is harmless — dropping it
|
||||||
|
would mean a SQLite table rebuild for one nullable text column, not worth doing on
|
||||||
|
its own. `GlobalSettings.poe2` on the TS side no longer exposes it, so it's already
|
||||||
|
fully inert; only the raw DB column remains as an artifact.
|
||||||
|
|
||||||
|
### 1.4 Over-exported (not dead, but worth a look)
|
||||||
|
These are only ever called from within their own module, so exporting them invites
|
||||||
|
cross-module coupling that hasn't happened yet but could:
|
||||||
|
- `backend/src/storage/db/sources.ts:27` — `listEnabledSources`
|
||||||
|
- `backend/src/storage/db/events.ts:46` — `getEvent`
|
||||||
|
- `backend/src/clustering.ts:9` — `strictnessToThreshold`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Inefficiencies
|
||||||
|
|
||||||
|
- **`backend/src/storage/db/index.ts:375-382`** — the `stooqToYahooSymbols` stock
|
||||||
|
symbol rewrite runs unconditionally on every server start, forever, with no
|
||||||
|
skip-guard once it's already been applied to a given database. Same category of
|
||||||
|
issue as the unconditional `DROP TABLE IF EXISTS admin_users` / `admin_sessions` at
|
||||||
|
lines 25-26 — both re-run one-time work on every boot instead of gating it behind a
|
||||||
|
"have I already done this" check.
|
||||||
|
- **`backend/src/storage/db/settings.ts:updateSettings` (lines 77-109)** — 33
|
||||||
|
positional `?` SQL parameters matched to 33 arguments passed to `.run()` in the same
|
||||||
|
order, with nothing enforcing that the two lists stay in sync if either is edited.
|
||||||
|
Not currently broken, but a single misordered edit here would silently write the
|
||||||
|
wrong value into the wrong column with no type error to catch it.
|
||||||
|
- Synthesis's 60-second tick calls `sourcesDb.listSources()` on every iteration, which
|
||||||
|
is redundant if the source list hasn't changed since the last tick — a small,
|
||||||
|
low-priority optimization.
|
||||||
|
- `runPassthroughCycle` and `runSynthesisCycle`'s "direct publish" halves are
|
||||||
|
near-duplicate copy-pasted logic rather than a shared helper.
|
||||||
|
- The `migrate()` function (410 lines) mixes four different guarded/unguarded
|
||||||
|
backfill idioms with no schema-version tracking, which makes it hard to tell at a
|
||||||
|
glance whether a given block still needs to run or is a no-op on any DB that's
|
||||||
|
already current.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI / layout consistency audit (public site)
|
||||||
|
|
||||||
|
- **`/weather` is not reachable from the top nav** — it's only linkable via the
|
||||||
|
sidebar widget, unlike every other route (home, categories, events, `/more`), which
|
||||||
|
are all in top nav.
|
||||||
|
- **Sidebar widgets don't share a component.** Weather, Stocks, and PoE2 each
|
||||||
|
hand-roll their own `.widget` shell but do converge on the same `.head` +
|
||||||
|
interval-tag pattern (`today`, `24h`, etc.). `BookmarksWidget` is the outlier: it
|
||||||
|
lacks that `.head`/interval wrapper the other three use, so it looks structurally
|
||||||
|
different from its neighbors in the same sidebar.
|
||||||
|
- Confirmed: the PoE2 icon-removal and 24h-only simplification from earlier this
|
||||||
|
session are fully complete on the frontend (verified via grep — no remaining icon
|
||||||
|
references).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI / layout consistency audit (admin panel)
|
||||||
|
|
||||||
|
- **Near-duplicate CRUD tabs.** `StocksTab.svelte` and `BookmarksTab.svelte` are
|
||||||
|
close to byte-identical in structure (add row, list, delete button). `Poe2Tab.svelte`
|
||||||
|
is a variant of the same pattern; `WeatherTab.svelte` uses a different
|
||||||
|
settings-form pattern entirely. None of the three share a common component, so a fix
|
||||||
|
to one (e.g. the null/`—` display bug fixed in Poe2Tab this session) doesn't
|
||||||
|
propagate to the others even where the same bug class could exist.
|
||||||
|
- **Admin tab list has an implicit, unlabeled grouping** — Sources & Content /
|
||||||
|
Sidebar widgets (Weather, Stocks, PoE2, Bookmarks) / Integrations (Telegram) /
|
||||||
|
System (Logs, Settings) — but the tab bar renders them as one flat list with no
|
||||||
|
visual separation or heading, so the grouping only exists in the code's mental
|
||||||
|
model, not on screen.
|
||||||
|
- **`/admin` itself is a dead redirect-only render** — it exists only to bounce to
|
||||||
|
`/admin/settings` or another tab, with no content of its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cross-system / type consistency findings
|
||||||
|
|
||||||
|
### 5.1 Duplicated types
|
||||||
|
- `WeatherHourEntry`, `WeatherDayEntry`, `WeatherCurrentConditions`, and
|
||||||
|
`WeatherAlert` are byte-identical between `frontend/src/lib/types.ts` and
|
||||||
|
`adminTypes.ts` — genuine duplication, not just similar shapes.
|
||||||
|
- `Category` and `CategoryPriority` are same-shape-but-differently-named — worth
|
||||||
|
confirming whether that's intentional (different semantic roles) or just drift from
|
||||||
|
copy-paste.
|
||||||
|
- Backend-side: `Tweet`/Telegram-message inline shapes are duplicated between
|
||||||
|
`ContentItem` and `MergedArticle` in `backend/src/storage/db/types.ts` (lines
|
||||||
|
78-108 vs. 125-143) rather than being extracted into a shared named interface.
|
||||||
|
|
||||||
|
### 5.2 CSS token drift
|
||||||
|
- A `12px` "card radius" value is hardcoded across roughly 20 files instead of using
|
||||||
|
the existing `var(--radius)` token (which is 8px) — meaning cards and the
|
||||||
|
token-driven `--radius` elements don't actually share one visual language despite
|
||||||
|
looking like they should.
|
||||||
|
- `LogsTab.svelte:99-101,157` hardcodes `#a8710f`/`#fff` for the "warn" log-level
|
||||||
|
color rather than using a token, unlike "err" which correctly uses `--text-danger`.
|
||||||
|
There's no `--text-warning` token defined to match.
|
||||||
|
|
||||||
|
### 5.3 Ingestion pipeline (traced end-to-end)
|
||||||
|
Flow: adapter → poller → priority queue → clustering/synthesis (or direct-publish
|
||||||
|
bypass) → articles table → public feed. The telegram/nitter/youtube direct-publish
|
||||||
|
bypass (skipping clustering/synthesis entirely for those source types) is coherent
|
||||||
|
and already explicitly commented as intentional — not an oversight, despite looking
|
||||||
|
unusual at first glance.
|
||||||
|
|
||||||
|
One asymmetry does look like a genuine oversight rather than a documented design
|
||||||
|
choice: the `'custom'` source type is excluded from both
|
||||||
|
`FOLLOWS_LINK_FOR_FULL_ARTICLE` (`backend/src/poller.ts:24`, which only lists
|
||||||
|
`['rss', 'api']`) and from `directPublishSourceIds` in
|
||||||
|
`backend/src/queue/priorityQueue.ts` — with no comment anywhere explaining why
|
||||||
|
`custom` is treated differently from `rss`, which otherwise behaves the same way a
|
||||||
|
`custom` source presumably would.
|
||||||
|
|
||||||
|
### 5.4 Frontend staleness beyond the sidebar
|
||||||
|
This session added a 5-minute `invalidate('app:sidebar')` timer
|
||||||
|
(`frontend/src/routes/+layout.svelte` / `+layout.ts`) so the sidebar widgets stay
|
||||||
|
fresh in a tab left open. That is the **only** refresh mechanism anywhere in the
|
||||||
|
frontend. Every other route — home (`/`), `/category/[name]`, `/event/[id]`,
|
||||||
|
`/article/[id]`, `/more`, and notably **`/admin/settings`** — is a pure one-shot
|
||||||
|
`load()` with no refresh at all. This is fine for content pages (a stale article list
|
||||||
|
is a minor nuisance, fixed by navigating), but is a real staleness risk specifically
|
||||||
|
for `/admin/settings`: an admin who leaves that page open to watch Logs, AI-status,
|
||||||
|
or Telegram connection status will see indefinitely stale state with no signal that
|
||||||
|
it's stale.
|
||||||
|
|
||||||
|
### 5.5 Public API field leak (most concrete/actionable finding)
|
||||||
|
`backend/src/api/public.ts` — the categories, bookmarks, and events routes all
|
||||||
|
explicitly filter their response shape down to public-safe fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lines ~48-51, 57-61, 68-72 — filtered
|
||||||
|
app.get('/api/events', async () => eventsDb.listEvents().filter(e => e.active).map(({ id, name, ... }) => ({ id, name, ... })));
|
||||||
|
```
|
||||||
|
|
||||||
|
But stocks and PoE2 do not:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// line 66
|
||||||
|
app.get('/api/stocks', async () => stocksDb.listStockTickers());
|
||||||
|
|
||||||
|
// lines 74-77
|
||||||
|
app.get('/api/poe2', async () => {
|
||||||
|
const { leagueName, updatedAt } = settingsDb.getSettings().poe2;
|
||||||
|
return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`listStockTickers()` and `listWatchlist()` return the full DB row, including
|
||||||
|
`lastError` and `lastPolledAt` — internal poller-diagnostic fields with no reason to
|
||||||
|
be visible to an unauthenticated visitor. This is inconsistent with how every other
|
||||||
|
public route in the same file handles the same concern, and is a straightforward fix:
|
||||||
|
map each entry down to its public-facing fields the same way categories/bookmarks/
|
||||||
|
events already do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: surveyed and found fine
|
||||||
|
|
||||||
|
Recorded here so these don't get re-litigated in a future review:
|
||||||
|
|
||||||
|
- **Logging** — a single shared `logger`, consistent lowercase tags, and only two
|
||||||
|
`console.log` calls outside of it, both explicitly commented as intentional
|
||||||
|
exceptions (in `index.ts`).
|
||||||
|
- **API route organization** — `admin.ts` (329 lines / 38 routes) is cleanly
|
||||||
|
sectioned into 11 logical groups; `public.ts` (78 lines / 9 routes) has no
|
||||||
|
duplicated logic. 52 routes total, no redundant endpoints found.
|
||||||
|
- **Scheduler cadences** — weather (45m), stocks (15m), and PoE2 (1h) all poll
|
||||||
|
immediately on start by design; the ingestion poll/synthesis loop (60s) and
|
||||||
|
retention sweep (1h) deliberately don't, also by design. No redundant external
|
||||||
|
fetches were found across the whole scheduler table.
|
||||||
|
- **Module organization pattern** — the `client.ts` (raw external API) +
|
||||||
|
`poller.ts` (orchestration) split is followed consistently for weather, stocks, and
|
||||||
|
PoE2. Telegram is the one deliberate exception (a stateful GramJS client/credentials
|
||||||
|
singleton, ingested through the generic per-source `ingestion/poller.ts` rather than
|
||||||
|
`queue/scheduler.ts`) — this is explicitly commented in the code as an intentional
|
||||||
|
divergence, not an inconsistency to fix.
|
||||||
@@ -118,9 +118,15 @@ export interface AdminPoe2Settings {
|
|||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminWidgetsEnabled {
|
||||||
|
weather: boolean;
|
||||||
|
stocks: boolean;
|
||||||
|
bookmarks: boolean;
|
||||||
|
poe2: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminSettings {
|
export interface AdminSettings {
|
||||||
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
||||||
defaultPollIntervalMinutes: number;
|
|
||||||
holdBeforePublishMinutes: number;
|
holdBeforePublishMinutes: number;
|
||||||
tagDedupThreshold: number;
|
tagDedupThreshold: number;
|
||||||
tagExpiryDays: number;
|
tagExpiryDays: number;
|
||||||
@@ -132,6 +138,8 @@ export interface AdminSettings {
|
|||||||
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
|
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
|
||||||
fxtwitterBaseUrl: string;
|
fxtwitterBaseUrl: string;
|
||||||
telegramMediaMode: 'self-host' | 'proxy';
|
telegramMediaMode: 'self-host' | 'proxy';
|
||||||
|
widgets: AdminWidgetsEnabled;
|
||||||
|
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
||||||
retention: RetentionSettings;
|
retention: RetentionSettings;
|
||||||
categoryPriority: CategoryPriority[];
|
categoryPriority: CategoryPriority[];
|
||||||
weather: AdminWeatherSettings;
|
weather: AdminWeatherSettings;
|
||||||
@@ -141,7 +149,7 @@ export interface AdminSettings {
|
|||||||
export interface AdminSource {
|
export interface AdminSource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter' | 'custom';
|
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter';
|
||||||
category: string[];
|
category: string[];
|
||||||
url: string;
|
url: string;
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
@@ -159,9 +167,10 @@ export interface AdminTrackedEvent {
|
|||||||
sourceIds: string[];
|
sourceIds: string[];
|
||||||
/** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds". */
|
/** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds". */
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
|
/** Hours between AI recaps, or null to turn recaps off entirely for this item. */
|
||||||
cadenceTime: string | null;
|
recapIntervalHours: 1 | 3 | 6 | 12 | 24 | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
isSpillover: boolean;
|
||||||
retentionOverrideDays: number | null;
|
retentionOverrideDays: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getBackendUrl } from './config';
|
import { getBackendUrl } from './config';
|
||||||
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data } from './types';
|
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from './types';
|
||||||
|
|
||||||
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
||||||
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
|
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
|
||||||
@@ -57,3 +57,7 @@ export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
|
|||||||
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
|
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
|
||||||
return get<Poe2Data>('/api/poe2', fetchFn);
|
return get<Poe2Data>('/api/poe2', fetchFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWidgetsEnabled(fetchFn?: typeof fetch): Promise<WidgetsEnabled> {
|
||||||
|
return get<WidgetsEnabled>('/api/widgets', fetchFn);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { MergedArticle } from '$lib/types';
|
|
||||||
import { timeAgo } from '$lib/format';
|
|
||||||
|
|
||||||
let { article }: { article: MergedArticle } = $props();
|
|
||||||
|
|
||||||
const sourceLabel = $derived(
|
|
||||||
article.sourceCount > 1
|
|
||||||
? `${article.sourceCount} sources`
|
|
||||||
: (article.sources[0]?.sourceName ?? 'Single source')
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a class="card" href={`/article/${article.id}`}>
|
|
||||||
{#if article.heroImage}
|
|
||||||
<img class="thumb" src={article.heroImage.url} alt="" loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<div class="thumb placeholder"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="meta">
|
|
||||||
{#if article.video}
|
|
||||||
<i class="tag-icon">▶</i> Video
|
|
||||||
{:else if article.sourceCount > 1}
|
|
||||||
<i class="tag-icon">⇄</i> {sourceLabel}
|
|
||||||
{:else}
|
|
||||||
{sourceLabel}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="title">{article.title}</div>
|
|
||||||
<div class="sub">{timeAgo(article.publishedAt)}</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
display: block;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.card:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.card:hover .title {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.thumb {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 10;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: var(--surface-1);
|
|
||||||
}
|
|
||||||
.thumb.placeholder {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-accent);
|
|
||||||
margin-bottom: 3px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.tag-icon {
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.35;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.sub {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// Same collapsed-by-default shell as WidgetSection, minus the enable/disable
|
||||||
|
// checkbox — for sections that are always "on" (Category priority, Sources) and
|
||||||
|
// just need to stay out of the way until expanded.
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
defaultExpanded = false,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let expanded = $state(defaultExpanded);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<button class="section-head" onclick={() => (expanded = !expanded)} aria-expanded={expanded}>
|
||||||
|
<span class="chevron" class:open={expanded}>▸</span>
|
||||||
|
<span class="title">{title}</span>
|
||||||
|
</button>
|
||||||
|
{#if expanded}
|
||||||
|
<div class="section-body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.section-body {
|
||||||
|
padding: 14px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0.5px solid var(--border);
|
||||||
|
}
|
||||||
|
.section-body > :global(*:first-child) {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
|
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
|
||||||
let events = $state([...initial]);
|
let events = $state([...initial]);
|
||||||
let showAdd = $state(false);
|
let showAdd = $state(false);
|
||||||
let newEvent = $state({ name: '', cadence: 'daily' as AdminTrackedEvent['cadence'], cadenceTime: '18:00' });
|
// Off by default — plenty of items (a commit feed, a torrent feed) exist just to
|
||||||
|
// organize sources under one nav entry and never need an AI recap.
|
||||||
|
let newEvent = $state({ name: '', recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'] });
|
||||||
|
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
function emptyEditForm() {
|
function emptyEditForm() {
|
||||||
@@ -14,17 +16,13 @@
|
|||||||
description: '',
|
description: '',
|
||||||
sourceIdSet: new Set<string>(),
|
sourceIdSet: new Set<string>(),
|
||||||
keywordsText: '',
|
keywordsText: '',
|
||||||
cadence: 'daily' as AdminTrackedEvent['cadence'],
|
recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'],
|
||||||
cadenceTime: '18:00',
|
isSpillover: false,
|
||||||
retentionOverrideDays: null as number | null
|
retentionOverrideDays: null as number | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let editForm = $state(emptyEditForm());
|
let editForm = $state(emptyEditForm());
|
||||||
|
|
||||||
function sourceNames(ids: string[]) {
|
|
||||||
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!newEvent.name) return;
|
if (!newEvent.name) return;
|
||||||
const created = await addEvent({
|
const created = await addEvent({
|
||||||
@@ -32,12 +30,12 @@
|
|||||||
description: '',
|
description: '',
|
||||||
sourceIds: [],
|
sourceIds: [],
|
||||||
keywords: [],
|
keywords: [],
|
||||||
cadence: newEvent.cadence,
|
recapIntervalHours: newEvent.recapIntervalHours,
|
||||||
cadenceTime: newEvent.cadence === 'daily' ? newEvent.cadenceTime : null,
|
isSpillover: false,
|
||||||
retentionOverrideDays: null
|
retentionOverrideDays: null
|
||||||
});
|
});
|
||||||
events = [...events, created];
|
events = [...events, created];
|
||||||
newEvent = { name: '', cadence: 'daily', cadenceTime: '18:00' };
|
newEvent = { name: '', recapIntervalHours: null };
|
||||||
showAdd = false;
|
showAdd = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +56,8 @@
|
|||||||
description: event.description,
|
description: event.description,
|
||||||
sourceIdSet: new Set(event.sourceIds),
|
sourceIdSet: new Set(event.sourceIds),
|
||||||
keywordsText: event.keywords.join(', '),
|
keywordsText: event.keywords.join(', '),
|
||||||
cadence: event.cadence,
|
recapIntervalHours: event.recapIntervalHours,
|
||||||
cadenceTime: event.cadenceTime ?? '18:00',
|
isSpillover: event.isSpillover,
|
||||||
retentionOverrideDays: event.retentionOverrideDays
|
retentionOverrideDays: event.retentionOverrideDays
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -86,8 +84,8 @@
|
|||||||
description: editForm.description,
|
description: editForm.description,
|
||||||
sourceIds: [...editForm.sourceIdSet],
|
sourceIds: [...editForm.sourceIdSet],
|
||||||
keywords,
|
keywords,
|
||||||
cadence: editForm.cadence,
|
recapIntervalHours: editForm.recapIntervalHours,
|
||||||
cadenceTime: editForm.cadence === 'daily' ? editForm.cadenceTime : null,
|
isSpillover: editForm.isSpillover,
|
||||||
retentionOverrideDays: editForm.retentionOverrideDays
|
retentionOverrideDays: editForm.retentionOverrideDays
|
||||||
});
|
});
|
||||||
events = events.map((e) => (e.id === editingId ? updated : e));
|
events = events.map((e) => (e.id === editingId ? updated : e));
|
||||||
@@ -96,22 +94,31 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span class="count">{events.length} tracked events</span>
|
<span class="count">{events.length} tracked items</span>
|
||||||
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New event</button>
|
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New item</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showAdd}
|
{#if showAdd}
|
||||||
<div class="add-panel">
|
<div class="add-panel">
|
||||||
<div class="add-grid">
|
<div class="add-grid">
|
||||||
<input placeholder="Event name (e.g. Iran war)" bind:value={newEvent.name} />
|
<input placeholder="Item name (e.g. Iran war)" bind:value={newEvent.name} />
|
||||||
<select bind:value={newEvent.cadence}>
|
</div>
|
||||||
<option value="continuous">Continuous</option>
|
<div class="cadence-block">
|
||||||
<option value="daily">Daily</option>
|
<div class="field-label">Recap cadence</div>
|
||||||
<option value="hourly">Hourly</option>
|
<select bind:value={newEvent.recapIntervalHours}>
|
||||||
|
<option value={null}>Off — no recap</option>
|
||||||
|
<option value={1}>Every hour</option>
|
||||||
|
<option value={3}>Every 3 hours</option>
|
||||||
|
<option value={6}>Every 6 hours</option>
|
||||||
|
<option value={12}>Every 12 hours</option>
|
||||||
|
<option value={24}>Every 24 hours</option>
|
||||||
</select>
|
</select>
|
||||||
{#if newEvent.cadence === 'daily'}
|
<p class="hint">
|
||||||
<input type="text" placeholder="18:00" bind:value={newEvent.cadenceTime} />
|
How often an AI recap is written summarizing this item's coverage, on top of its
|
||||||
{/if}
|
individual articles (which publish immediately either way). Off by default — leave it
|
||||||
|
off for something you're just organizing under its own nav entry (a commit feed, a
|
||||||
|
torrent feed) with nothing that needs summarizing.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="add-actions">
|
<div class="add-actions">
|
||||||
<button onclick={() => (showAdd = false)}>Cancel</button>
|
<button onclick={() => (showAdd = false)}>Cancel</button>
|
||||||
@@ -126,15 +133,7 @@
|
|||||||
{#if editingId === event.id}
|
{#if editingId === event.id}
|
||||||
<div class="edit-panel">
|
<div class="edit-panel">
|
||||||
<div class="add-grid">
|
<div class="add-grid">
|
||||||
<input placeholder="Event name" bind:value={editForm.name} />
|
<input placeholder="Item name" bind:value={editForm.name} />
|
||||||
<select bind:value={editForm.cadence}>
|
|
||||||
<option value="continuous">Continuous</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="hourly">Hourly</option>
|
|
||||||
</select>
|
|
||||||
{#if editForm.cadence === 'daily'}
|
|
||||||
<input type="text" placeholder="18:00" bind:value={editForm.cadenceTime} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<textarea placeholder="Description (optional)" bind:value={editForm.description} rows="2"></textarea>
|
<textarea placeholder="Description (optional)" bind:value={editForm.description} rows="2"></textarea>
|
||||||
|
|
||||||
@@ -156,10 +155,33 @@
|
|||||||
<input placeholder="e.g. 🇮🇷, Tehran, IRGC" bind:value={editForm.keywordsText} />
|
<input placeholder="e.g. 🇮🇷, Tehran, IRGC" bind:value={editForm.keywordsText} />
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Comma-separated words, phrases, or emoji — only items from the sources above whose
|
Comma-separated words, phrases, or emoji — only items from the sources above whose
|
||||||
title/summary/body contain at least one qualify for this event's recap. Leave blank to
|
title/summary/body contain at least one qualify for this item's recap. Leave blank to
|
||||||
include everything from the assigned sources.
|
include everything from the assigned sources.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="cadence-block">
|
||||||
|
<div class="field-label">Recap cadence</div>
|
||||||
|
<select bind:value={editForm.recapIntervalHours}>
|
||||||
|
<option value={null}>Off — no recap</option>
|
||||||
|
<option value={1}>Every hour</option>
|
||||||
|
<option value={3}>Every 3 hours</option>
|
||||||
|
<option value={6}>Every 6 hours</option>
|
||||||
|
<option value={12}>Every 12 hours</option>
|
||||||
|
<option value={24}>Every 24 hours</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">
|
||||||
|
How often an AI recap is written summarizing this item's coverage, on top of its
|
||||||
|
individual articles (which publish immediately either way). Off by default — leave
|
||||||
|
it off for something you're just organizing under its own nav entry (a commit feed,
|
||||||
|
a torrent feed) with nothing that needs summarizing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="spillover-toggle edit-spillover">
|
||||||
|
<input type="checkbox" bind:checked={editForm.isSpillover} />
|
||||||
|
Show in "More »" instead of its own nav tab
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="add-actions">
|
<div class="add-actions">
|
||||||
<button onclick={cancelEdit}>Cancel</button>
|
<button onclick={cancelEdit}>Cancel</button>
|
||||||
<button class="primary" onclick={saveEdit}>Save</button>
|
<button class="primary" onclick={saveEdit}>Save</button>
|
||||||
@@ -170,12 +192,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="name">{event.name}</div>
|
<div class="name">{event.name}</div>
|
||||||
<div class="sub">
|
<div class="sub">
|
||||||
{sourceNames(event.sourceIds)}
|
{event.sourceIds.length} source{event.sourceIds.length === 1 ? '' : 's'} active
|
||||||
{#if event.keywords.length > 0}
|
|
||||||
· matching {event.keywords.map((k) => `"${k}"`).join(', ')}
|
|
||||||
{/if}
|
|
||||||
·
|
|
||||||
{event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" class:active={event.active} onclick={() => toggleActive(event)} role="button" tabindex="0">
|
<span class="badge" class:active={event.active} onclick={() => toggleActive(event)} role="button" tabindex="0">
|
||||||
@@ -314,4 +331,27 @@
|
|||||||
.edit-panel .hint {
|
.edit-panel .hint {
|
||||||
margin: 6px 0 10px;
|
margin: 6px 0 10px;
|
||||||
}
|
}
|
||||||
|
.cadence-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 0.5px solid var(--border);
|
||||||
|
}
|
||||||
|
.cadence-block .hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.edit-spillover {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.spillover-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.spillover-toggle input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AdminSettings } from '$lib/adminTypes';
|
import type { AdminSettings, AdminSource } from '$lib/adminTypes';
|
||||||
import { updateSettings, createCategory, deleteCategory } from '$lib/adminApi';
|
import { updateSettings, createCategory, deleteCategory } from '$lib/adminApi';
|
||||||
import SaveStatus from './SaveStatus.svelte';
|
import SaveStatus from './SaveStatus.svelte';
|
||||||
|
import CollapsibleSection from './CollapsibleSection.svelte';
|
||||||
|
import SourcesTab from './SourcesTab.svelte';
|
||||||
|
|
||||||
let { settings }: { settings: AdminSettings } = $props();
|
let { settings, sources }: { settings: AdminSettings; sources: AdminSource[] } = $props();
|
||||||
|
|
||||||
let local = $state({ ...settings, categoryPriority: [...settings.categoryPriority] });
|
let local = $state({ ...settings, categoryPriority: [...settings.categoryPriority] });
|
||||||
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
try {
|
try {
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
mergeStrictness: local.mergeStrictness,
|
mergeStrictness: local.mergeStrictness,
|
||||||
defaultPollIntervalMinutes: local.defaultPollIntervalMinutes,
|
|
||||||
holdBeforePublishMinutes: local.holdBeforePublishMinutes,
|
holdBeforePublishMinutes: local.holdBeforePublishMinutes,
|
||||||
followUpMinHoursSinceLast: local.followUpMinHoursSinceLast,
|
followUpMinHoursSinceLast: local.followUpMinHoursSinceLast,
|
||||||
followUpMinNewSources: local.followUpMinNewSources,
|
followUpMinNewSources: local.followUpMinNewSources,
|
||||||
@@ -89,78 +90,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel">
|
<CollapsibleSection title="Category priority">
|
||||||
<div class="head">
|
|
||||||
<span class="panel-title">Merge strictness</span>
|
|
||||||
<SaveStatus {status} />
|
|
||||||
</div>
|
|
||||||
<p class="hint">How similar articles must be before they're combined into one story.</p>
|
|
||||||
<div class="slider-row">
|
|
||||||
<span class="end">Loose</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
step="1"
|
|
||||||
bind:value={local.mergeStrictness}
|
|
||||||
oninput={scheduleSave}
|
|
||||||
/>
|
|
||||||
<span class="end">Strict</span>
|
|
||||||
<span class="value">{local.mergeStrictness}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="panel">
|
|
||||||
<span class="panel-title">Poll interval</span>
|
|
||||||
<p class="hint">How often each source is checked for new items.</p>
|
|
||||||
<select bind:value={local.defaultPollIntervalMinutes} onchange={scheduleSave}>
|
|
||||||
<option value={5}>Every 5 minutes</option>
|
|
||||||
<option value={15}>Every 15 minutes</option>
|
|
||||||
<option value={60}>Every hour</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<span class="panel-title">Hold before publish</span>
|
|
||||||
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
|
|
||||||
<select bind:value={local.holdBeforePublishMinutes} onchange={scheduleSave}>
|
|
||||||
<option value={0}>Publish immediately</option>
|
|
||||||
<option value={30}>Wait 30 minutes</option>
|
|
||||||
<option value={120}>Wait 2 hours</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<span class="panel-title">Follow-up articles</span>
|
|
||||||
<p class="hint">
|
|
||||||
Instead of editing a published article, a distinct follow-up is created once enough new
|
|
||||||
corroborating sources arrive after enough time has passed.
|
|
||||||
</p>
|
|
||||||
<div class="grid-2">
|
|
||||||
<div>
|
|
||||||
<label class="field-label" for="followup-hours">Minimum time since last article</label>
|
|
||||||
<select id="followup-hours" bind:value={local.followUpMinHoursSinceLast} onchange={scheduleSave}>
|
|
||||||
<option value={1}>1 hour</option>
|
|
||||||
<option value={6}>6 hours</option>
|
|
||||||
<option value={12}>12 hours</option>
|
|
||||||
<option value={24}>24 hours</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="field-label" for="followup-sources">Minimum new sources</label>
|
|
||||||
<select id="followup-sources" bind:value={local.followUpMinNewSources} onchange={scheduleSave}>
|
|
||||||
<option value={1}>1</option>
|
|
||||||
<option value={2}>2</option>
|
|
||||||
<option value={3}>3</option>
|
|
||||||
<option value={4}>4</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<span class="panel-title">Category priority</span>
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
|
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
|
||||||
categories just wait longer when the queue is busy. This list also drives the site's nav —
|
categories just wait longer when the queue is busy. This list also drives the site's nav —
|
||||||
@@ -225,6 +155,69 @@
|
|||||||
{addingCategory ? 'Adding…' : '+ Add'}
|
{addingCategory ? 'Adding…' : '+ Add'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection title="Sources">
|
||||||
|
<SourcesTab {sources} categories={local.categoryPriority} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="head">
|
||||||
|
<span class="panel-title">Merge strictness</span>
|
||||||
|
<SaveStatus {status} />
|
||||||
|
</div>
|
||||||
|
<p class="hint">How similar articles must be before they're combined into one story.</p>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="end">Loose</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
step="1"
|
||||||
|
bind:value={local.mergeStrictness}
|
||||||
|
oninput={scheduleSave}
|
||||||
|
/>
|
||||||
|
<span class="end">Strict</span>
|
||||||
|
<span class="value">{local.mergeStrictness}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<span class="panel-title">Hold before publish</span>
|
||||||
|
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
|
||||||
|
<select bind:value={local.holdBeforePublishMinutes} onchange={scheduleSave}>
|
||||||
|
<option value={0}>Publish immediately</option>
|
||||||
|
<option value={30}>Wait 30 minutes</option>
|
||||||
|
<option value={120}>Wait 2 hours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<span class="panel-title">Follow-up articles</span>
|
||||||
|
<p class="hint">
|
||||||
|
Instead of editing a published article, a distinct follow-up is created once enough new
|
||||||
|
corroborating sources arrive after enough time has passed.
|
||||||
|
</p>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="followup-hours">Minimum time since last article</label>
|
||||||
|
<select id="followup-hours" bind:value={local.followUpMinHoursSinceLast} onchange={scheduleSave}>
|
||||||
|
<option value={1}>1 hour</option>
|
||||||
|
<option value={6}>6 hours</option>
|
||||||
|
<option value={12}>12 hours</option>
|
||||||
|
<option value={24}>24 hours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="followup-sources">Minimum new sources</label>
|
||||||
|
<select id="followup-sources" bind:value={local.followUpMinNewSources} onchange={scheduleSave}>
|
||||||
|
<option value={1}>1</option>
|
||||||
|
<option value={2}>2</option>
|
||||||
|
<option value={3}>3</option>
|
||||||
|
<option value={4}>4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|||||||
@@ -196,7 +196,6 @@
|
|||||||
<option value="telegram">Telegram</option>
|
<option value="telegram">Telegram</option>
|
||||||
<option value="youtube">YouTube</option>
|
<option value="youtube">YouTube</option>
|
||||||
<option value="nitter">Nitter</option>
|
<option value="nitter">Nitter</option>
|
||||||
<option value="custom">Custom</option>
|
|
||||||
</select>
|
</select>
|
||||||
{#if form.type === 'youtube'}
|
{#if form.type === 'youtube'}
|
||||||
<input placeholder="Channel URL (@handle or /channel/UC…), or channel ID" bind:value={form.channelId} />
|
<input placeholder="Channel URL (@handle or /channel/UC…), or channel ID" bind:value={form.channelId} />
|
||||||
@@ -396,7 +395,7 @@
|
|||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 120px;
|
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 170px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
enabled,
|
||||||
|
onToggle,
|
||||||
|
canMoveUp,
|
||||||
|
canMoveDown,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
canMoveUp: boolean;
|
||||||
|
canMoveDown: boolean;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<button class="head-btn" onclick={() => (expanded = !expanded)} aria-expanded={expanded}>
|
||||||
|
<span class="chevron" class:open={expanded}>▸</span>
|
||||||
|
<span class="title">{title}</span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" onclick={onMoveUp} disabled={!canMoveUp} aria-label="Move up">▲</button>
|
||||||
|
<button class="icon-btn" onclick={onMoveDown} disabled={!canMoveDown} aria-label="Move down">▼</button>
|
||||||
|
<span class="badge" class:active={enabled} onclick={onToggle} role="button" tabindex="0">
|
||||||
|
{enabled ? 'Active' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if expanded}
|
||||||
|
<div class="section-body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.icon-btn:disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.head-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.active {
|
||||||
|
background: var(--bg-accent);
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
.section-body {
|
||||||
|
padding: 14px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0.5px solid var(--border);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.section-body > :global(*:first-child) {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AdminSettings, AdminStockTicker, AdminBookmark, AdminPoe2Entry } from '$lib/adminTypes';
|
||||||
|
import { updateSettings } from '$lib/adminApi';
|
||||||
|
import WidgetSection from './WidgetSection.svelte';
|
||||||
|
import WeatherTab from './WeatherTab.svelte';
|
||||||
|
import StocksTab from './StocksTab.svelte';
|
||||||
|
import BookmarksTab from './BookmarksTab.svelte';
|
||||||
|
import Poe2Tab from './Poe2Tab.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
settings,
|
||||||
|
stockTickers,
|
||||||
|
bookmarks,
|
||||||
|
poe2Watchlist
|
||||||
|
}: {
|
||||||
|
settings: AdminSettings;
|
||||||
|
stockTickers: AdminStockTicker[];
|
||||||
|
bookmarks: AdminBookmark[];
|
||||||
|
poe2Watchlist: AdminPoe2Entry[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Local copies so each toggle/reorder reflects immediately — same idiom as
|
||||||
|
// BookmarksTab's per-row "Private" toggle.
|
||||||
|
let widgets = $state({ ...settings.widgets });
|
||||||
|
let widgetOrder = $state([...settings.widgetOrder]);
|
||||||
|
|
||||||
|
const titles: Record<(typeof widgetOrder)[number], string> = {
|
||||||
|
weather: 'Weather',
|
||||||
|
stocks: 'Stocks',
|
||||||
|
bookmarks: 'Bookmarks',
|
||||||
|
poe2: 'PoE2'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function toggle(key: keyof typeof widgets) {
|
||||||
|
widgets[key] = !widgets[key];
|
||||||
|
await updateSettings({ widgets });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(index: number, dir: -1 | 1) {
|
||||||
|
const target = index + dir;
|
||||||
|
if (target < 0 || target >= widgetOrder.length) return;
|
||||||
|
const arr = [...widgetOrder];
|
||||||
|
[arr[index], arr[target]] = [arr[target], arr[index]];
|
||||||
|
widgetOrder = arr;
|
||||||
|
await updateSettings({ widgetOrder });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each widgetOrder as key, i (key)}
|
||||||
|
<WidgetSection
|
||||||
|
title={titles[key]}
|
||||||
|
enabled={widgets[key]}
|
||||||
|
onToggle={() => toggle(key)}
|
||||||
|
canMoveUp={i > 0}
|
||||||
|
canMoveDown={i < widgetOrder.length - 1}
|
||||||
|
onMoveUp={() => move(i, -1)}
|
||||||
|
onMoveDown={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
{#if key === 'weather'}
|
||||||
|
<WeatherTab {settings} />
|
||||||
|
{:else if key === 'stocks'}
|
||||||
|
<StocksTab tickers={stockTickers} />
|
||||||
|
{:else if key === 'bookmarks'}
|
||||||
|
<BookmarksTab {bookmarks} />
|
||||||
|
{:else if key === 'poe2'}
|
||||||
|
<Poe2Tab {settings} watchlist={poe2Watchlist} />
|
||||||
|
{/if}
|
||||||
|
</WidgetSection>
|
||||||
|
{/each}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { Weather, StockTicker, Bookmark, Poe2Data } from '$lib/types';
|
import type { Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from '$lib/types';
|
||||||
import WeatherWidget from './WeatherWidget.svelte';
|
import WeatherWidget from './WeatherWidget.svelte';
|
||||||
import StocksWidget from './StocksWidget.svelte';
|
import StocksWidget from './StocksWidget.svelte';
|
||||||
import BookmarksWidget from './BookmarksWidget.svelte';
|
import BookmarksWidget from './BookmarksWidget.svelte';
|
||||||
import Poe2Widget from './Poe2Widget.svelte';
|
import Poe2Widget from './Poe2Widget.svelte';
|
||||||
|
|
||||||
let { weather, stocks, bookmarks, poe2 }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[]; poe2: Poe2Data } = $props();
|
let {
|
||||||
|
weather,
|
||||||
|
stocks,
|
||||||
|
bookmarks,
|
||||||
|
poe2,
|
||||||
|
widgetsEnabled
|
||||||
|
}: {
|
||||||
|
weather: Weather;
|
||||||
|
stocks: StockTicker[];
|
||||||
|
bookmarks: Bookmark[];
|
||||||
|
poe2: Poe2Data;
|
||||||
|
widgetsEnabled: WidgetsEnabled;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
// Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the viewport. Plain
|
// Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the viewport. Plain
|
||||||
// `position: sticky` alone can only pin a box at a constant offset — it can't reveal
|
// `position: sticky` alone can only pin a box at a constant offset — it can't reveal
|
||||||
@@ -55,11 +67,13 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Widget data changing the sidebar's natural height needs a remeasure, not just a
|
// Widget data changing the sidebar's natural height needs a remeasure, not just a
|
||||||
// scroll-position update.
|
// scroll-position update. widgetsEnabled changes it too — a disabled widget is
|
||||||
|
// removed from the flow entirely, not just emptied.
|
||||||
void weather;
|
void weather;
|
||||||
void stocks;
|
void stocks;
|
||||||
void bookmarks;
|
void bookmarks;
|
||||||
void poe2;
|
void poe2;
|
||||||
|
void widgetsEnabled;
|
||||||
remeasure();
|
remeasure();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,10 +90,17 @@
|
|||||||
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
|
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
|
||||||
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
|
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
|
||||||
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
|
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
|
||||||
<WeatherWidget {weather} />
|
{#each widgetsEnabled.order as key (key)}
|
||||||
<StocksWidget {stocks} />
|
{#if key === 'weather' && widgetsEnabled.weather}
|
||||||
<Poe2Widget {poe2} />
|
<WeatherWidget {weather} />
|
||||||
<BookmarksWidget {bookmarks} />
|
{:else if key === 'stocks' && widgetsEnabled.stocks}
|
||||||
|
<StocksWidget {stocks} />
|
||||||
|
{:else if key === 'poe2' && widgetsEnabled.poe2}
|
||||||
|
<Poe2Widget {poe2} />
|
||||||
|
{:else if key === 'bookmarks' && widgetsEnabled.bookmarks}
|
||||||
|
<BookmarksWidget {bookmarks} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ export interface TrackedEventPublic {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
cadence: string;
|
recapIntervalHours: number | null;
|
||||||
|
isSpillover: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -171,3 +172,12 @@ export interface Poe2Data {
|
|||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
entries: Poe2WatchlistEntry[];
|
entries: Poe2WatchlistEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-widget sidebar visibility + display order, admin-set from the consolidated "Widgets" tab. */
|
||||||
|
export interface WidgetsEnabled {
|
||||||
|
weather: boolean;
|
||||||
|
stocks: boolean;
|
||||||
|
bookmarks: boolean;
|
||||||
|
poe2: boolean;
|
||||||
|
order: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,20 +51,23 @@
|
|||||||
// list) — those collapse into a single trailing "More »" tab instead, so the nav
|
// list) — those collapse into a single trailing "More »" tab instead, so the nav
|
||||||
// doesn't get too wide or wrap once there are more than a handful of categories.
|
// doesn't get too wide or wrap once there are more than a handful of categories.
|
||||||
//
|
//
|
||||||
// A tracked event is a displayed category too, just backed by a source+keyword
|
// A tracked item is a displayed category too, just backed by a source+keyword
|
||||||
// filter instead of manual per-source category checkboxes, and periodically
|
// filter instead of manual per-source category checkboxes, and periodically
|
||||||
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
|
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
|
||||||
// appended after the regular categories.
|
// appended after the regular categories, unless marked spillover — same "More »"
|
||||||
|
// collapse as an overflow category, see /more's +page.ts.
|
||||||
const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
|
const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
|
||||||
const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
|
const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
|
||||||
|
const primaryEvents = $derived(data.events.filter((e) => !e.isSpillover));
|
||||||
|
const spilloverEvents = $derived(data.events.filter((e) => e.isSpillover));
|
||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
...primaryCategories.map((cat) => ({
|
...primaryCategories.map((cat) => ({
|
||||||
label: cat.name,
|
label: cat.name,
|
||||||
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
|
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
|
||||||
})),
|
})),
|
||||||
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
|
...primaryEvents.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
|
||||||
...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
|
...(spilloverCategories.length > 0 || spilloverEvents.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{#if showSidebar}
|
{#if showSidebar}
|
||||||
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} />
|
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} widgetsEnabled={data.widgetsEnabled} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2 } from '$lib/api';
|
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2, getWidgetsEnabled } from '$lib/api';
|
||||||
import { getPrivateAccessStatus } from '$lib/privateAccess';
|
import { getPrivateAccessStatus } from '$lib/privateAccess';
|
||||||
|
|
||||||
// Named so the layout can be re-fetched on its own (see +layout.svelte's periodic
|
// Named so the layout can be re-fetched on its own (see +layout.svelte's periodic
|
||||||
@@ -7,14 +7,15 @@ import { getPrivateAccessStatus } from '$lib/privateAccess';
|
|||||||
// own pagination state, which a blanket invalidateAll() would reset every refresh.
|
// own pagination state, which a blanket invalidateAll() would reset every refresh.
|
||||||
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||||
depends('app:sidebar');
|
depends('app:sidebar');
|
||||||
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2] = await Promise.all([
|
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2, widgetsEnabled] = await Promise.all([
|
||||||
getCategories(fetch),
|
getCategories(fetch),
|
||||||
getEvents(fetch),
|
getEvents(fetch),
|
||||||
getPrivateAccessStatus(fetch),
|
getPrivateAccessStatus(fetch),
|
||||||
getWeather(fetch),
|
getWeather(fetch),
|
||||||
getStocks(fetch),
|
getStocks(fetch),
|
||||||
getBookmarks(fetch),
|
getBookmarks(fetch),
|
||||||
getPoe2(fetch)
|
getPoe2(fetch),
|
||||||
|
getWidgetsEnabled(fetch)
|
||||||
]);
|
]);
|
||||||
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
|
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
|
||||||
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
|
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
|
||||||
@@ -26,6 +27,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|||||||
weather,
|
weather,
|
||||||
stocks,
|
stocks,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
poe2
|
poe2,
|
||||||
|
widgetsEnabled
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import MergeTab from '$lib/components/admin/MergeTab.svelte';
|
import MergeTab from '$lib/components/admin/MergeTab.svelte';
|
||||||
import SourcesTab from '$lib/components/admin/SourcesTab.svelte';
|
|
||||||
import ModelsTab from '$lib/components/admin/ModelsTab.svelte';
|
import ModelsTab from '$lib/components/admin/ModelsTab.svelte';
|
||||||
import RetentionTab from '$lib/components/admin/RetentionTab.svelte';
|
import RetentionTab from '$lib/components/admin/RetentionTab.svelte';
|
||||||
import EventsTab from '$lib/components/admin/EventsTab.svelte';
|
import EventsTab from '$lib/components/admin/EventsTab.svelte';
|
||||||
import WeatherTab from '$lib/components/admin/WeatherTab.svelte';
|
import WidgetsTab from '$lib/components/admin/WidgetsTab.svelte';
|
||||||
import StocksTab from '$lib/components/admin/StocksTab.svelte';
|
|
||||||
import BookmarksTab from '$lib/components/admin/BookmarksTab.svelte';
|
|
||||||
import Poe2Tab from '$lib/components/admin/Poe2Tab.svelte';
|
|
||||||
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
|
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
|
||||||
import LogsTab from '$lib/components/admin/LogsTab.svelte';
|
import LogsTab from '$lib/components/admin/LogsTab.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'merge', label: 'Merge' },
|
{ id: 'merge', label: 'Sources & Merge' },
|
||||||
{ id: 'sources', label: 'Sources' },
|
|
||||||
{ id: 'models', label: 'Models' },
|
{ id: 'models', label: 'Models' },
|
||||||
{ id: 'retention', label: 'Retention' },
|
{ id: 'retention', label: 'Retention' },
|
||||||
{ id: 'events', label: 'Tracked events' },
|
{ id: 'events', label: 'Tracked items' },
|
||||||
{ id: 'weather', label: 'Weather' },
|
{ id: 'widgets', label: 'Widgets' },
|
||||||
{ id: 'stocks', label: 'Stocks' },
|
|
||||||
{ id: 'bookmarks', label: 'Bookmarks' },
|
|
||||||
{ id: 'poe2', label: 'PoE2' },
|
|
||||||
{ id: 'connections', label: 'Connections' },
|
{ id: 'connections', label: 'Connections' },
|
||||||
{ id: 'logs', label: 'Logs' }
|
{ id: 'logs', label: 'Logs' }
|
||||||
];
|
];
|
||||||
@@ -44,23 +36,15 @@
|
|||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if active === 'merge'}
|
{#if active === 'merge'}
|
||||||
<MergeTab settings={data.settings} />
|
<MergeTab settings={data.settings} sources={data.sources} />
|
||||||
{:else if active === 'sources'}
|
|
||||||
<SourcesTab sources={data.sources} categories={data.settings.categoryPriority} />
|
|
||||||
{:else if active === 'models'}
|
{:else if active === 'models'}
|
||||||
<ModelsTab settings={data.settings} models={data.models} aiStatus={data.aiStatus} />
|
<ModelsTab settings={data.settings} models={data.models} aiStatus={data.aiStatus} />
|
||||||
{:else if active === 'retention'}
|
{:else if active === 'retention'}
|
||||||
<RetentionTab settings={data.settings} />
|
<RetentionTab settings={data.settings} />
|
||||||
{:else if active === 'events'}
|
{:else if active === 'events'}
|
||||||
<EventsTab events={data.events} sources={data.sources} />
|
<EventsTab events={data.events} sources={data.sources} />
|
||||||
{:else if active === 'weather'}
|
{:else if active === 'widgets'}
|
||||||
<WeatherTab settings={data.settings} />
|
<WidgetsTab settings={data.settings} stockTickers={data.stockTickers} bookmarks={data.bookmarks} poe2Watchlist={data.poe2Watchlist} />
|
||||||
{:else if active === 'stocks'}
|
|
||||||
<StocksTab tickers={data.stockTickers} />
|
|
||||||
{:else if active === 'bookmarks'}
|
|
||||||
<BookmarksTab bookmarks={data.bookmarks} />
|
|
||||||
{:else if active === 'poe2'}
|
|
||||||
<Poe2Tab settings={data.settings} watchlist={data.poe2Watchlist} />
|
|
||||||
{:else if active === 'connections'}
|
{:else if active === 'connections'}
|
||||||
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
|
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
|
||||||
{:else if active === 'logs'}
|
{:else if active === 'logs'}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="title">{data.name}</span>
|
<span class="title">{data.name}</span>
|
||||||
<span class="sub">Tracked event — periodically recapped by AI</span>
|
<span class="sub">Tracked item — periodically recapped by AI</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InfiniteFeed initial={data.initial} filters={data.filters} pageSize={data.pageSize} />
|
<InfiniteFeed initial={data.initial} filters={data.filters} pageSize={data.pageSize} />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sections">
|
<div class="sections">
|
||||||
{#each data.sections as section (section.category.id)}
|
{#each data.categorySections as section (section.category.id)}
|
||||||
{#if section.articles.length > 0}
|
{#if section.articles.length > 0}
|
||||||
<section class="cat-section">
|
<section class="cat-section">
|
||||||
<a class="cat-name" href={`/category/${slugify(section.category.name)}`}>{section.category.name}</a>
|
<a class="cat-name" href={`/category/${slugify(section.category.name)}`}>{section.category.name}</a>
|
||||||
@@ -23,6 +23,18 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each data.eventSections as section (section.event.id)}
|
||||||
|
{#if section.articles.length > 0}
|
||||||
|
<section class="cat-section">
|
||||||
|
<a class="cat-name" href={`/event/${section.event.id}`}>{section.event.name}</a>
|
||||||
|
<div class="list">
|
||||||
|
{#each section.articles as article (article.id)}
|
||||||
|
<ArticleListRow {article} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -4,19 +4,28 @@ import { getFeed } from '$lib/api';
|
|||||||
const PREVIEW_COUNT = 5;
|
const PREVIEW_COUNT = 5;
|
||||||
|
|
||||||
// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
|
// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
|
||||||
// category (see MergeTab.svelte's "More" toggle) with its few newest articles, the
|
// category (see MergeTab.svelte's "More" toggle) or spillover tracked item (see
|
||||||
// category name itself linking through to the full /category/:slug page. Mirrors
|
// EventsTab.svelte's "More" toggle) with its few newest articles, the name itself
|
||||||
|
// linking through to the full /category/:slug or /event/:id page. Mirrors
|
||||||
// category/[name]/+page.ts's parent()-based category access rather than a second fetch.
|
// category/[name]/+page.ts's parent()-based category access rather than a second fetch.
|
||||||
export const load: PageLoad = async ({ fetch, parent }) => {
|
export const load: PageLoad = async ({ fetch, parent }) => {
|
||||||
const { categories } = await parent();
|
const { categories, events } = await parent();
|
||||||
const spillover = categories.filter((c) => c.isSpillover);
|
const spilloverCategories = categories.filter((c) => c.isSpillover);
|
||||||
|
const spilloverEvents = events.filter((e) => e.isSpillover);
|
||||||
|
|
||||||
const sections = await Promise.all(
|
const categorySections = await Promise.all(
|
||||||
spillover.map(async (category) => ({
|
spilloverCategories.map(async (category) => ({
|
||||||
category,
|
category,
|
||||||
articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch)
|
articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sections };
|
const eventSections = await Promise.all(
|
||||||
|
spilloverEvents.map(async (event) => ({
|
||||||
|
event,
|
||||||
|
articles: await getFeed({ eventId: event.id, limit: PREVIEW_COUNT }, fetch)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { categorySections, eventSections };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
let settings = {
|
let settings = {
|
||||||
mergeStrictness: 3,
|
mergeStrictness: 3,
|
||||||
defaultPollIntervalMinutes: 15,
|
|
||||||
holdBeforePublishMinutes: 30,
|
holdBeforePublishMinutes: 30,
|
||||||
tagDedupThreshold: 0.82,
|
tagDedupThreshold: 0.82,
|
||||||
tagExpiryDays: 21,
|
tagExpiryDays: 21,
|
||||||
@@ -87,9 +86,9 @@ let events = [
|
|||||||
name: "Iran war",
|
name: "Iran war",
|
||||||
description: "Ongoing conflict coverage, sourced primarily from Telegram channels for speed.",
|
description: "Ongoing conflict coverage, sourced primarily from Telegram channels for speed.",
|
||||||
sourceIds: ["src-2"],
|
sourceIds: ["src-2"],
|
||||||
cadence: "daily",
|
recapIntervalHours: 24,
|
||||||
cadenceTime: "18:00",
|
|
||||||
active: true,
|
active: true,
|
||||||
|
isSpillover: false,
|
||||||
retentionOverrideDays: null
|
retentionOverrideDays: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,9 +96,9 @@ let events = [
|
|||||||
name: "Fed rate decisions",
|
name: "Fed rate decisions",
|
||||||
description: "",
|
description: "",
|
||||||
sourceIds: ["src-1"],
|
sourceIds: ["src-1"],
|
||||||
cadence: "continuous",
|
recapIntervalHours: 1,
|
||||||
cadenceTime: null,
|
|
||||||
active: true,
|
active: true,
|
||||||
|
isSpillover: false,
|
||||||
retentionOverrideDays: 7
|
retentionOverrideDays: 7
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ const tags = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const events = [
|
const events = [
|
||||||
{ id: "evt-iran", name: "Iran war", active: true, cadence: "daily" },
|
{ id: "evt-iran", name: "Iran war", active: true, recapIntervalHours: 24, isSpillover: false },
|
||||||
{ id: "evt-fed", name: "Fed rate decisions", active: true, cadence: "continuous" }
|
{ id: "evt-fed", name: "Fed rate decisions", active: true, recapIntervalHours: 1, isSpillover: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
module.exports = { articles, tags, events };
|
module.exports = { articles, tags, events };
|
||||||
|
|||||||
Reference in New Issue
Block a user