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)
This commit is contained in:
@@ -15,8 +15,7 @@ const adapters: Record<Source['type'], SourceAdapter> = {
|
||||
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,
|
||||
|
||||
@@ -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<T>(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<string, number>): number {
|
||||
const source = sourcesDb.getSource(item.sourceId);
|
||||
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>, sourcesById: Map<string, Source>): 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<string, number>)
|
||||
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
|
||||
* 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([]);
|
||||
if (items.length === 0) return 0;
|
||||
|
||||
const sourcesById = new Map(sourcesDb.listSources().map((s) => [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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,52 +60,58 @@ export function updateSettings(patch: Partial<GlobalSettings>): 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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
+30
-8
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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>
|
||||
@@ -196,7 +196,6 @@
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="nitter">Nitter</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
{#if form.type === 'youtube'}
|
||||
<input placeholder="Channel URL (@handle or /channel/UC…), or channel ID" bind:value={form.channelId} />
|
||||
|
||||
Reference in New Issue
Block a user