From 24bf5afb074d581f1a72497b6da887f032121a46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 16:45:46 +0000 Subject: [PATCH] Act on review findings: dead code, poe2_primary_currency_name, inefficiencies, custom source type - Delete dead ArticleCard.svelte and five zero-call-site backend exports (getTagsByIds, unclusteredItemsForSources, itemsByCluster, deleteAllContentItems, latestArticleInThread) - Drop poe2_primary_currency_name from the schema entirely (dev DB, no migration concerns) - settings.ts: bind named params instead of 33 positional ?s to remove the reorder-and-silently-corrupt risk - Remove the long-dead stooqToYahooSymbols unconditional startup rewrite - Share direct-publish logic between runPassthroughCycle/runSynthesisCycle via a new publishItemsDirect helper, and cache sourcesDb.listSources() once per synthesis tick instead of a per-item getSource() call - Remove the 'custom' source type (had no adapter of its own, aliased to api) --- backend/src/ingestion/poller.ts | 3 +- backend/src/queue/priorityQueue.ts | 84 +++++++++-------- backend/src/storage/db/articles.ts | 7 -- backend/src/storage/db/contentItems.ts | 18 ---- backend/src/storage/db/index.ts | 17 +--- backend/src/storage/db/settings.ts | 94 ++++++++++--------- backend/src/storage/db/tags.ts | 7 -- backend/src/storage/db/types.ts | 2 +- docs/review-2026-07.md | 38 ++++++-- frontend/src/lib/adminTypes.ts | 2 +- .../src/lib/components/ArticleCard.svelte | 76 --------------- .../lib/components/admin/SourcesTab.svelte | 1 - 12 files changed, 131 insertions(+), 218 deletions(-) delete mode 100644 frontend/src/lib/components/ArticleCard.svelte diff --git a/backend/src/ingestion/poller.ts b/backend/src/ingestion/poller.ts index 5a13c89..b7475dc 100644 --- a/backend/src/ingestion/poller.ts +++ b/backend/src/ingestion/poller.ts @@ -15,8 +15,7 @@ const adapters: Record = { telegram: telegramAdapter, api: apiAdapter, youtube: youtubeAdapter, - nitter: nitterAdapter, - custom: apiAdapter + nitter: nitterAdapter }; // Which source types point at a real webpage worth following for the full article, diff --git a/backend/src/queue/priorityQueue.ts b/backend/src/queue/priorityQueue.ts index c6234fa..1eded36 100644 --- a/backend/src/queue/priorityQueue.ts +++ b/backend/src/queue/priorityQueue.ts @@ -7,7 +7,7 @@ import { embedPendingItems } from '../pipeline/embedding.js'; import { clusterItems } from '../pipeline/clustering.js'; import { publishCluster, publishDirect } from '../pipeline/publish.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(items: T[], predicate: (item: T) => boolean): [T[], T[]] { const matches: T[] = []; @@ -31,8 +31,8 @@ function claimedEventId(item: ContentItem, events: TrackedEvent[]): string | nul return match?.id ?? null; } -function primaryCategoryRank(item: ContentItem, rankByName: Map): number { - const source = sourcesDb.getSource(item.sourceId); +function primaryCategoryRank(item: ContentItem, rankByName: Map, sourcesById: Map): number { + const source = sourcesById.get(item.sourceId); const cats = source?.category ?? []; let best = Infinity; for (const cat of cats) { @@ -45,6 +45,33 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map) 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 { + 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 * 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 [s.id, s])); const categories = categoriesDb.listCategories(); const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank])); 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) .map((r) => r.item); - let published = 0; - - 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; + return publishItemsDirect(ranked, settings, activeEvents, () => 'no AI available', 'Passthrough publish failed'); } /** @@ -96,36 +110,32 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G const items = contentItemsDb.unclusteredItemsExcludingSources([]); 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 // 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. const directPublishSourceIds = new Set( - sourcesDb - .listSources() - .filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram') - .map((s) => s.id) + [...sourcesById.values()].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)); - let publishedDirect = 0; - for (const item of directItems) { - try { - const eventId = claimedEventId(item, activeEvents) ?? undefined; - const article = await publishDirect(item, settings, { eventId }); - contentItemsDb.assignCluster([item.id], article.id); - 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 publishedDirect = await publishItemsDirect( + directItems, + settings, + activeEvents, + (item) => sourcesById.get(item.sourceId)?.type ?? 'unknown', + 'Direct publish failed' + ); const categories = categoriesDb.listCategories(); const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank])); 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) .map((r) => r.item); diff --git a/backend/src/storage/db/articles.ts b/backend/src/storage/db/articles.ts index 85ea331..30f0e47 100644 --- a/backend/src/storage/db/articles.ts +++ b/backend/src/storage/db/articles.ts @@ -143,13 +143,6 @@ export function articlesForEventSince(eventId: string, since: string): MergedArt 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[] { const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); const rows = db.prepare('SELECT * FROM merged_articles WHERE published_at < ?').all(cutoff); diff --git a/backend/src/storage/db/contentItems.ts b/backend/src/storage/db/contentItems.ts index 057df87..3356053 100644 --- a/backend/src/storage/db/contentItems.ts +++ b/backend/src/storage/db/contentItems.ts @@ -69,15 +69,6 @@ export function unclusteredItemsExcludingSources(excludeSourceIds: string[]): Co 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[]) { 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); } -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[] { const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); 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) { db.prepare('DELETE FROM content_items WHERE source_id = ?').run(sourceId); } - -export function deleteAllContentItems() { - db.prepare('DELETE FROM content_items').run(); -} diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 0697e94..8d5b415 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -37,7 +37,7 @@ export function migrate() { CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, 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 url TEXT, config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders @@ -200,7 +200,6 @@ export function migrate() { weather_updated_at TEXT, -- ISO timestamp, NULL pre-first-poll poe2_league_id 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 ); @@ -347,7 +346,6 @@ export function migrate() { 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_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'); } @@ -368,19 +366,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" — // 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 diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index 35723a3..b220a8f 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -60,52 +60,58 @@ export function updateSettings(patch: Partial): GlobalSettings { weather: { ...current.weather, ...(patch.weather ?? {}) }, poe2: { ...current.poe2, ...(patch.poe2 ?? {}) } }; + // Named params (rather than positional `?`) so this list can be reordered or + // extended without the column list and the bound-values list silently drifting + // out of sync — node:sqlite binds each by its `$name` key, not position. db.prepare( `UPDATE global_settings SET - merge_strictness=?, default_poll_interval_minutes=?, hold_before_publish_minutes=?, - tag_dedup_threshold=?, tag_expiry_days=?, follow_up_min_hours_since_last=?, follow_up_min_new_sources=?, - ai_service_host=?, ai_service_port=?, selected_models=?, - nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?, - published_article_max_age_days=?, raw_item_max_age_days=?, - storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?, - weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?, - weather_wind_unit=?, weather_pressure_unit=?, - weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?, - poe2_league_id=?, poe2_league_name=?, poe2_updated_at=? + merge_strictness=$merge_strictness, default_poll_interval_minutes=$default_poll_interval_minutes, + hold_before_publish_minutes=$hold_before_publish_minutes, + tag_dedup_threshold=$tag_dedup_threshold, tag_expiry_days=$tag_expiry_days, + follow_up_min_hours_since_last=$follow_up_min_hours_since_last, follow_up_min_new_sources=$follow_up_min_new_sources, + ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models, + nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, telegram_media_mode=$telegram_media_mode, + published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days, + storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit, + weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude, + weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit, + weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily, + weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at, + poe2_league_id=$poe2_league_id, poe2_league_name=$poe2_league_name, poe2_updated_at=$poe2_updated_at WHERE id = 1` - ).run( - merged.mergeStrictness, - merged.defaultPollIntervalMinutes, - merged.holdBeforePublishMinutes, - merged.tagDedupThreshold, - merged.tagExpiryDays, - merged.followUpMinHoursSinceLast, - merged.followUpMinNewSources, - merged.aiServiceHost, - merged.aiServicePort, - JSON.stringify(merged.selectedModels), - merged.nitterMediaMode, - merged.fxtwitterBaseUrl, - merged.telegramMediaMode, - merged.retention.publishedArticleMaxAgeDays, - merged.retention.rawItemMaxAgeDays, - merged.retention.storageCapEnabled ? 1 : 0, - merged.retention.storageCapValue, - merged.retention.storageCapUnit, - merged.weather.locationName, - merged.weather.latitude, - merged.weather.longitude, - merged.weather.unit, - merged.weather.windUnit, - merged.weather.pressureUnit, - merged.weather.current ? JSON.stringify(merged.weather.current) : null, - JSON.stringify(merged.weather.hourly), - JSON.stringify(merged.weather.daily), - JSON.stringify(merged.weather.alerts), - merged.weather.updatedAt, - merged.poe2.leagueId, - merged.poe2.leagueName, - merged.poe2.updatedAt - ); + ).run({ + $merge_strictness: merged.mergeStrictness, + $default_poll_interval_minutes: merged.defaultPollIntervalMinutes, + $hold_before_publish_minutes: merged.holdBeforePublishMinutes, + $tag_dedup_threshold: merged.tagDedupThreshold, + $tag_expiry_days: merged.tagExpiryDays, + $follow_up_min_hours_since_last: merged.followUpMinHoursSinceLast, + $follow_up_min_new_sources: merged.followUpMinNewSources, + $ai_service_host: merged.aiServiceHost, + $ai_service_port: merged.aiServicePort, + $selected_models: JSON.stringify(merged.selectedModels), + $nitter_media_mode: merged.nitterMediaMode, + $fxtwitter_base_url: merged.fxtwitterBaseUrl, + $telegram_media_mode: merged.telegramMediaMode, + $published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays, + $raw_item_max_age_days: merged.retention.rawItemMaxAgeDays, + $storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0, + $storage_cap_value: merged.retention.storageCapValue, + $storage_cap_unit: merged.retention.storageCapUnit, + $weather_location_name: merged.weather.locationName, + $weather_latitude: merged.weather.latitude, + $weather_longitude: merged.weather.longitude, + $weather_unit: merged.weather.unit, + $weather_wind_unit: merged.weather.windUnit, + $weather_pressure_unit: merged.weather.pressureUnit, + $weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null, + $weather_hourly: JSON.stringify(merged.weather.hourly), + $weather_daily: JSON.stringify(merged.weather.daily), + $weather_alerts: JSON.stringify(merged.weather.alerts), + $weather_updated_at: merged.weather.updatedAt, + $poe2_league_id: merged.poe2.leagueId, + $poe2_league_name: merged.poe2.leagueName, + $poe2_updated_at: merged.poe2.updatedAt + }); return getSettings(); } diff --git a/backend/src/storage/db/tags.ts b/backend/src/storage/db/tags.ts index e2ea9f6..455f47a 100644 --- a/backend/src/storage/db/tags.ts +++ b/backend/src/storage/db/tags.ts @@ -28,13 +28,6 @@ export function listActiveTags(): Tag[] { 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 { if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0; let dot = 0, diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 45b759b..5717ad8 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -1,7 +1,7 @@ export interface Source { id: string; name: string; - type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter' | 'custom'; + type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter'; category: string[]; url: string | null; config: Record; diff --git a/docs/review-2026-07.md b/docs/review-2026-07.md index 9cccd26..008a633 100644 --- a/docs/review-2026-07.md +++ b/docs/review-2026-07.md @@ -2,16 +2,38 @@ 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. This is a catalog of findings only — nothing here has been -changed. Each item below is meant to be independently actionable; treat this as a -punch list to pick from, not a sequence. +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. -Two items are worth acting on first if you only have time for a couple of fixes: +## Resolved since this report -- **`/api/stocks` and `/api/poe2` leak internal poller-diagnostic fields to - unauthenticated visitors** (finding 5.5 below) — a real, if low-severity, security - gap, and the most concrete/actionable thing in this review. -- **`ArticleCard.svelte` is dead code** (finding 1.1) — safe to delete outright. +- **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. --- diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 2cc08d6..6550513 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -141,7 +141,7 @@ export interface AdminSettings { export interface AdminSource { id: string; name: string; - type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter' | 'custom'; + type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter'; category: string[]; url: string; config?: Record; diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte deleted file mode 100644 index 009c574..0000000 --- a/frontend/src/lib/components/ArticleCard.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - - {#if article.heroImage} - - {:else} -
- {/if} -
- {#if article.video} - Video - {:else if article.sourceCount > 1} - {sourceLabel} - {:else} - {sourceLabel} - {/if} -
-
{article.title}
-
{timeAgo(article.publishedAt)}
-
- - diff --git a/frontend/src/lib/components/admin/SourcesTab.svelte b/frontend/src/lib/components/admin/SourcesTab.svelte index 5e8c709..91467fe 100644 --- a/frontend/src/lib/components/admin/SourcesTab.svelte +++ b/frontend/src/lib/components/admin/SourcesTab.svelte @@ -196,7 +196,6 @@ - {#if form.type === 'youtube'}