Files
homefeed/backend/src/storage/db/contentItems.ts
T
Claude e204c70e00 Fix source deletion, add content clearing, multi-category/editable sources, News category, wider layout, and a YouTube source module
- Fix "Body cannot be empty" error on DELETE by making the JSON content-type
  parser tolerate empty bodies, and by only sending Content-Type from the
  frontend when a request actually has one.
- Deleting a source now cascades: raw content items and any article composed
  entirely from that source are removed too, plus their media.
- Add admin endpoints/UI to clear all articles, all media, or a single
  source's content without deleting the source, so things can be repopulated
  fresh.
- Sources can now be assigned multiple categories via checkboxes (instead of
  free text) and edited in place, not just added/deleted.
- Add a "News" default category (seeded fresh, backfilled on existing DBs) so
  general news sources have a real home instead of the pseudo-category "Top
  stories", which is just the homepage's all-categories chronological view.
- Widen the site's content column 15% (1080px -> 1242px).
- Add YouTube as its own source type/ingestion module: pulls a channel's
  public Atom feed, and each video always publishes directly as its own
  article (title, embedded video, publish date, description) rather than
  going through the cross-source clustering/synthesis pipeline.
2026-07-21 18:09:20 +00:00

114 lines
3.7 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { ContentItem } from './types.js';
function rowToItem(row: any): ContentItem {
return {
id: row.id,
sourceId: row.source_id,
type: row.type,
title: row.title,
summary: row.summary,
body: row.body,
images: JSON.parse(row.images),
videos: JSON.parse(row.videos),
link: row.link,
publishedAt: row.published_at,
fetchedAt: row.fetched_at,
tags: JSON.parse(row.tags),
geo: row.geo,
embedding: row.embedding ? JSON.parse(row.embedding) : null,
eventId: row.event_id,
clusterId: row.cluster_id,
raw: row.raw ? JSON.parse(row.raw) : null
};
}
export function insertContentItem(item: Omit<ContentItem, 'id'>): ContentItem {
const id = `ci-${randomUUID()}`;
db.prepare(
`INSERT INTO content_items
(id, source_id, type, title, summary, body, images, videos, link, published_at, fetched_at, tags, geo, embedding, event_id, cluster_id, raw)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
item.sourceId,
item.type,
item.title,
item.summary,
item.body,
JSON.stringify(item.images),
JSON.stringify(item.videos),
item.link,
item.publishedAt,
item.fetchedAt,
JSON.stringify(item.tags),
item.geo,
item.embedding ? JSON.stringify(item.embedding) : null,
item.eventId,
item.clusterId,
item.raw ? JSON.stringify(item.raw) : null
);
return { ...item, id };
}
/** Avoids re-ingesting the same story twice on repeated polls. */
export function existsByLink(link: string): boolean {
const row = db.prepare('SELECT id FROM content_items WHERE link = ?').get(link);
return !!row;
}
export function unclusteredItemsExcludingSources(excludeSourceIds: string[]): ContentItem[] {
const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id IS NULL').all();
const items = rows.map(rowToItem);
if (excludeSourceIds.length === 0) return items;
return items.filter((i) => !excludeSourceIds.includes(i.sourceId));
}
export function unclusteredItemsForSources(sourceIds: string[], sinceISO: string): ContentItem[] {
if (sourceIds.length === 0) return [];
const placeholders = sourceIds.map(() => '?').join(',');
const rows = db
.prepare(`SELECT * FROM content_items WHERE cluster_id IS NULL AND source_id IN (${placeholders}) AND fetched_at > ?`)
.all(...sourceIds, sinceISO);
return rows.map(rowToItem);
}
export function setEmbedding(id: string, embedding: number[]) {
db.prepare('UPDATE content_items SET embedding = ? WHERE id = ?').run(JSON.stringify(embedding), id);
}
export function assignCluster(ids: string[], clusterId: string) {
const stmt = db.prepare('UPDATE content_items SET cluster_id = ? WHERE id = ?');
for (const id of ids) stmt.run(clusterId, id);
}
export function itemsByCluster(clusterId: string): ContentItem[] {
const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id = ?').all(clusterId);
return rows.map(rowToItem);
}
export function itemsOlderThan(days: number): ContentItem[] {
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
const rows = db.prepare('SELECT * FROM content_items WHERE fetched_at < ?').all(cutoff);
return rows.map(rowToItem);
}
export function deleteContentItems(ids: string[]) {
const stmt = db.prepare('DELETE FROM content_items WHERE id = ?');
for (const id of ids) stmt.run(id);
}
export function itemsForSource(sourceId: string): ContentItem[] {
const rows = db.prepare('SELECT * FROM content_items WHERE source_id = ?').all(sourceId);
return rows.map(rowToItem);
}
export function deleteContentItemsForSource(sourceId: string) {
db.prepare('DELETE FROM content_items WHERE source_id = ?').run(sourceId);
}
export function deleteAllContentItems() {
db.prepare('DELETE FROM content_items').run();
}