diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 72ac85f..43c6dcd 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -3,7 +3,7 @@ import * as settingsDb from '../storage/db/settings.js'; import * as sourcesDb from '../storage/db/sources.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; -import { clearSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; +import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import { pollSourceNow } from '../ingestion/poller.js'; import { logger, listLogs } from '../storage/db/logs.js'; @@ -95,6 +95,17 @@ export async function registerAdminRoutes(app: FastifyInstance) { return { ingested, source: sourcesDb.getSource(id) }; }); + // Deletes this source's already-published articles and requeues their raw items for + // re-publish (see contentCascade.reissueSourceContent) — for picking up pipeline + // changes (e.g. a new tweet card layout) without waiting on the feed to resurface + // the same items again. + app.post('/api/admin/sources/:id/reissue', async (req, reply) => { + const { id } = req.params as { id: string }; + const source = sourcesDb.getSource(id); + if (!source) return reply.code(404).send({ error: 'not found' }); + return reissueSourceContent(id); + }); + // --- Tracked events --- app.get('/api/admin/events', async () => eventsDb.listEvents()); diff --git a/backend/src/storage/contentCascade.ts b/backend/src/storage/contentCascade.ts index eea9bca..3757245 100644 --- a/backend/src/storage/contentCascade.ts +++ b/backend/src/storage/contentCascade.ts @@ -15,6 +15,11 @@ export interface ClearResult { articlesDeleted: number; } +export interface ReissueResult { + articlesDeleted: number; + itemsRequeued: number; +} + /** * Removes every raw content item ingested from a source, plus any merged article that * was composed entirely from that source's items (so it doesn't linger on the site @@ -44,6 +49,43 @@ export function clearSourceContent(sourceId: string): ClearResult { return { itemsDeleted: itemIds.size, articlesDeleted }; } +/** + * Deletes a source's already-published articles (and their media) so they can be + * republished fresh through the current pipeline — unlike clearSourceContent, the raw + * content_items are kept, since Twitter/RSS feeds don't reliably keep serving the same + * historical items on the next poll. Reset cluster_id is what makes an item eligible + * again: the next scheduler tick (poll or synthesis, within about a minute) picks it up + * and re-publishes it exactly like a newly-ingested item. + * + * Same restriction as clearSourceContent: an article merged from this source's items + * together with other sources' is left alone entirely (and its items stay clustered) — + * there's no supported way to un-merge just one contributor's share back out of it. + */ +export function reissueSourceContent(sourceId: string): ReissueResult { + const items = contentItemsDb.itemsForSource(sourceId); + const itemIds = new Set(items.map((i) => i.id)); + + let articlesDeleted = 0; + const requeueIds = new Set(); + if (itemIds.size > 0) { + for (const article of articlesDb.allArticlesNewestFirst()) { + if (article.sources.length > 0 && article.sources.every((s) => itemIds.has(s.itemId))) { + deleteMediaByArticleId(article.id); + articlesDb.deleteArticle(article.id); + articlesDeleted++; + for (const s of article.sources) requeueIds.add(s.itemId); + } + } + } + + contentItemsDb.resetClusterForItems([...requeueIds]); + logger.info( + 'admin', + `Reissuing content for source ${sourceId}: ${articlesDeleted} article(s) deleted, ${requeueIds.size} item(s) requeued` + ); + return { articlesDeleted, itemsRequeued: requeueIds.size }; +} + /** Wipes every published article and its media, keeping raw ingested items intact so they can be re-synthesized fresh. */ export function clearAllArticles(): number { const articles = articlesDb.allArticlesNewestFirst(); diff --git a/backend/src/storage/db/contentItems.ts b/backend/src/storage/db/contentItems.ts index f4942b2..471afdd 100644 --- a/backend/src/storage/db/contentItems.ts +++ b/backend/src/storage/db/contentItems.ts @@ -85,6 +85,12 @@ export function assignCluster(ids: string[], clusterId: string) { for (const id of ids) stmt.run(clusterId, id); } +/** Clears cluster_id back to NULL, making these items eligible for re-publish on the next poll/synthesis tick. */ +export function resetClusterForItems(ids: string[]) { + const stmt = db.prepare('UPDATE content_items SET cluster_id = NULL WHERE 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); diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index c0c1ca6..5ee90b4 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -85,6 +85,11 @@ export const deleteSource = (id: string, fetchFn?: typeof fetch) => export const pollSourceNow = (id: string, fetchFn?: typeof fetch) => request<{ ingested: number; source: AdminSource }>(`/api/admin/sources/${id}/poll`, { method: 'POST' }, fetchFn); +// Deletes this source's published articles and requeues their raw items for republish — +// picks up pipeline changes without needing the feed to resurface the same items. +export const reissueSourceContent = (id: string, fetchFn?: typeof fetch) => + request<{ articlesDeleted: number; itemsRequeued: number }>(`/api/admin/sources/${id}/reissue`, { method: 'POST' }, fetchFn); + // Content clearing — wipe articles/media/a source's raw items so they can be repopulated fresh. export const clearSourceContent = (id: string, fetchFn?: typeof fetch) => request<{ itemsDeleted: number; articlesDeleted: number }>(`/api/admin/content/sources/${id}`, { method: 'DELETE' }, fetchFn); diff --git a/frontend/src/lib/components/admin/SourcesTab.svelte b/frontend/src/lib/components/admin/SourcesTab.svelte index bb83676..b6b73af 100644 --- a/frontend/src/lib/components/admin/SourcesTab.svelte +++ b/frontend/src/lib/components/admin/SourcesTab.svelte @@ -1,6 +1,6 @@