# Articles — Bulk URL Import ## Status (2026-04-28) **Phase 0 — Plan:** in progress. **Phasen 1–7:** offen. ## Ziel User wirft eine Liste von URLs in ein Textfeld (zeilengetrennt), Mana extrahiert + speichert alle Artikel im Hintergrund. Funktioniert auch wenn der Tab schließt, das Gerät wechselt, das Netz kurz weg ist. Der Job überlebt Sessions und ist auf jedem Gerät sichtbar an dem der User eingeloggt ist. Heute existiert nur Single-URL-Ingestion (`AddUrlForm`, `QuickAddInput`, Bookmarklets v1+v2, Share-Target). Alle Pfade rufen am Ende `articlesStore.saveFromUrl()` oder `saveFromExtracted()` auf. ## Leitsätze 1. **Job-State lebt in der synchronisierten DB**, nicht im Tab. Damit fallen Tab-Close-Resilienz, Multi-Device-Sicht, Resume-after-Offline und Audit automatisch ab. 2. **Server macht Extract, Client macht Encrypt.** Das ehrt das At-Rest- Modell — der Master-Key bleibt clientseitig, der Server sieht den extrahierten Text nur kurz in einer Pickup-Inbox (gleicher Threat- Model wie heute schon der `/extract`-Endpoint). 3. **Eine Code-Bahn für jede Ingestion.** Single-URL und Bulk laufen nach Phase 7 durch denselben Worker — der QuickAdd-Pfad legt unter der Haube auch einen 1-URL-Job an. 4. **Soft → Hard.** Schema- und Semantik-Migrationen kommen in zwei Commits: erst tolerant zu alten Rows, dann hartes Cleanup. ## Architektur ``` ┌──────────────────────────────────────────┐ │ /articles/import (List + JobDetail) │ │ pure liveQuery-View, UI macht keine │ │ Job-Logik selbst │ └─────────────────┬────────────────────────┘ │ liveQuery ▼ ┌────────────────────────────────────────────────────────┐ │ Dexie + mana-sync (articles appId) │ │ │ │ articleImportJobs │ │ id, spaceId, totalUrls, status, leasedBy, │ │ leasedUntil, savedCount, duplicateCount, │ │ errorCount, warningCount, finishedAt │ │ │ │ articleImportItems │ │ id, jobId, spaceId, idx, url, state, articleId, │ │ warning, error, attempts, lastAttemptAt │ │ │ │ articleExtractPickup (kurzlebige Inbox) │ │ id, itemId, payload (extracted), createdAt │ └─────────────────┬───────────────────────┬──────────────┘ │ pending items │ pickup rows ▼ ▼ ┌─────────────────────────────┐ ┌──────────────────────────┐ │ apps/api Extract-Worker │ │ Client Pickup-Consumer │ │ • snapshot der Items │ │ • liveQuery auf Pickup │ │ • Lease + Heartbeat │ │ • encryptRecord │ │ • Concurrency 3 / User │ │ • articleTable.add() │ │ • shared-rss extractFromUrl│ │ • item.state='saved' │ │ • schreibt Pickup-Row │ │ • pickup.delete() │ │ • setzt Item-State │ │ │ └─────────────────────────────┘ └──────────────────────────┘ ``` ## Datenmodell ### `articleImportJobs` (synced, articles appId) ```ts interface LocalArticleImportJob extends BaseRecord { totalUrls: number; status: 'queued' | 'running' | 'paused' | 'done' | 'cancelled'; /** Worker-Lease — verhindert dass mehrere Worker denselben Job ziehen. * Server-Worker stempelt seine workerId beim Claim, erneuert die * leasedUntil per Heartbeat. Lease-Ablauf > 60s = Job ist verfügbar. */ leasedBy: string | null; leasedUntil: string | null; startedAt: string | null; finishedAt: string | null; /** Counters werden vom Server beim Item-Übergang in einen Terminal-State * inkrementiert. Pure Bookkeeping — Truth liegt in den Item-Rows, das * hier ist die Cache-Spalte für die Liste. */ savedCount: number; duplicateCount: number; errorCount: number; warningCount: number; } ``` ### `articleImportItems` (synced, articles appId) ```ts type ImportItemState = | 'pending' // wartet auf den Worker | 'extracting' // Worker hat geclaimed | 'extracted' // Pickup-Row liegt für den Client bereit | 'saved' // im Article-Table angekommen | 'duplicate' // Article mit dieser URL gabs schon | 'consent-wall' // gespeichert, aber Cookie-Wand erkannt | 'error' // X Versuche fehlgeschlagen | 'cancelled'; // Job abgebrochen vor Verarbeitung interface LocalArticleImportItem extends BaseRecord { jobId: string; idx: number; // Reihenfolge aus der User-Eingabe url: string; // PLAINTEXT — Server muss lesen können state: ImportItemState; articleId: string | null; // bei saved/duplicate gesetzt warning: 'probable_consent_wall' | null; error: string | null; attempts: number; lastAttemptAt: string | null; } ``` **`url` bleibt bewusst plaintext** — der Server-Worker liest sie aus `sync_changes` und kann nicht entschlüsseln. Gleiche Begründung wie bei `articles.originalUrl` / `newsArticles.originalUrl` / `links.originalUrl`. `error` bleibt plaintext, weil Fehlertexte technisch sind ("502 Bad Gateway") und keinen User-Inhalt enthalten. ### `articleExtractPickup` (synced, articles appId, kurzlebig) ```ts interface LocalArticleExtractPickup extends BaseRecord { itemId: string; // Pointer zum Item — auch dessen jobId payload: ExtractedArticle; // PLAINTEXT — Server hat das eh createdAt: string; } ``` Inbox-Tabelle. Server schreibt rein, Client liest und löscht. Im Steady-State leer. TTL serverseitig: 24 h, dann GC. **Warum eine eigene Tabelle statt direkt `articles` schreiben?** Der Server hat keinen Master-Key — er kann den Article nicht verschlüsseln. Pickup ist die Übergabe-Pufferzone, der Client holt sie ab und ruft die existierende `saveFromExtracted()` auf, die `encryptRecord()` triggert. ### Crypto-Registry ```ts // articleImportJobs: keine User-typed Inhalte → plaintext-allowlist // articleImportItems: url + error sind plaintext, sonst nichts schützenswert → plaintext-allowlist // articleExtractPickup: payload wird gleich nach Apply gelöscht → plaintext-allowlist ``` Alle drei landen auf der `plaintext-allowlist.ts`, nicht in `ENCRYPTION_REGISTRY`. Items und Job-Rows enthalten keine User-typed- Felder die nicht eh schon plaintext bleiben müssten (URL fürs Routing, Counters, Foreign Keys). Der eigentliche Article-Inhalt wandert wie bisher verschlüsselt in `articles`. ### Module-Config + Sync `modules/articles/module.config.ts` bekommt drei neue Tabellen: ```ts tables: [ { name: 'articles' }, { name: 'articleHighlights', syncName: 'highlights' }, { name: 'articleTags' }, { name: 'articleImportJobs', syncName: 'importJobs' }, { name: 'articleImportItems', syncName: 'importItems' }, { name: 'articleExtractPickup', syncName: 'extractPickup' }, ] ``` Damit gehen sie automatisch durch den Standard-Sync-Pfad, RLS, field-level LWW. Keine neue Sync-Infrastruktur. ## Server-Worker ### Wo **`apps/api/src/modules/articles/import-worker.ts`**, gestartet aus `apps/api/src/index.ts` neben den Routes. Nicht in `services/mana-ai` (falscher Scope) und nicht in `services/mana-research` (Provider- Orchestrierung, kein Persistenz-Worker). ### Konzept Standard-Pattern aus `services/mana-ai`: 1. **Snapshot-Projektion** — eine kleine Tabelle in `mana_platform.articles_imports`-Schema die `sync_changes` für `appId='articles'` und `tableName ∈ {articleImportJobs, articleImportItems}` zu Live-Records faltet (field-level LWW). Refreshed sich pro Tick. 2. **Tick alle 2 s.** Liest die Snapshot, sucht: - Jobs mit `status='running'` und (`leasedBy=null` OR `leasedUntil < now`) - dazu Items mit `state='pending'` für diese Jobs 3. **Lease** — `leasedBy` auf eigene `workerId` setzen, `leasedUntil = now + 60s`. Schreiben als `sync_changes`-Row mit `actor=system`, `origin=system`, `source='articles-import-worker'`. 4. **Concurrency 3 pro Job** — pro Tick max 3 Items in `state='extracting'` schalten, `extractFromUrl()` aus `@mana/shared-rss` aufrufen. 5. **Pickup-Write** — bei Erfolg: `articleExtractPickup`-Row schreiben + Item-State auf `extracted`. Bei Fehler: `attempts += 1`, wenn `attempts >= 3` → `state='error'`, sonst zurück auf `pending`. 6. **Job-Completion** — wenn alle Items eines Jobs in einem Terminal-State sind (`saved | duplicate | consent-wall | error | cancelled`), setze `job.status='done'` + `finishedAt`. Counter-Spalten gleich mit aktualisieren. 7. **Heartbeat** — solange Items `extracting`, alle 30 s `leasedUntil` erneuern. ### Single-Instance-Garantie `pg_advisory_lock()` über die Worker-Loop. Falls apps/api in mehreren Instanzen läuft, nimmt nur eine den Lock und tickt. Andere idlen. ### Counters: woher Worker tracked Item-Übergänge und stempelt: - `pending → extracted`: keine Counter-Änderung - `extracted → saved` (Client signalisiert): `savedCount += 1` - `extracted → duplicate` (Client signalisiert): `duplicateCount += 1` - `extracted → consent-wall` (Client signalisiert): `warningCount += 1` - jeder Übergang → `error`: `errorCount += 1` Counter-Updates gehen als normale `articleImportJobs.update` durch `sync_changes`, RLS-correct. ### Server-side Cleanup Stündlicher GC-Job: - Pickup-Rows älter 24 h löschen (Sicherheits-Cap) - Jobs mit `status='done' AND finishedAt < now - 30d` archivieren (späteres Polish — erst mal nur Cap) ## Client-Pickup-Consumer `apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts`, gestartet aus `data-layer-listeners.ts` zusammen mit den anderen Listener-Wirings. Logik: ```ts liveQuery(() => articleExtractPickup .filter(r => !r.deletedAt) .toArray() ).subscribe(rows => { for (const row of rows) { void consumeOne(row); } }); async function consumeOne(row: LocalArticleExtractPickup) { // Re-entrancy guard via in-memory Set so multiple liveQuery ticks // don't race the same row. if (inFlight.has(row.id)) return; inFlight.add(row.id); try { const item = await articleImportItemTable.get(row.itemId); if (!item || item.state !== 'extracted') { await articleExtractPickupTable.delete(row.id); // Stale row return; } // Dedupe-Check für den Fall dass der User die URL parallel // single-saved hat während der Job lief. const existing = await articlesStore.findByUrl(row.payload.originalUrl); if (existing) { await articleImportItemTable.update(item.id, { state: 'duplicate', articleId: existing.id, }); await articleExtractPickupTable.delete(row.id); return; } const article = await articlesStore.saveFromExtracted(row.payload); const nextState: ImportItemState = row.payload.warning === 'probable_consent_wall' ? 'consent-wall' : 'saved'; await articleImportItemTable.update(item.id, { state: nextState, articleId: article.id, warning: row.payload.warning ?? null, }); await articleExtractPickupTable.delete(row.id); } finally { inFlight.delete(row.id); } } ``` Multi-Tab: alle Tabs sehen Pickup-Rows. Web-Lock `mana:articles:pickup` sorgt dafür dass nur ein Tab gleichzeitig konsumiert. Andere Tabs sehen die liveQuery, der Lock-halter pickt ab. ## Store-API `modules/articles/stores/imports.svelte.ts` — neue Datei. ```ts export const articleImportsStore = { /** Erzeugt Job + N Items in einem Dexie bulkAdd, returns jobId. */ async createJob(urls: string[]): Promise { … }, async pauseJob(jobId: string): Promise { … }, async resumeJob(jobId: string): Promise { … }, async cancelJob(jobId: string): Promise { … }, /** Setzt alle Error-Items eines Jobs zurück auf pending. */ async retryFailed(jobId: string): Promise { … }, /** Soft-Delete des Jobs + aller Items. Article-Rows bleiben. */ async deleteJob(jobId: string): Promise { … }, }; ``` `saveFromExtracted` in `modules/articles/stores/articles.svelte.ts` bleibt der gemeinsame Kern — der Pickup-Consumer ruft sie genauso auf wie der existierende Single-URL-Pfad. URL-Parser steht im Store, nicht im Component: ```ts export function parseUrls(raw: string): { valid: string[]; invalid: string[]; duplicates: string[]; } { … } ``` Pure Funktion, unit-testbar. ## UI ### `/articles/import` — Index + Eingabe (`+page.svelte`) - `` Komponente mit `