managarten/docs/plans/articles-module.md
Till JS 7611d109be feat(articles): M8 highlights view + stats + dashboard widget
useStats() live-query aggregates total / per-status / savedThisWeek /
finishedThisWeek / topSites / totalHighlights in one scoped Dexie pass.
useAllHighlights() joins cross-article highlights with article-header
info (title, siteName, originalUrl) for rendering.

/articles/highlights — HighlightsView groups chronologically-sorted
highlights per article with color-accented stripes, click-to-reader
jumps, and two export actions:
  - Copy as Markdown (clipboard)
  - Download .md (file)
Export logic lives in lib/markdown-export.ts as a pure function
(renderHighlightsMarkdown) so future snapshot tests don't need the
render tree.

Dashboard widget: ArticlesUnreadWidget mirrors NewsUnreadWidget's
pattern — self-contained live query, top-3 unread/reading, stats
strip ("N ungelesen · M diese Woche gespeichert"), empty state
CTA to /articles/add. Registered in:
  - lib/types/dashboard.ts (WidgetType union + WIDGET_REGISTRY)
  - lib/components/dashboard/widget-registry.ts (component map)
  - lib/i18n/locales/dashboard/{de,en}.json (translations)
  fr/it/es intentionally left untranslated — consistent with how
  invoices_open and broadcasts are handled.

ListView gains a pencil button next to the settings gear linking
to /articles/highlights.

Also: plan doc marks M7 + M8 done with commit refs; M1–M8 scope is
now complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:12:18 +02:00

406 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Articles — Module Plan
## Status (2026-04-21)
**M1 Skelett: DONE** (commit `3357e88a1`) — Modul registriert, Dexie v33, Encryption-Registry-Einträge (articles + articleHighlights verschlüsselt, articleTags auf Plaintext-Allowlist als FK-Junction ins globale Tag-System), App-Registry + orangefarbenes Icon, Route mountet, Empty-State ListView.
**M2 URL-Save + Reader: DONE** (commit `3357e88a1`) — `POST /api/v1/articles/extract` (ein Endpoint statt zwei — Client cached die Preview und vermeidet doppelten Server-Fetch), AddUrlForm mit scope-aware Dedupe, DetailView mit ReaderView (Serif/Sans, Light/Sepia/Dark, Size-Slider), auto-getrackter Reading-Progress mit Scroll-Restore.
**M3 Highlights: DONE** (commit `3357e88a1`) — TreeWalker-basierte Plain-Text-Offset-Resolution (`lib/offsets.ts`), `highlightsStore`, Floating `HighlightMenu` mit Create/Edit-Modi, `HighlightLayer`-Orchestrator der Overlays bei jedem Highlights- oder HTML-Change unwrap+re-applies (Range.surroundContents pro Text-Node-Slice). Vier Farben mit Dark-Mode-angepassten Alpha-Werten.
**M4 Tags + Filter: DONE** (commit `04293ed5e`) — `useArticleTagIds` / `useArticleTagMap` Live-Queries gegen `articleTagOps`. DetailView bekommt `<TagField>` aus `@mana/shared-ui` mit globalem Tag-Pool; `onChange``articleTagOps.setTags(id, ids)`. ListView: 6 Filter-Chips (Alle / Ungelesen / In Arbeit / Gelesen / Favoriten / Archiv) mit Live-Counts, `<TagChip>` auf jeder Karte, neue `.status-reading`-Pill, Favoriten-Stern.
**M5 Migration von news:type='saved': DONE** (commit `04293ed5e`) — Boot-gated Migration in `modules/articles/migrations/from-news.ts` (localStorage-Sentinel `mana:articles:from-news-migration:v1`), decrypt→re-encrypt zwischen den beiden Field-Allowlists, Status-Mapping `isArchived→archived` / `isRead→finished` / sonst `unread`, Source-Rows werden soft-deletet. News-Code deprecated: `saveFromUrl` + `extractFromUrl` entfernt, `save_news_article` AI-Tool behält seinen Namen (wegen Mission-History) und leitet intern aufs `articles`-Modul um. `/news/add` + `/news/saved` sind Redirects. `news-research` „Speichern"-Buttons routen auf `/articles/[id]`.
**M6 AI-Tools: DONE** (commit `5924f4fac`) — 5 neue Einträge im `AI_TOOL_CATALOG` (`shared-ai/src/tools/schemas.ts`): `list_articles` (auto), `save_article` / `archive_article` / `tag_article` / `add_article_highlight` (alle propose). `modules/articles/tools.ts` enthält die `execute`-Funktionen, registriert in `data/tools/init.ts`. `tag_article` dedupliziert case-insensitive über den globalen Pool und legt Tags via `tagMutations.createTag` an falls nötig. `add_article_highlight` snappt auf die erste wörtliche Fundstelle in `article.content` und lehnt den Call ab wenn der Text nicht exakt vorkommt (kein Orphan-Highlight). Policy/Executor/Server-Planner leiten sich automatisch aus dem Katalog ab.
**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden.
**M7 Share-Target + Bookmarklet: DONE** (commit `8a991f7c3`) — `@mana/shared-pwa` bekommt neue Types (`PWAShareTarget`, `PWAShareTargetParams`), `createPWAConfig` threadet `shareTarget` in den Manifest, `ManifestConfig.share_target?` ergänzt. Web-App: `vite.config.ts` setzt `shareTarget: { action: '/articles/add', method: 'GET', params: { title, text, url } }`; `AddUrlForm` liest Query-Params in `onMount` (inkl. URL-Regex-Fallback auf `text` weil Chrome Android / WhatsApp den Link dort reinstecken), triggert auto-Vorschau. Neue Route `/articles/settings` rendert Bookmarklet-Karte (Drag-to-Bookmark + Copy-Snippet + expandable Quellcode) und Share-Target-Erklärung. `ListView` bekommt Zahnrad-Button zum Settings-Aufruf.
Nicht im Scope (bewusst ausgelassen): die „optional" im Plan markierte `_pendingUrls`-Offline-Queue. Kann als M7b nachgereicht werden wenn das Problem auftaucht.
**M8 HighlightsView + Stats + Dashboard-Widget: DONE** (commit pending) — `queries.ts` bekommt `useStats()` (total, pro-Status, savedThisWeek, finishedThisWeek, topSites, totalHighlights) und `useAllHighlights()` (chronologisch, joined mit Artikel-Header-Info). Neue Route `/articles/highlights` mountet `HighlightsView.svelte`: Highlights pro Artikel gruppiert, farbige Akzent-Striche, Click-to-Reader, Copy-Markdown + Download-.md via `lib/markdown-export.ts` (pure Funktion, unit-testbar). Dashboard-Widget `ArticlesUnreadWidget` zeigt Top-3 unread + Stats-Strip; registriert in `widget-registry.ts`, `dashboard.ts` `WidgetType`-Union + `WIDGET_REGISTRY`; i18n-Keys in de.json + en.json (fr/it/es konsistent mit anderen Widgets weggelassen). ListView bekommt ✎-Button zum Highlights-Aufruf neben dem Settings-Zahnrad.
Damit ist der komplette M1M8-Scope aus diesem Plan umgesetzt. Phase-3-Kandidaten (Highlight→Note-Export mit Backlink, Full-Text-Search, Mercury/archive.org-Fallback, Jahresrückblick) bleiben offen für spätere Iterationen.
## Ziel
Ein dediziertes Pocket-/Instapaper-Ersatzmodul: der Nutzer speichert beliebige Web-URLs, der Inhalt wird serverseitig mit Readability extrahiert, landet verschlüsselt in IndexedDB und ist danach **offline lesbar** im eigenen Reader-View — mit Highlights, Tags, Notizen und Reading-Progress.
Kernfrage: *„Ich will diesen Artikel später in Ruhe lesen."*
Nicht im Scope: Web-Browser-Extension mit automatischem Save (kommt in M7 als PWA-Share-Target/Bookmarklet), Social-Features, Public-Sharing, Full-Text-Search-Index. Kein Highlights-Export in andere Tools (Phase 3).
## Abgrenzung zu bestehenden Modulen
- **`news`**: bleibt der **kuratierte Feed** aus dem server-seitigen `curated_articles`-Pool + Reaktionen/Preferences. Die dortige `type: 'saved'`-Funktion (ad-hoc URL-Save) wird auf `articles` migriert und im News-Modul deprecated. `/news/saved` entfällt, `/news/add` fällt weg (Redirect auf `/articles/add`).
- **`library`**: konsumierte Medien (Bücher, Filme, Serien, Comics). Keine Web-Artikel.
- **`guides`**: eigene strukturierte Schritt-für-Schritt-Anleitungen. Kein Web-Extract.
- **`notes`**: freie Notizen, kein Web-Extract + Reader-View.
- **`kontext`**: URL-Crawl für AI-Kontext (Singleton-Doc, nicht pro Artikel). Überlappt nicht.
## Entscheidungen vorab
- **Name `articles` + appId `articles`** (nicht `pocket` — markenneutral, klar, generisch).
- **Shared Extract statt neuem Code:** `@mana/shared-rss` bietet bereits `extractFromUrl()` (Readability + JSDOM, siehe `packages/shared-rss/src/extract.ts`). Beide Module nutzen dasselbe Package. Kein Refactor der Bibliothek nötig.
- **Eigener API-Endpoint:** `/api/v1/articles/extract/preview` + `/api/v1/articles/extract/save` in `apps/api/src/modules/articles/routes.ts` — dupliziert einen kleinen Handler, damit das articles-Modul nicht auf `news/routes.ts` angewiesen ist. Die eigentliche Extraktion passiert weiterhin in `shared-rss`.
- **Drei Tabellen statt einer:**
- `articles` — Haupttabelle (extrahierter Inhalt + Reading-State)
- `articleHighlights` — pro Highlight eine Row (Offset-Range + optionale Notiz)
- `articleTags` — reine **Junction-Tabelle** `(id, articleId, tagId)` ins globale Tag-System
Begründung: Highlights brauchen eigene Write-Pfade (Select → Save) und eigene Encryption-Felder. Tags **sind bereits global** als Kern-Infra (`globalTags`/`tagGroups` mit `appId: 'tags'`, siehe `apps/mana/apps/web/src/lib/modules/core/module.config.ts`) — jedes Modul hält nur eine schlanke Junction. Kein eigener Name/Farbe/Sortierung — das lebt zentral.
- **`originalUrl` bleibt plaintext.** Dedupe-Key, gleiche Begründung wie bei `newsArticles.originalUrl` und `uLoad.links.originalUrl` in der Encryption-Registry.
- **Content + Titel + Excerpt + Author + Highlights + Notizen bleiben verschlüsselt.** Reading-Behavior ist GDPR-sensitiv — gleiche Schutzklasse wie `newsArticles`.
- **Kein Client-side Extract im ersten Schritt.** JSDOM läuft nur serverseitig. Offline-gepuffertes Save-Later (z.B. aus PWA-Share-Target ohne Internet) geht in eine lokale `_pendingUrls`-Queue und wird beim nächsten Online-Sync extrahiert. Das kommt in M7.
- **Migration der `news`-saved-Rows:** einmaliger Upgrade-Hook in der Dexie-Schema-Migration — alle `newsArticles` mit `type='saved'` wandern nach `articles`. Danach kann die News-Types um das `type`-Discriminator-Feld und den `saveFromUrl`-Pfad verschlankt werden.
## Modul-Struktur
```
apps/mana/apps/web/src/lib/modules/articles/
├── types.ts # LocalArticle, LocalHighlight, LocalTag, public DTOs
├── collections.ts # articleTable, highlightTable, tagTable + Defaults
├── queries.ts # useAllArticles, useArticle(id), useHighlights(articleId), useTags, toArticle/toHighlight/toTag
├── api.ts # fetch wrappers for /api/v1/articles/extract/*
├── stores/
│ ├── articles.svelte.ts # saveFromUrl, markRead, toggleFavorite, archive, setProgress, delete
│ ├── highlights.svelte.ts # addHighlight, updateHighlightNote, deleteHighlight
│ └── tags.svelte.ts # Vier-Zeiler: re-export aus @mana/shared-stores + articleTagOps = createTagLinkOps({...})
├── components/
│ ├── ArticleCard.svelte # Listeneintrag (Cover + Titel + Excerpt + Reading-Time + Status-Badge)
│ ├── AddUrlForm.svelte # URL-Paste + Preview + Save
│ ├── ReaderView.svelte # Reader-Typografie (Serif/Sans, Größe, Zeilenhöhe, Sepia/Dunkel)
│ ├── HighlightLayer.svelte # Overlay für bestehende Highlights + Selection-Handler
│ ├── HighlightMenu.svelte # Floating-Menu bei Text-Selection (Farbe + Notiz + Save)
│ ├── TagPicker.svelte # Multi-Select mit Inline-Create
│ ├── TagChip.svelte # Farbige Chip-Darstellung
│ ├── ProgressBar.svelte # Reading-Fortschritt (0..1)
│ └── StatusFilter.svelte # Alle | Ungelesen | Favoriten | Archiv
├── views/
│ ├── ListView.svelte # Modul-Root (List + Filter + FAB)
│ ├── DetailView.svelte # Reader-View + Highlight-Layer + Tag/Action-Bar
│ └── HighlightsView.svelte # Sammelansicht über alle Artikel (Phase 2)
├── tools.ts # AI-Tools — siehe AI-Integration
├── constants.ts # READER_FONTS, READER_THEMES, DEFAULT_HIGHLIGHT_COLORS
├── module.config.ts # { appId: 'articles', tables: [...] }
└── index.ts # Re-Exports
```
## Daten-Schema
### `LocalArticle`
```typescript
export type ArticleStatus = 'unread' | 'reading' | 'finished' | 'archived';
export interface LocalArticle extends BaseRecord {
originalUrl: string; // plaintext — Dedupe-Key
title: string; // encrypted
excerpt: string | null; // encrypted
content: string; // encrypted — plain text (fallback)
htmlContent: string | null; // encrypted — sanitisiertes HTML (Reader)
author: string | null; // encrypted
siteName: string | null; // plaintext — Filter (nach Quelle gruppieren)
imageUrl: string | null; // plaintext — Externe URL / media-Ref
wordCount: number | null; // plaintext — Reading-Time, Stats
readingTimeMinutes: number | null; // plaintext
publishedAt: string | null; // plaintext ISO — Sort/Filter
// Reading-State
status: ArticleStatus; // plaintext — Haupt-Filter
readingProgress: number; // plaintext 0..1 — Scroll-Position beim Re-Open
isFavorite: boolean; // plaintext
savedAt: string; // plaintext ISO
readAt: string | null; // plaintext ISO — wann zuerst „finished"
// Organisation — KEIN tagIds: string[] direkt auf dem Record.
// Tag-Zuordnung lebt ausschließlich in der Junction-Tabelle `articleTags`.
// Gelesen via `articleTagOps.getTagIds(id)` / `getTagIdsForMany(ids)`.
userNote: string | null; // encrypted — freie Notiz des Users zum Artikel
// Meta
extractedVersion: number; // plaintext — falls wir später re-extrahieren
}
```
### `LocalHighlight`
```typescript
export type HighlightColor = 'yellow' | 'green' | 'blue' | 'pink';
export interface LocalHighlight extends BaseRecord {
articleId: string; // plaintext — FK, indexed
text: string; // encrypted — der markierte Text
note: string | null; // encrypted — optionale Notiz
color: HighlightColor; // plaintext
/** Offsets ins extrahierte `content`-Feld (Plain-Text-Offset). HTML-Rendering
* mapped im HighlightLayer von Plain-Offset → DOM-Range. */
startOffset: number; // plaintext
endOffset: number; // plaintext
/** Kontext-Snippet (ca. 50 chars vorher/nachher), falls das Article-Content
* später re-extrahiert wird und die Offsets verrutschen — dann kann man
* anhand des Snippets re-anchorn. */
contextBefore: string | null; // encrypted
contextAfter: string | null; // encrypted
}
```
### `articleTags` (Junction → globalTags)
Keine eigene Tag-Entity. Schema ist identisch zu `noteTags`, `eventTags`, `contactTags` etc.:
```typescript
export interface LocalArticleTag {
id: string;
articleId: string;
tagId: string; // FK → globalTags.id
userId?: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string;
}
```
Tag-Namen, Farben, Gruppen leben zentral in `globalTags` / `tagGroups` und werden via `@mana/shared-stores`-Helpers abgefragt (`useAllTags`, `getTagById`, `getTagsByIds`, `getTagColor`, `tagMutations`).
### Dexie-Indizes
```typescript
// apps/mana/apps/web/src/lib/data/database.ts — neue Version N+1:
articles: 'id, userId, status, savedAt, isFavorite, siteName, originalUrl',
articleHighlights: 'id, userId, articleId, [articleId+startOffset]',
articleTags: 'id, userId, articleId, tagId, [articleId+tagId]',
```
- `originalUrl` indexiert für O(1)-Dedupe beim Save.
- `[articleId+startOffset]` für sortierten Highlight-Render im Reader.
- `status` für den Main-Filter (Unread/Reading/Finished/Archived).
- `[articleId+tagId]` matcht das Pattern aller anderen Tag-Junctions (N+1-freies Batch-Read via `getTagIdsForMany`).
### Encryption-Registry
`apps/mana/apps/web/src/lib/data/crypto/registry.ts` — drei neue Einträge:
```typescript
// ─── Articles ────────────────────────────────────────────
// Pocket-style read-it-later. Same sensitivity class as newsArticles —
// reading behaviour is GDPR-relevant. originalUrl stays plaintext
// (dedupe key, same rationale as newsArticles.originalUrl / links.originalUrl).
// siteName plaintext for the "group by source" view.
articles: entry<LocalArticle>(['title', 'excerpt', 'content', 'htmlContent', 'author', 'userNote']),
// Highlights carry the marked text + an optional user note. The article
// FK stays plaintext (indexed for range scans in the reader). Offsets +
// color are structural. Context snippets are fragments of encrypted
// content and are therefore themselves encrypted.
articleHighlights: entry<LocalHighlight>(['text', 'note', 'contextBefore', 'contextAfter']),
// articleTags ist NICHT registriert — pure FK-Junction (articleId, tagId),
// zero user-typed content. Gleicher Pattern wie noteTags, eventTags,
// contactTags, placeTags, manaLinks: Tag-Namen leben in globalTags und
// haben dort ihre eigene Encryption-Policy. Eintrag in plaintext-allowlist.ts.
```
## Routing
```
apps/mana/apps/web/src/routes/(app)/articles/
├── +page.svelte # ListView
├── add/+page.svelte # AddUrlForm — paste URL → preview → save
├── [id]/+page.svelte # DetailView (Reader + Highlights)
└── highlights/+page.svelte # HighlightsView (Phase 2)
```
Deep-Links:
- `/articles?status=unread` — vorgefilterte Liste
- `/articles?tag=<tagId>` — nach Tag gefiltert
- `/articles/add?url=...` — aus externem Share-Target (M7) vorbefüllt
## UI-Konzept
### Landing (`/articles`)
- **Top-Bar:** Status-Filter-Segmented-Control (Alle | Ungelesen | In Arbeit | Favoriten | Archiv), Tag-Chips horizontal scrollbar, Sort (Neu gespeichert | Lesezeit | Titel).
- **Liste:** Kachel- oder Zeilen-View (Toggle). Kachel zeigt Cover (16:9), Titel, Excerpt (2 Zeilen), Site-Name, Reading-Time, Status-Badge. Zeile kompakter.
- **FAB:** „+" öffnet `/articles/add`.
- **Empty-State:** „Noch nichts gespeichert" + CTA „Erste URL einfügen" (+ SceneScopeEmptyState wenn Scope aktiv — gleiches Pattern wie andere Module).
### AddUrlForm (`/articles/add`)
- URL-Input (groß, Autofocus).
- „Vorschau abrufen" → Call auf `/api/v1/articles/extract/preview` → zeigt Titel, Excerpt, Cover, Lesezeit.
- „Speichern" → Call auf `/extract/save` + `articlesStore.saveFromUrl(url)`.
- Dedupe: beim Paste sofort `articleTable.where('originalUrl').equals(url).first()` — wenn bereits vorhanden, statt Save direkt auf bestehenden Artikel routen.
- Optional im selben Dialog: Tags vor dem Speichern setzen, Notiz hinzufügen.
### DetailView (`/articles/[id]`)
- **Header:** Titel, Author, Site-Name, Published-Date, Wordcount/Lesezeit.
- **Reader-Body:** rendert `htmlContent` mit sanitisierendem DOMPurify durch eine Reader-Typografie-Schale (Serif-Default, konfigurierbar Größe/Zeilenhöhe/Theme).
- **Action-Bar (sticky):**
- Als gelesen markieren / entmarkieren
- Favorit-Toggle
- Archivieren
- Tags-Picker
- „Original öffnen" (external link)
- Notiz
- Löschen
- **Highlight-Layer:**
- Bei Text-Selection erscheint `HighlightMenu` mit 4 Farben + Notiz-Feld.
- Beim Save: `highlightsStore.addHighlight({ articleId, text, startOffset, endOffset, color, contextBefore, contextAfter })`.
- Bestehende Highlights werden beim Render als gefärbte Spans überlagert (Plain-Text-Offset → DOM-Range-Resolver).
- **Reading-Progress:** Scroll-Event setzt throttled `articlesStore.setProgress(id, progress)`; beim nächsten Öffnen springt der View auf die letzte Position zurück.
- **Bottom-Drawer (Phase 2):** alle Highlights dieses Artikels in einer Liste, klickbar → springt zur Stelle.
### HighlightsView (`/articles/highlights`, Phase 2)
Sammelansicht: alle Highlights über alle Artikel chronologisch oder nach Artikel gruppiert, mit Notizen + Quell-Link zurück zum Artikel. Export als Markdown (Plain-Text-Export, kein Share).
## Registrierung (Checklist)
1. `apps/mana/apps/web/src/lib/modules/articles/module.config.ts` anlegen:
```typescript
export const articlesModuleConfig: ModuleConfig = {
appId: 'articles',
tables: [
{ name: 'articles' },
{ name: 'articleHighlights', syncName: 'highlights' },
{ name: 'articleTags', syncName: 'tags' },
],
};
```
2. Config in `apps/mana/apps/web/src/lib/data/module-registry.ts` importieren + in `MODULE_CONFIGS` aufnehmen.
3. Dexie-Schema-Migration: neue `db.version(N+1).stores({ articles: '...', articleHighlights: '...', articleTags: '...' })` + `.upgrade()`-Hook, der `newsArticles` mit `type='saved'` nach `articles` kopiert (siehe Migration unten).
4. Encryption-Registry — drei Einträge (siehe oben). Unit-Test für Crypto-Roundtrip.
5. Routes unter `(app)/articles/` anlegen.
6. App-Registry-Eintrag in `packages/shared-branding/src/mana-apps.ts`:
```typescript
{
id: 'articles',
name: 'Artikel',
description: { de: 'Später lesen', en: 'Read Later' },
longDescription: {
de: 'Speichere Web-Artikel und lies sie später offline — mit Highlights, Tags und Notizen.',
en: 'Save web articles and read them offline later — with highlights, tags, and notes.',
},
icon: APP_ICONS.articles,
color: '#ef4444', // oder ein anderes Rot/Orange, anti-News (Grün)
status: 'development',
requiredTier: 'guest',
}
```
7. Icon in `packages/shared-branding/src/app-icons.ts` (SVG als Data-URL — Lesezeichen-Form o.ä.).
8. API-Modul in `apps/api/src/modules/articles/routes.ts` + Mount unter `/api/v1/articles`.
9. `docs/MODULE_REGISTRY.md` unter „Produktivität & Wissen" ergänzen.
10. `docs/PORT_SCHEMA.md` prüfen — neuer Endpoint bekommt keinen neuen Port, läuft im bestehenden `apps/api`.
11. Vitest-Tests:
- Store-Mutationen (save, highlight, tag)
- Encryption-Roundtrip (alle drei Tabellen)
- Dedupe-Pfad in `saveFromUrl`
- Offset-Mapping im HighlightLayer (unabhängig von DOM)
12. Playwright-Happy-Path (M2 Ende): Artikel speichern → öffnen → Reader sichtbar → Highlight setzen → Tag vergeben → als gelesen markieren.
## Migration von `news`-saved-Rows
Einmaliger Upgrade-Hook in der Dexie-Migration, die `articles` einführt:
```typescript
db.version(N+1).stores({ articles: '...', articleHighlights: '...', articleTags: '...' })
.upgrade(async (tx) => {
const saved = await tx.table('newsArticles').where('type').equals('saved').toArray();
for (const old of saved) {
await tx.table('articles').add({
id: crypto.randomUUID(), // neue ID, alte bleibt verwaist im newsArticles für Sync-Sauberkeit
originalUrl: old.originalUrl,
title: old.title,
excerpt: old.excerpt,
content: old.content,
htmlContent: old.htmlContent,
author: old.author,
siteName: old.siteName,
imageUrl: old.imageUrl,
wordCount: old.wordCount,
readingTimeMinutes: old.readingTimeMinutes,
publishedAt: old.publishedAt,
status: old.isRead ? 'finished' : (old.isArchived ? 'archived' : 'unread'),
readingProgress: 0,
isFavorite: old.isFavorite ?? false,
savedAt: old.createdAt,
readAt: old.isRead ? old.updatedAt : null,
tagIds: [],
userNote: null,
extractedVersion: 1,
userId: old.userId,
createdAt: old.createdAt,
updatedAt: new Date().toISOString(),
});
// Alte Row soft-deleten, damit sie nicht mehr im /news/saved auftaucht
// und der Sync-Engine den Delete propagiert:
await tx.table('newsArticles').update(old.id, {
deletedAt: new Date().toISOString(),
});
}
});
```
**Hinweis Encryption:** Die `newsArticles`-Rows sind beim Upgrade-Hook-Lauf **noch verschlüsselt** (Dexie-Upgrade läuft unterhalb der Store-Abstraktion). Zwei Optionen:
- **A (bevorzugt):** Migration läuft nicht im `.upgrade()`, sondern als Boot-Task *nach* Crypto-Init — in `apps/mana/apps/web/src/lib/data/migrations/articles-from-news.ts` mit einer `_migrationFlags`-Dexie-Tabelle, die markiert, dass die Migration einmal lief. Dann ist `decryptRecords` verfügbar und die Daten wandern korrekt decrypted → re-encrypted unter den neuen Feld-Allowlists.
- **B:** Migration bei der *Store-Ebene* — beim ersten Mount von `/articles` einmalig ausführen. Einfacher, aber User sieht beim ersten Öffnen eine kurze Ladephase.
Empfehlung: **A**. Entkoppelt Dexie-Version von der Crypto-abhängigen Daten-Bewegung; folgt demselben Muster wie die `companion` → `ai-agents`-Migration.
**Nach-Migration im `news`-Modul:**
- `saveFromUrl` in `articles.svelte.ts` (news) entfernen.
- `type: 'curated' | 'saved'` → `type: 'curated'` (Discriminator entfällt, da alle Rows curated sind).
- Route `/news/add` → Redirect auf `/articles/add`.
- Route `/news/saved` entfällt (oder redirectet auf `/articles?status=unread`).
- AI-Tool `save_news_article` bleibt als **Alias** für `save_article` (ruft intern `articlesStore.saveFromUrl`). Begründung: bestehende Missionen/Workbench-Events in der DB beziehen sich auf den Namen — hartes Löschen würde historische Iterations brechen.
## AI-Integration
Tools in `apps/mana/apps/web/src/lib/modules/articles/tools.ts` + Katalog-Eintrag in `@mana/shared-ai/src/tools/schemas.ts` (Single Source of Truth — webapp + mana-ai leiten daraus ab):
| Tool | Policy | Beschreibung |
|-------------------------|---------|----------------------------------------------------------------------|
| `list_articles` | auto | Filter nach `status`/`tag`, read-only; für Recherche-Missionen. |
| `save_article` | propose | URL → Readability-Extract → User bestätigt im Proposal-Dialog. |
| `archive_article` | propose | Status → `archived`. |
| `tag_article` | propose | Tag-ID(s) setzen. |
| `add_article_highlight` | propose | Textausschnitt + optionale Notiz; User bestätigt Stelle + Farbe. |
Der Runner injiziert `articles` in der Pool-Filterung zusätzlich zu `news`/`news-research`, damit Missionen wie *„Speichere die drei meistzitierten Artikel zu Thema X"* nativ gehen.
`AiProposalInbox` wird im `/articles` Hauptview eingebettet (`<AiProposalInbox module="articles" />`) — gleiches Pattern wie `/todo`, `/calendar`, `/places`.
## Scene Scope
Standardpattern wie in library/notes: `scopeTagIds` auf der aktiven Scene filtert Artikel via `filterBySceneScopeBatch`. Wenn der Scope alles ausblendet → `<ScopeEmptyState label="Artikel" />` anzeigen.
## Cross-Modul-Hooks
- **Tags:** articles klinkt sich in das **globale Tag-System** ein (`globalTags` + `tagGroups` unter `appId: 'tags'`). Derselbe Tag-Pool wie notes/calendar/contacts/chat/picture/places/… — Umbenennen und Farbwechsel propagieren automatisch. Scene-Scope via `scopeTagIds` funktioniert sofort, ohne zusätzlichen Code (gleicher Tag-Raum).
- **Notizen aus Highlights:** Später (Phase 3) Button „Highlight als Note speichern" → erzeugt `note` im notes-Modul mit Backlink.
- **Goals:** „X Artikel pro Woche lesen" kann der goals-Modul über die `completedAt`-Äquivalent `readAt` abfragen (cross-module-mechanik wie bei library).
## Offene Fragen
- **Site-Favicon als Source-Indikator:** lohnt sich der Aufwand (Favicon fetch + cache)? Für M1/M2 weglassen, aus `siteName` Text-Badge rendern. Kommt ggf. in M6+.
- **Kein Content-Refresh:** was, wenn ein Artikel aktualisiert wurde? Vorschlag: Button „neu extrahieren" im Detail-View, setzt `extractedVersion++`, Highlights bleiben per Kontext-Snippet re-anchored. Erste Version: keine automatische Aktualisierung.
- **PDF/Mobilizer:** Paywall-Artikel → kein Extract. Erste Version: Fehlermeldung „nicht extrahierbar" + Link zum Original. Mercury-/archive.org-Fallback später.
- **YouTube/Video:** URLs mit YouTube/Vimeo → out-of-scope oder als Special-Case mit Title+Description+Embed? **Vorschlag:** Für M1 kein Special-Case, `extractFromUrl` liefert `null` → Fehler. Wenn Bedarf, separater Handler in `shared-rss`.
- **Share-Target Trigger:** PWA-Manifest braucht `share_target`-Eintrag (Web Share Target Level 2). Funktioniert nur für installierte PWAs auf Android/Chromium. Für iOS bleibt Bookmarklet.
- **Encryption-Phase für Migrations-Pfad:** wenn der User zero-knowledge-Mode hat, ist der Crypto-State beim Boot erst verfügbar, sobald das Recovery-Code-Unlock passiert. Migration muss dahinter laufen — siehe DATA_LAYER_AUDIT.md §Encryption Rollout.
## Milestones
1. **M1 — Skelett**: types, collections, module.config, Registry-Einträge, Dexie-Migration (Tabellen anlegen, **noch keine** news-Migration), API-Modul leer, Routes mit Empty-State. App registry + Icon. *Ziel: `/articles` mountet, zeigt „Noch nichts gespeichert", nichts crasht, encryption-Audit grün.*
2. **M2 — URL-Save + Reader**: AddUrlForm, `/api/v1/articles/extract/*`, `articlesStore.saveFromUrl`, ArticleCard-Liste, DetailView mit Reader-Typografie (Serif default + Size-Slider + Light/Dark/Sepia). *Ziel: manueller Workflow „URL einfügen → lesen" geht durchgängig, offline-reload funktioniert.*
3. **M3 — Highlights**: HighlightLayer, HighlightMenu, `highlightsStore`, Offset-Resolver. *Ziel: Text markieren + Notiz anheften + beim Re-Open wieder sehen.*
4. **M4 — Tags + Filter + Progress**: `articleTagOps` + TagPicker (re-use bestehender Komponenten aus notes/calendar wenn vorhanden, sonst minimal neu), Status-Filter-Chips in ListView, Reading-Progress-Scroll-Restore, Favorit-Toggle, Archivieren. *Ziel: Volle organisatorische UX steht.* Kleiner Scope als ursprünglich geplant — kein Tag-CRUD im Modul (gehört ins globale Tag-System).
5. **M5 — Migration von news:type='saved'**: Boot-Migration nach Option A, News-Code-Deprecation (`saveFromUrl` raus, Route-Redirects, AI-Tool-Alias). *Ziel: Alle bestehenden saved-Artikel im neuen Modul, `/news/saved` leer/redirect.*
6. **M6 — AI-Tools**: list/save/tag/highlight/archive Tools, Katalog-Eintrag, Policy, AiProposalInbox-Mount. *Ziel: Missionen können URLs speichern und taggen.*
7. **M7 — Share-Target + Bookmarklet**: PWA-Manifest `share_target` + Bookmarklet-Snippet in Settings (`javascript:` → öffnet `/articles/add?url=...`). Offline-Queue für Share ohne Internet (`_pendingUrls`). *Ziel: „Seite im Browser → drei Clicks → in Mana gespeichert" geht.*
8. **M8 — HighlightsView + Stats + Dashboard-Widget**: `/articles/highlights` Sammelansicht, Markdown-Export, `useStats()` (Artikel/Woche, gelesen/gespeichert, Lieblings-Sites), Dashboard-Widget „Ungelesene Artikel" im widget-grid. *Ziel: Modul steht auf Augenhöhe mit notes/library auf dem Dashboard.*
Phase-3-Kandidaten (kein fester Milestone):
- Highlight → Note-Export mit Backlink
- Full-Text-Search (sqlite-wasm oder Dexie-Minisearch)
- Mercury/archive.org-Fallback für Paywalls
- Goodreads-ähnlicher Jahresrückblick („Du hast 142 Artikel gelesen, 28 Stunden Lesezeit …")