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:
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user