Add a per-source "Push to Top Stories?" opt-in, off by default
Every ingested article used to show up on the homepage regardless of its source, which meant a handful of high-volume feeds could flood "Top Stories." Sources now default to not appearing there; a source has to explicitly opt in via a new checkbox (also toggleable inline with a star icon) for its articles to show up on the homepage feed. An article shows there if any of its contributing sources opted in — merged/clustered stories aren't held to requiring all sources to agree. Category pages, Local, tags, and events are unaffected; this only gates the bare, no-filter homepage query. Schema: sources.push_to_top_stories and merged_articles.top_stories, both backfilled for existing databases via ALTER TABLE.
This commit is contained in:
@@ -21,6 +21,11 @@ function uniqueCategories(items: ContentItem[]): string[] {
|
||||
return [...cats];
|
||||
}
|
||||
|
||||
/** An article shows on the homepage if any of its contributing sources opted into "Push to Top Stories?". */
|
||||
function anyPushesToTopStories(items: ContentItem[]): boolean {
|
||||
return items.some((item) => sources.getSource(item.sourceId)?.pushToTopStories ?? false);
|
||||
}
|
||||
|
||||
/** Takes the first line of the synthesized body as a working title until a dedicated title-generation step exists. */
|
||||
function deriveTitle(body: string): string {
|
||||
const firstLine = body.split('\n')[0];
|
||||
@@ -106,7 +111,8 @@ export async function publishDirect(item: ContentItem): Promise<MergedArticle> {
|
||||
tags: [], // no LLM available to extract tags yet — backfilling these later is a reasonable future improvement
|
||||
threadId: randomUUID(),
|
||||
previousArticleId: null,
|
||||
nextArticleId: null
|
||||
nextArticleId: null,
|
||||
topStories: anyPushesToTopStories([item])
|
||||
});
|
||||
|
||||
if (storedMediaId) promoteToPublished(storedMediaId, article.id);
|
||||
@@ -197,7 +203,8 @@ export async function publishCluster(
|
||||
tags: tagIds,
|
||||
threadId,
|
||||
previousArticleId,
|
||||
nextArticleId: null
|
||||
nextArticleId: null,
|
||||
topStories: anyPushesToTopStories(items)
|
||||
});
|
||||
|
||||
if (storedMediaId) {
|
||||
|
||||
@@ -20,7 +20,8 @@ function rowToArticle(row: any): MergedArticle {
|
||||
tags: JSON.parse(row.tags),
|
||||
threadId: row.thread_id,
|
||||
previousArticleId: row.previous_article_id,
|
||||
nextArticleId: row.next_article_id
|
||||
nextArticleId: row.next_article_id,
|
||||
topStories: !!row.top_stories
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,8 +29,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
|
||||
const id = `art-${randomUUID()}`;
|
||||
db.prepare(
|
||||
`INSERT INTO merged_articles
|
||||
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
article.title,
|
||||
@@ -47,7 +48,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
|
||||
JSON.stringify(article.tags),
|
||||
article.threadId,
|
||||
article.previousArticleId,
|
||||
article.nextArticleId
|
||||
article.nextArticleId,
|
||||
article.topStories ? 1 : 0
|
||||
);
|
||||
if (article.previousArticleId) {
|
||||
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId);
|
||||
@@ -76,6 +78,17 @@ export function queryFeed(filters: {
|
||||
}): MergedArticle[] {
|
||||
let sql = 'SELECT * FROM merged_articles WHERE 1=1';
|
||||
const params: unknown[] = [];
|
||||
|
||||
// The bare feed (no category/geo/eventId/tag — i.e. the homepage/"Top stories") only
|
||||
// shows articles whose contributing source(s) opted into "Push to Top Stories?" —
|
||||
// otherwise every ingested article from every source would flood the homepage.
|
||||
// Any explicit filter (a real category page, Local's geo filter, a tag or event page)
|
||||
// is unaffected — those show everything matching, regardless of this flag.
|
||||
const isHomepage = !filters.category && !filters.geo && !filters.eventId && !filters.tag;
|
||||
if (isHomepage) {
|
||||
sql += ' AND top_stories = 1';
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
sql += ' AND category LIKE ?';
|
||||
params.push(`%"${filters.category}"%`);
|
||||
|
||||
@@ -28,6 +28,7 @@ export function migrate() {
|
||||
config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders
|
||||
poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
push_to_top_stories INTEGER NOT NULL DEFAULT 0, -- opt-in: keeps the homepage from being flooded by every ingested source
|
||||
last_polled_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
@@ -73,7 +74,8 @@ export function migrate() {
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON tag ids
|
||||
thread_id TEXT NOT NULL,
|
||||
previous_article_id TEXT,
|
||||
next_article_id TEXT
|
||||
next_article_id TEXT,
|
||||
top_stories INTEGER NOT NULL DEFAULT 0 -- true if any contributing source opted into "Push to Top Stories?"
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_published ON merged_articles(published_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id);
|
||||
@@ -182,10 +184,22 @@ export function migrate() {
|
||||
).run();
|
||||
}
|
||||
|
||||
// Backfill new columns for installs seeded before they existed — node:sqlite's
|
||||
// CREATE TABLE IF NOT EXISTS doesn't add columns to an already-existing table.
|
||||
const hasColumn = (table: string, column: string) =>
|
||||
(db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).some((c) => c.name === column);
|
||||
if (!hasColumn('sources', 'push_to_top_stories')) {
|
||||
db.exec('ALTER TABLE sources ADD COLUMN push_to_top_stories INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!hasColumn('merged_articles', 'top_stories')) {
|
||||
db.exec('ALTER TABLE merged_articles ADD COLUMN top_stories INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
// 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's all-categories-chronological view (see
|
||||
// +layout.svelte's nav mapping and /api/feed's no-category-filter default).
|
||||
// filterable tag: it's the homepage view, now scoped to only the articles whose
|
||||
// sources opted into "Push to Top Stories?" (see sources.push_to_top_stories and
|
||||
// articles.queryFeed's isHomepage gate) rather than every ingested article.
|
||||
const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get() as { c: number };
|
||||
if (catCount.c === 0) {
|
||||
const defaults = ['Top stories', 'News', 'Local', 'World', 'Business', 'Tech', 'Culture'];
|
||||
|
||||
@@ -12,6 +12,7 @@ function rowToSource(row: any): Source {
|
||||
config: JSON.parse(row.config),
|
||||
pollIntervalMinutes: row.poll_interval_minutes,
|
||||
enabled: !!row.enabled,
|
||||
pushToTopStories: !!row.push_to_top_stories,
|
||||
lastPolledAt: row.last_polled_at,
|
||||
lastError: row.last_error,
|
||||
createdAt: row.created_at
|
||||
@@ -37,8 +38,8 @@ export function createSource(input: Partial<Source>): Source {
|
||||
const id = `src-${randomUUID()}`;
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO sources (id, name, type, category, url, config, poll_interval_minutes, enabled, last_polled_at, last_error, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)`
|
||||
`INSERT INTO sources (id, name, type, category, url, config, poll_interval_minutes, enabled, push_to_top_stories, last_polled_at, last_error, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.name ?? 'Untitled source',
|
||||
@@ -48,6 +49,7 @@ export function createSource(input: Partial<Source>): Source {
|
||||
JSON.stringify(input.config ?? {}),
|
||||
input.pollIntervalMinutes ?? 15,
|
||||
input.enabled === false ? 0 : 1,
|
||||
input.pushToTopStories ? 1 : 0,
|
||||
now
|
||||
);
|
||||
return getSource(id)!;
|
||||
@@ -58,7 +60,7 @@ export function updateSource(id: string, patch: Partial<Source>): Source | null
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...patch };
|
||||
db.prepare(
|
||||
`UPDATE sources SET name=?, type=?, category=?, url=?, config=?, poll_interval_minutes=?, enabled=?, last_polled_at=?, last_error=? WHERE id=?`
|
||||
`UPDATE sources SET name=?, type=?, category=?, url=?, config=?, poll_interval_minutes=?, enabled=?, push_to_top_stories=?, last_polled_at=?, last_error=? WHERE id=?`
|
||||
).run(
|
||||
merged.name,
|
||||
merged.type,
|
||||
@@ -67,6 +69,7 @@ export function updateSource(id: string, patch: Partial<Source>): Source | null
|
||||
JSON.stringify(merged.config),
|
||||
merged.pollIntervalMinutes,
|
||||
merged.enabled ? 1 : 0,
|
||||
merged.pushToTopStories ? 1 : 0,
|
||||
merged.lastPolledAt,
|
||||
merged.lastError,
|
||||
id
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Source {
|
||||
config: Record<string, unknown>;
|
||||
pollIntervalMinutes: number;
|
||||
enabled: boolean;
|
||||
/** Opt-in — default false, so the homepage ("Top stories") isn't flooded by every ingested source. */
|
||||
pushToTopStories: boolean;
|
||||
lastPolledAt: string | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
@@ -57,6 +59,8 @@ export interface MergedArticle {
|
||||
threadId: string;
|
||||
previousArticleId: string | null;
|
||||
nextArticleId: string | null;
|
||||
/** True if any contributing source opted into "Push to Top Stories?" — gates the homepage feed, see articles.queryFeed. */
|
||||
topStories: boolean;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
|
||||
Reference in New Issue
Block a user