Add per-source "reissue" action: republish existing content fresh

Unlike "Clear content" (which deletes both the raw ingested items and
the published articles), reissue keeps the raw content_items and only
deletes the articles built from them, resetting cluster_id so those
items get picked up and republished by the very next scheduler tick.

This is for picking up pipeline/rendering changes on already-ingested
content without depending on the source feed to serve the same items
again — Nitter/Twitter in particular won't reliably resurface an old
tweet on a fresh poll. Same multi-source protection as clearing: an
article merged from this source's items together with another
source's is left alone entirely, since undoing just one contributor's
share of a merge isn't supported.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 18:19:53 +00:00
parent 668055fe7d
commit 9ec09e2b1b
5 changed files with 101 additions and 5 deletions
+12 -1
View File
@@ -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());
+42
View File
@@ -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<string>();
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();
+6
View File
@@ -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);
+5
View File
@@ -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);
@@ -1,6 +1,6 @@
<script lang="ts">
import type { AdminSource, CategoryPriority } from '$lib/adminTypes';
import { addSource, deleteSource, updateSource, pollSourceNow, clearSourceContent } from '$lib/adminApi';
import { addSource, deleteSource, updateSource, pollSourceNow, clearSourceContent, reissueSourceContent } from '$lib/adminApi';
let { sources: initial, categories }: { sources: AdminSource[]; categories: CategoryPriority[] } = $props();
let sources = $state([...initial]);
@@ -8,8 +8,10 @@
let editingId = $state<string | null>(null);
let pollingId = $state<string | null>(null);
let clearingId = $state<string | null>(null);
let reissuingId = $state<string | null>(null);
let justPolled = $state<{ id: string; count: number } | null>(null);
let justCleared = $state<{ id: string; items: number; articles: number } | null>(null);
let justReissued = $state<{ id: string; articles: number; items: number } | null>(null);
// "Top stories" isn't a real filterable tag — it's the homepage's all-categories,
// chronological view (see /api/feed's no-filter default and +layout.svelte's nav
@@ -118,6 +120,26 @@
}
}
async function handleReissue(source: AdminSource) {
if (
!confirm(
`Republish "${source.name}"'s content fresh? Its already-published articles will be deleted and rebuilt from the same raw items using the current pipeline — nothing is re-fetched from the feed. Articles merged with other sources are left alone.`
)
)
return;
reissuingId = source.id;
justReissued = null;
try {
const { articlesDeleted, itemsRequeued } = await reissueSourceContent(source.id);
justReissued = { id: source.id, articles: articlesDeleted, items: itemsRequeued };
setTimeout(() => {
if (justReissued?.id === source.id) justReissued = null;
}, 4000);
} finally {
reissuingId = null;
}
}
async function toggleEnabled(source: AdminSource) {
const updated = await updateSource(source.id, { enabled: !source.enabled });
sources = sources.map((s) => (s.id === source.id ? updated : s));
@@ -233,14 +255,16 @@
</button>
<div>
<div class="name">{source.name}</div>
{#if justCleared?.id === source.id || justPolled?.id === source.id || source.lastError}
{#if justCleared?.id === source.id || justReissued?.id === source.id || justPolled?.id === source.id || source.lastError}
<div
class="sub"
class:error={source.lastError && !justPolled && !justCleared}
class:success={justPolled?.id === source.id || justCleared?.id === source.id}
class:error={source.lastError && !justPolled && !justCleared && !justReissued}
class:success={justPolled?.id === source.id || justCleared?.id === source.id || justReissued?.id === source.id}
>
{#if justCleared?.id === source.id}
✓ cleared {justCleared.items} item(s), {justCleared.articles} article(s)
{:else if justReissued?.id === source.id}
✓ deleted {justReissued.articles} article(s), requeued {justReissued.items} item(s) — republishing within ~1 min
{:else if justPolled?.id === source.id}
{justPolled.count > 0 ? `✓ ${justPolled.count} new item(s)` : '✓ up to date, nothing new'}
{:else if source.lastError}
@@ -273,6 +297,14 @@
>
</button>
<button
class="icon-btn"
onclick={() => handleReissue(source)}
disabled={reissuingId === source.id}
title="Reissue: delete published articles and republish from the same raw items"
>
🔁
</button>
<button class="icon-btn danger" onclick={() => handleDelete(source.id)} title="Delete"></button>
</div>
</div>