Merge pull request #3 from Salastil/claude/source-management-content-1w2yno
Add a per-source "Push to Top Stories?" opt-in, off by default
This commit is contained in:
@@ -21,6 +21,11 @@ function uniqueCategories(items: ContentItem[]): string[] {
|
|||||||
return [...cats];
|
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. */
|
/** Takes the first line of the synthesized body as a working title until a dedicated title-generation step exists. */
|
||||||
function deriveTitle(body: string): string {
|
function deriveTitle(body: string): string {
|
||||||
const firstLine = body.split('\n')[0];
|
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
|
tags: [], // no LLM available to extract tags yet — backfilling these later is a reasonable future improvement
|
||||||
threadId: randomUUID(),
|
threadId: randomUUID(),
|
||||||
previousArticleId: null,
|
previousArticleId: null,
|
||||||
nextArticleId: null
|
nextArticleId: null,
|
||||||
|
topStories: anyPushesToTopStories([item])
|
||||||
});
|
});
|
||||||
|
|
||||||
if (storedMediaId) promoteToPublished(storedMediaId, article.id);
|
if (storedMediaId) promoteToPublished(storedMediaId, article.id);
|
||||||
@@ -197,7 +203,8 @@ export async function publishCluster(
|
|||||||
tags: tagIds,
|
tags: tagIds,
|
||||||
threadId,
|
threadId,
|
||||||
previousArticleId,
|
previousArticleId,
|
||||||
nextArticleId: null
|
nextArticleId: null,
|
||||||
|
topStories: anyPushesToTopStories(items)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (storedMediaId) {
|
if (storedMediaId) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ function rowToArticle(row: any): MergedArticle {
|
|||||||
tags: JSON.parse(row.tags),
|
tags: JSON.parse(row.tags),
|
||||||
threadId: row.thread_id,
|
threadId: row.thread_id,
|
||||||
previousArticleId: row.previous_article_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()}`;
|
const id = `art-${randomUUID()}`;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO merged_articles
|
`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)
|
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
article.title,
|
article.title,
|
||||||
@@ -47,7 +48,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
|
|||||||
JSON.stringify(article.tags),
|
JSON.stringify(article.tags),
|
||||||
article.threadId,
|
article.threadId,
|
||||||
article.previousArticleId,
|
article.previousArticleId,
|
||||||
article.nextArticleId
|
article.nextArticleId,
|
||||||
|
article.topStories ? 1 : 0
|
||||||
);
|
);
|
||||||
if (article.previousArticleId) {
|
if (article.previousArticleId) {
|
||||||
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, 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[] {
|
}): MergedArticle[] {
|
||||||
let sql = 'SELECT * FROM merged_articles WHERE 1=1';
|
let sql = 'SELECT * FROM merged_articles WHERE 1=1';
|
||||||
const params: unknown[] = [];
|
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) {
|
if (filters.category) {
|
||||||
sql += ' AND category LIKE ?';
|
sql += ' AND category LIKE ?';
|
||||||
params.push(`%"${filters.category}"%`);
|
params.push(`%"${filters.category}"%`);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function migrate() {
|
|||||||
config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders
|
config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders
|
||||||
poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
|
poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
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_polled_at TEXT,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
@@ -73,7 +74,8 @@ export function migrate() {
|
|||||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON tag ids
|
tags TEXT NOT NULL DEFAULT '[]', -- JSON tag ids
|
||||||
thread_id TEXT NOT NULL,
|
thread_id TEXT NOT NULL,
|
||||||
previous_article_id TEXT,
|
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_published ON merged_articles(published_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id);
|
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id);
|
||||||
@@ -182,10 +184,22 @@ export function migrate() {
|
|||||||
).run();
|
).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" —
|
// 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's all-categories-chronological view (see
|
// filterable tag: it's the homepage view, now scoped to only the articles whose
|
||||||
// +layout.svelte's nav mapping and /api/feed's no-category-filter default).
|
// 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 };
|
const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get() as { c: number };
|
||||||
if (catCount.c === 0) {
|
if (catCount.c === 0) {
|
||||||
const defaults = ['Top stories', 'News', 'Local', 'World', 'Business', 'Tech', 'Culture'];
|
const defaults = ['Top stories', 'News', 'Local', 'World', 'Business', 'Tech', 'Culture'];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function rowToSource(row: any): Source {
|
|||||||
config: JSON.parse(row.config),
|
config: JSON.parse(row.config),
|
||||||
pollIntervalMinutes: row.poll_interval_minutes,
|
pollIntervalMinutes: row.poll_interval_minutes,
|
||||||
enabled: !!row.enabled,
|
enabled: !!row.enabled,
|
||||||
|
pushToTopStories: !!row.push_to_top_stories,
|
||||||
lastPolledAt: row.last_polled_at,
|
lastPolledAt: row.last_polled_at,
|
||||||
lastError: row.last_error,
|
lastError: row.last_error,
|
||||||
createdAt: row.created_at
|
createdAt: row.created_at
|
||||||
@@ -37,8 +38,8 @@ export function createSource(input: Partial<Source>): Source {
|
|||||||
const id = `src-${randomUUID()}`;
|
const id = `src-${randomUUID()}`;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO sources (id, name, type, category, url, config, poll_interval_minutes, enabled, last_polled_at, last_error, created_at)
|
`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, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)`
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
input.name ?? 'Untitled source',
|
input.name ?? 'Untitled source',
|
||||||
@@ -48,6 +49,7 @@ export function createSource(input: Partial<Source>): Source {
|
|||||||
JSON.stringify(input.config ?? {}),
|
JSON.stringify(input.config ?? {}),
|
||||||
input.pollIntervalMinutes ?? 15,
|
input.pollIntervalMinutes ?? 15,
|
||||||
input.enabled === false ? 0 : 1,
|
input.enabled === false ? 0 : 1,
|
||||||
|
input.pushToTopStories ? 1 : 0,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
return getSource(id)!;
|
return getSource(id)!;
|
||||||
@@ -58,7 +60,7 @@ export function updateSource(id: string, patch: Partial<Source>): Source | null
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const merged = { ...existing, ...patch };
|
const merged = { ...existing, ...patch };
|
||||||
db.prepare(
|
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(
|
).run(
|
||||||
merged.name,
|
merged.name,
|
||||||
merged.type,
|
merged.type,
|
||||||
@@ -67,6 +69,7 @@ export function updateSource(id: string, patch: Partial<Source>): Source | null
|
|||||||
JSON.stringify(merged.config),
|
JSON.stringify(merged.config),
|
||||||
merged.pollIntervalMinutes,
|
merged.pollIntervalMinutes,
|
||||||
merged.enabled ? 1 : 0,
|
merged.enabled ? 1 : 0,
|
||||||
|
merged.pushToTopStories ? 1 : 0,
|
||||||
merged.lastPolledAt,
|
merged.lastPolledAt,
|
||||||
merged.lastError,
|
merged.lastError,
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface Source {
|
|||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
pollIntervalMinutes: number;
|
pollIntervalMinutes: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Opt-in — default false, so the homepage ("Top stories") isn't flooded by every ingested source. */
|
||||||
|
pushToTopStories: boolean;
|
||||||
lastPolledAt: string | null;
|
lastPolledAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -57,6 +59,8 @@ export interface MergedArticle {
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
previousArticleId: string | null;
|
previousArticleId: string | null;
|
||||||
nextArticleId: 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 {
|
export interface Tag {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface AdminSource {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
pollIntervalMinutes: number;
|
pollIntervalMinutes: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
pushToTopStories: boolean;
|
||||||
lastPolledAt: string | null;
|
lastPolledAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
url: '',
|
url: '',
|
||||||
channelId: '',
|
channelId: '',
|
||||||
categorySet: new Set<string>(),
|
categorySet: new Set<string>(),
|
||||||
pollIntervalMinutes: 15
|
pollIntervalMinutes: 15,
|
||||||
|
pushToTopStories: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@
|
|||||||
url: source.type === 'youtube' ? '' : source.url,
|
url: source.type === 'youtube' ? '' : source.url,
|
||||||
channelId: source.type === 'youtube' ? (source.url || (source.config?.channelId as string) || '') : '',
|
channelId: source.type === 'youtube' ? (source.url || (source.config?.channelId as string) || '') : '',
|
||||||
categorySet: new Set(source.category),
|
categorySet: new Set(source.category),
|
||||||
pollIntervalMinutes: source.pollIntervalMinutes
|
pollIntervalMinutes: source.pollIntervalMinutes,
|
||||||
|
pushToTopStories: source.pushToTopStories
|
||||||
};
|
};
|
||||||
editingId = source.id;
|
editingId = source.id;
|
||||||
showAdd = true;
|
showAdd = true;
|
||||||
@@ -69,7 +71,8 @@
|
|||||||
type: form.type,
|
type: form.type,
|
||||||
url: form.channelId,
|
url: form.channelId,
|
||||||
category,
|
category,
|
||||||
pollIntervalMinutes: form.pollIntervalMinutes
|
pollIntervalMinutes: form.pollIntervalMinutes,
|
||||||
|
pushToTopStories: form.pushToTopStories
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +80,8 @@
|
|||||||
type: form.type,
|
type: form.type,
|
||||||
url: form.url,
|
url: form.url,
|
||||||
category,
|
category,
|
||||||
pollIntervalMinutes: form.pollIntervalMinutes
|
pollIntervalMinutes: form.pollIntervalMinutes,
|
||||||
|
pushToTopStories: form.pushToTopStories
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +123,11 @@
|
|||||||
sources = sources.map((s) => (s.id === source.id ? updated : s));
|
sources = sources.map((s) => (s.id === source.id ? updated : s));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleTopStories(source: AdminSource) {
|
||||||
|
const updated = await updateSource(source.id, { pushToTopStories: !source.pushToTopStories });
|
||||||
|
sources = sources.map((s) => (s.id === source.id ? updated : s));
|
||||||
|
}
|
||||||
|
|
||||||
async function pollNow(source: AdminSource) {
|
async function pollNow(source: AdminSource) {
|
||||||
pollingId = source.id;
|
pollingId = source.id;
|
||||||
justPolled = null;
|
justPolled = null;
|
||||||
@@ -176,6 +185,11 @@
|
|||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<label class="top-stories-check">
|
||||||
|
<input type="checkbox" bind:checked={form.pushToTopStories} />
|
||||||
|
Push to Top Stories?
|
||||||
|
<span class="hint">Off by default — keeps the homepage from being flooded by every source.</span>
|
||||||
|
</label>
|
||||||
<div class="add-actions">
|
<div class="add-actions">
|
||||||
<button onclick={cancelForm}>Cancel</button>
|
<button onclick={cancelForm}>Cancel</button>
|
||||||
<button class="primary" onclick={handleSubmit}>{editingId ? 'Save' : 'Add'}</button>
|
<button class="primary" onclick={handleSubmit}>{editingId ? 'Save' : 'Add'}</button>
|
||||||
@@ -226,6 +240,14 @@
|
|||||||
<span class="cat">{source.category.join(', ')}</span>
|
<span class="cat">{source.category.join(', ')}</span>
|
||||||
<span class="cat">{source.pollIntervalMinutes} min</span>
|
<span class="cat">{source.pollIntervalMinutes} min</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:starred={source.pushToTopStories}
|
||||||
|
onclick={() => toggleTopStories(source)}
|
||||||
|
title={source.pushToTopStories ? 'Pushing to Top Stories — click to stop' : 'Not pushed to Top Stories — click to enable'}
|
||||||
|
>
|
||||||
|
{source.pushToTopStories ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
<button class="icon-btn" onclick={() => startEdit(source)} title="Edit">✎</button>
|
<button class="icon-btn" onclick={() => startEdit(source)} title="Edit">✎</button>
|
||||||
<button class="icon-btn" onclick={() => toggleEnabled(source)} title={source.enabled ? 'Disable' : 'Enable'}>
|
<button class="icon-btn" onclick={() => toggleEnabled(source)} title={source.enabled ? 'Disable' : 'Enable'}>
|
||||||
{source.enabled ? '⏸' : '▶'}
|
{source.enabled ? '⏸' : '▶'}
|
||||||
@@ -292,6 +314,22 @@
|
|||||||
.category-check input {
|
.category-check input {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
.top-stories-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.top-stories-check input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.top-stories-check .hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.add-actions {
|
.add-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -308,7 +346,7 @@
|
|||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 96px;
|
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 120px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -386,4 +424,7 @@
|
|||||||
.icon-btn.danger:hover {
|
.icon-btn.danger:hover {
|
||||||
color: var(--text-danger);
|
color: var(--text-danger);
|
||||||
}
|
}
|
||||||
|
.icon-btn.starred {
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user