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 sourcesDb from '../storage/db/sources.js';
|
||||||
import * as eventsDb from '../storage/db/events.js';
|
import * as eventsDb from '../storage/db/events.js';
|
||||||
import * as categoriesDb from '../storage/db/categories.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 { OllamaProvider } from '../inference/ollama-provider.js';
|
||||||
import { pollSourceNow } from '../ingestion/poller.js';
|
import { pollSourceNow } from '../ingestion/poller.js';
|
||||||
import { logger, listLogs } from '../storage/db/logs.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) };
|
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 ---
|
// --- Tracked events ---
|
||||||
app.get('/api/admin/events', async () => eventsDb.listEvents());
|
app.get('/api/admin/events', async () => eventsDb.listEvents());
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface ClearResult {
|
|||||||
articlesDeleted: number;
|
articlesDeleted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReissueResult {
|
||||||
|
articlesDeleted: number;
|
||||||
|
itemsRequeued: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes every raw content item ingested from a source, plus any merged article that
|
* 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
|
* 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 };
|
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. */
|
/** Wipes every published article and its media, keeping raw ingested items intact so they can be re-synthesized fresh. */
|
||||||
export function clearAllArticles(): number {
|
export function clearAllArticles(): number {
|
||||||
const articles = articlesDb.allArticlesNewestFirst();
|
const articles = articlesDb.allArticlesNewestFirst();
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ export function assignCluster(ids: string[], clusterId: string) {
|
|||||||
for (const id of ids) stmt.run(clusterId, id);
|
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[] {
|
export function itemsByCluster(clusterId: string): ContentItem[] {
|
||||||
const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id = ?').all(clusterId);
|
const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id = ?').all(clusterId);
|
||||||
return rows.map(rowToItem);
|
return rows.map(rowToItem);
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export const deleteSource = (id: string, fetchFn?: typeof fetch) =>
|
|||||||
export const pollSourceNow = (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);
|
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.
|
// Content clearing — wipe articles/media/a source's raw items so they can be repopulated fresh.
|
||||||
export const clearSourceContent = (id: string, fetchFn?: typeof fetch) =>
|
export const clearSourceContent = (id: string, fetchFn?: typeof fetch) =>
|
||||||
request<{ itemsDeleted: number; articlesDeleted: number }>(`/api/admin/content/sources/${id}`, { method: 'DELETE' }, fetchFn);
|
request<{ itemsDeleted: number; articlesDeleted: number }>(`/api/admin/content/sources/${id}`, { method: 'DELETE' }, fetchFn);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AdminSource, CategoryPriority } from '$lib/adminTypes';
|
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: initial, categories }: { sources: AdminSource[]; categories: CategoryPriority[] } = $props();
|
||||||
let sources = $state([...initial]);
|
let sources = $state([...initial]);
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let pollingId = $state<string | null>(null);
|
let pollingId = $state<string | null>(null);
|
||||||
let clearingId = $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 justPolled = $state<{ id: string; count: number } | null>(null);
|
||||||
let justCleared = $state<{ id: string; items: number; articles: 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,
|
// "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
|
// 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) {
|
async function toggleEnabled(source: AdminSource) {
|
||||||
const updated = await updateSource(source.id, { enabled: !source.enabled });
|
const updated = await updateSource(source.id, { enabled: !source.enabled });
|
||||||
sources = sources.map((s) => (s.id === source.id ? updated : s));
|
sources = sources.map((s) => (s.id === source.id ? updated : s));
|
||||||
@@ -233,14 +255,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<div class="name">{source.name}</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
|
<div
|
||||||
class="sub"
|
class="sub"
|
||||||
class:error={source.lastError && !justPolled && !justCleared}
|
class:error={source.lastError && !justPolled && !justCleared && !justReissued}
|
||||||
class:success={justPolled?.id === source.id || justCleared?.id === source.id}
|
class:success={justPolled?.id === source.id || justCleared?.id === source.id || justReissued?.id === source.id}
|
||||||
>
|
>
|
||||||
{#if justCleared?.id === source.id}
|
{#if justCleared?.id === source.id}
|
||||||
✓ cleared {justCleared.items} item(s), {justCleared.articles} article(s)
|
✓ 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}
|
{:else if justPolled?.id === source.id}
|
||||||
{justPolled.count > 0 ? `✓ ${justPolled.count} new item(s)` : '✓ up to date, nothing new'}
|
{justPolled.count > 0 ? `✓ ${justPolled.count} new item(s)` : '✓ up to date, nothing new'}
|
||||||
{:else if source.lastError}
|
{:else if source.lastError}
|
||||||
@@ -273,6 +297,14 @@
|
|||||||
>
|
>
|
||||||
⟲
|
⟲
|
||||||
</button>
|
</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>
|
<button class="icon-btn danger" onclick={() => handleDelete(source.id)} title="Delete">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user