diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 82db2e7de..ecf5b9231 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -35,6 +35,7 @@ import { guidesRoutes } from './modules/guides/routes'; import { moodlitRoutes } from './modules/moodlit/routes'; import { newsRoutes } from './modules/news/routes'; import { newsResearchRoutes } from './modules/news-research/routes'; +import { articlesRoutes } from './modules/articles/routes'; import { tracesRoutes } from './modules/traces/routes'; import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; @@ -104,6 +105,7 @@ app.route('/api/v1/guides', guidesRoutes); app.route('/api/v1/moodlit', moodlitRoutes); app.route('/api/v1/news', newsRoutes); app.route('/api/v1/news-research', newsResearchRoutes); +app.route('/api/v1/articles', articlesRoutes); app.route('/api/v1/traces', tracesRoutes); app.route('/api/v1/presi', presiRoutes); app.route('/api/v1/research', researchRoutes); diff --git a/apps/api/src/modules/articles/routes.ts b/apps/api/src/modules/articles/routes.ts new file mode 100644 index 000000000..79263e4f8 --- /dev/null +++ b/apps/api/src/modules/articles/routes.ts @@ -0,0 +1,54 @@ +/** + * Articles module — server-side URL extraction. + * + * Thin wrapper around `@mana/shared-rss`'s Readability pipeline. The + * extracted payload is returned to the client which then encrypts + + * stores it locally (and syncs via mana-sync). The server keeps no + * per-user article state — all reading-list data lives in the unified + * Mana app's IndexedDB. + * + * One endpoint (`POST /extract`), not two. News has a `preview` + `save` + * split for legacy reasons; here both UI paths (AddUrlForm preview + the + * direct saveFromUrl path) use the same payload. The client caches the + * response when the user confirms, avoiding a double server fetch. + */ + +import { Hono } from 'hono'; +import { extractFromUrl } from '@mana/shared-rss'; + +const routes = new Hono(); + +routes.post('/extract', async (c) => { + const body = await c.req.json<{ url?: string }>().catch(() => ({}) as { url?: string }); + const url = body.url; + if (!url || typeof url !== 'string') { + return c.json({ error: 'URL is required' }, 400); + } + + // Minimal URL shape check — extractFromUrl will no-op on a bad URL but + // the caller deserves a clear 400 vs a generic 502. + try { + new URL(url); + } catch { + return c.json({ error: 'Invalid URL' }, 400); + } + + const extracted = await extractFromUrl(url); + if (!extracted) { + return c.json({ error: 'Extraction failed' }, 502); + } + + return c.json({ + originalUrl: url, + title: extracted.title, + excerpt: extracted.excerpt, + content: extracted.content, + htmlContent: extracted.htmlContent, + author: extracted.byline, + siteName: extracted.siteName, + wordCount: extracted.wordCount, + readingTimeMinutes: extracted.readingTimeMinutes, + }); +}); + +export { routes as articlesRoutes }; diff --git a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts index 9b9272e16..2bca7ae44 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts @@ -22,6 +22,7 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [ 'aiMissions', // TODO: audit 'albumItems', // TODO: audit 'albums', // TODO: audit + 'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags. 'automations', // TODO: audit 'boardViews', // TODO: audit 'budgets', // TODO: audit @@ -72,7 +73,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [ 'mukkeProjects', // TODO: audit 'newsCachedFeed', // TODO: audit 'noteTags', // TODO: audit - 'pendingProposals', // TODO: audit 'periodSymptoms', // TODO: audit 'photoFavorites', // TODO: audit 'photoMediaTags', // TODO: audit diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 9cc816621..36e34ad03 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -88,6 +88,7 @@ import type { LocalBroadcastTemplate, LocalBroadcastSettings, } from '../../modules/broadcast/types'; +import type { LocalArticle, LocalHighlight } from '../../modules/articles/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -572,6 +573,44 @@ export const ENCRYPTION_REGISTRY: Record = { // structural fields. agents: { enabled: true, fields: ['systemPrompt', 'memory'] }, + // ─── Articles (Pocket-style read-it-later) ────────────── + // Reading-behaviour data — same sensitivity class as newsArticles. + // Encrypted: + // - title / excerpt / content / htmlContent / author: the Readability + // extract body. Leaking this would be the same as leaking the user's + // bookmark history + full article texts. + // - userNote: the user's own note about the saved article. + // Plaintext (intentional): + // - originalUrl: dedupe key. Indexed + used by saveFromUrl to avoid + // duplicate ingestion. Same rationale as newsArticles.originalUrl / + // uLoad.links.originalUrl. + // - siteName: powers the "group by source" view and stays cheap to + // aggregate without decrypting every row. Not a secret — the site + // name is recoverable from originalUrl anyway. + // - imageUrl: opaque pointer; the bytes are already public at that URL. + // - status / readingProgress / isFavorite / savedAt / readAt / + // wordCount / readingTimeMinutes / publishedAt / extractedVersion: + // all structural, needed for filtering/sorting/stats. + // + // Highlights carry the marked text + the surrounding context fragments + // (re-anchor substrates). Both are fragments of the encrypted content + // and are themselves encrypted. Offsets + color + articleId are + // structural — the reader needs them for range scans and rendering. + // + // articleTags is intentionally NOT registered — pure FK junction + // (articleId, tagId), zero user-typed content. Tag names live in + // globalTags, which has its own encryption policy. Lives on the + // plaintext-allowlist alongside noteTags / eventTags / placeTags. + articles: entry([ + 'title', + 'excerpt', + 'content', + 'htmlContent', + 'author', + 'userNote', + ]), + articleHighlights: entry(['text', 'note', 'contextBefore', 'contextAfter']), + // ─── Library ───────────────────────────────────────────── // Reading / watching log with a kind discriminator (book / movie / // series / comic) in one table. User-typed text (title, original diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 767978a00..0f07f1792 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -738,6 +738,23 @@ db.version(32).stores({ broadcastSettings: 'id', }); +// v33 — Articles module: Pocket-style read-it-later. +// See docs/plans/articles-module.md. Three tables: +// - articles: saved URLs + extracted Readability content. `originalUrl` +// indexed for O(1) dedupe at save time. `status` + `savedAt` drive +// the ListView's main filter + sort. `isFavorite` + `siteName` are +// indexed for the "favourites" + "group by source" views. +// - articleHighlights: per-selection rows. [articleId+startOffset] +// gives sorted range scans per article for the reader overlay. +// - articleTags: pure junction into globalTags (appId 'tags'). +// [articleId+tagId] matches the pattern used by noteTags / eventTags +// / contactTags / placeTags and is what `createTagLinkOps` expects. +db.version(33).stores({ + articles: 'id, userId, status, savedAt, isFavorite, siteName, originalUrl', + articleHighlights: 'id, userId, articleId, [articleId+startOffset]', + articleTags: 'id, userId, articleId, tagId, [articleId+tagId]', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 8a1afb5b8..d6c2441d5 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -99,6 +99,7 @@ import { kontextModuleConfig } from '$lib/modules/kontext/module.config'; import { quizModuleConfig } from '$lib/modules/quiz/module.config'; import { profileModuleConfig } from '$lib/modules/profile/module.config'; import { libraryModuleConfig } from '$lib/modules/library/module.config'; +import { articlesModuleConfig } from '$lib/modules/articles/module.config'; import { invoicesModuleConfig } from '$lib/modules/invoices/module.config'; import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; @@ -157,6 +158,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ quizModuleConfig, profileModuleConfig, libraryModuleConfig, + articlesModuleConfig, invoicesModuleConfig, broadcastModuleConfig, wetterModuleConfig, diff --git a/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte b/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte new file mode 100644 index 000000000..3f4ddbea5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte @@ -0,0 +1,200 @@ + + + +
+
+
+
+

Artikel

+

Später lesen — gespeicherte Web-Artikel, offline verfügbar.

+
+ +
+
+ + {#if articles$.loading} +

Lädt…

+ {:else if articles.length === 0} +
+

Noch nichts gespeichert.

+

+ URL einfügen, der Server extrahiert den Artikel mit Readability, alles bleibt verschlüsselt + offline verfügbar. +

+ +
+ {:else} +
    + {#each articles as article (article.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/articles/api.ts b/apps/mana/apps/web/src/lib/modules/articles/api.ts new file mode 100644 index 000000000..b49b9b083 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/api.ts @@ -0,0 +1,50 @@ +/** + * Articles API client — talks to apps/api `/api/v1/articles/*`. + * + * One endpoint (`POST /extract`) with the Readability result. Both the + * preview (AddUrlForm) and the direct save paths share the same call; + * the client chooses whether to show the result or immediately persist. + * + * Auth + base-URL handling mirrors news/api.ts — see that file for the + * full rationale on why we read `getManaApiUrl()` and `authStore. + * getValidToken()` instead of the cookie/env shortcuts. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; + +async function authHeader(): Promise> { + const token = await authStore.getValidToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export interface ExtractedArticle { + originalUrl: string; + title: string; + excerpt: string | null; + content: string; + htmlContent: string; + author: string | null; + siteName: string | null; + wordCount: number; + readingTimeMinutes: number; +} + +export async function extractArticle( + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + const response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(await authHeader()), + }, + body: JSON.stringify({ url }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`extractArticle failed: ${response.status} ${text}`); + } + return (await response.json()) as ExtractedArticle; +} diff --git a/apps/mana/apps/web/src/lib/modules/articles/collections.ts b/apps/mana/apps/web/src/lib/modules/articles/collections.ts new file mode 100644 index 000000000..ca0d00f33 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/collections.ts @@ -0,0 +1,14 @@ +/** + * Articles module — Dexie accessors. + * + * No guest seed: articles are by definition URLs the user chose to save, + * so an empty state is the honest first-run experience. The ListView's + * empty-state hints the user toward /articles/add instead. + */ + +import { db } from '$lib/data/database'; +import type { LocalArticle, LocalHighlight, LocalArticleTag } from './types'; + +export const articleTable = db.table('articles'); +export const articleHighlightTable = db.table('articleHighlights'); +export const articleTagTable = db.table('articleTags'); diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte new file mode 100644 index 000000000..2142d94db --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte @@ -0,0 +1,256 @@ + + + +
+
+

Artikel speichern

+

URL einfügen, Vorschau prüfen, speichern.

+
+ +
+ { + if (e.key === 'Enter') handlePreview(); + }} + use:focusOnMount + /> + +
+ + {#if error} +

{error}

+ {/if} + + {#if duplicate} +
+

Den hast du schon gespeichert.

+

{duplicate.title}

+
+ + +
+
+ {/if} + + {#if preview} +
+

{preview.title}

+
+ {#if preview.siteName}{preview.siteName}{/if} + {#if preview.author}· {preview.author}{/if} + {#if preview.readingTimeMinutes}· {preview.readingTimeMinutes} min{/if} + {#if preview.wordCount}· {preview.wordCount} Wörter{/if} +
+ {#if preview.excerpt} +

{preview.excerpt}

+ {/if} +
+ + +
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte new file mode 100644 index 000000000..c8c4fb193 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte @@ -0,0 +1,285 @@ + + + +
+ {#if menu?.kind === 'create'} + (menu = null)} + /> + {:else if menu?.kind === 'edit'} + (menu = null)} + /> + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte new file mode 100644 index 000000000..115e0a52e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte @@ -0,0 +1,208 @@ + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte new file mode 100644 index 000000000..7737a0649 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte @@ -0,0 +1,190 @@ + + + +
+ {#if html} + + {@html html} + {:else} +
{plainFallback}
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/articles/index.ts b/apps/mana/apps/web/src/lib/modules/articles/index.ts new file mode 100644 index 000000000..8d9e0ce30 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/index.ts @@ -0,0 +1,29 @@ +/** + * Articles module — barrel exports. + */ + +export { articlesStore } from './stores/articles.svelte'; +export { highlightsStore } from './stores/highlights.svelte'; +export { articleTagOps } from './stores/tags.svelte'; + +export { + useAllArticles, + useArticle, + useArticleHighlights, + toArticle, + toHighlight, + filterByStatus, + searchArticles, +} from './queries'; + +export { articleTable, articleHighlightTable, articleTagTable } from './collections'; + +export type { + LocalArticle, + LocalHighlight, + LocalArticleTag, + Article, + Highlight, + ArticleStatus, + HighlightColor, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts b/apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts new file mode 100644 index 000000000..7836cefab --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts @@ -0,0 +1,195 @@ +/** + * Highlight offset resolution. + * + * We persist each highlight as a `{ startOffset, endOffset }` pair of + * plain-text character offsets into the Reader's root. "Plain text" here + * is the concatenation of all text nodes in document order — i.e. the + * value of `root.textContent` — so `

Hello world

` + * has the offsets `H=0, e=1, …, w=6, o=7, …`.
, , and block + * boundaries contribute zero characters, which matches `textContent`'s + * behaviour and the user's mental model of "what did I actually select?" + * + * Storing offsets into the rendered DOM (as opposed to the article's raw + * `content` field) means we don't have to reconcile Readability's + * whitespace normalisation with the browser's. On re-open we walk the + * same DOM and find the node for each offset. + * + * The context-snippet fields on `LocalHighlight` (`contextBefore`, + * `contextAfter`) are populated here for re-anchor purposes in later + * milestones (when the article is re-extracted and the offsets drift). + */ + +const CONTEXT_CHARS = 40; + +export interface TextOffsetPair { + start: number; + end: number; +} + +/** + * A single contiguous text-node slice between `start` and `end` offsets. + * Used when wrapping a multi-node range into highlight spans. + */ +export interface TextSlice { + node: Text; + /** inclusive offset inside this text node */ + start: number; + /** exclusive offset inside this text node */ + end: number; +} + +/** + * Resolve a DOM Range to plain-text offsets relative to `root`. + * + * Returns null if the range is collapsed, not inside the root, or + * crosses element boundaries we can't map (shouldn't happen for normal + * user selections inside the reader body). + */ +export function rangeToTextOffsets(range: Range, root: Element): TextOffsetPair | null { + if (range.collapsed) return null; + if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) { + return null; + } + + let start = -1; + let end = -1; + let offset = 0; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null = walker.nextNode(); + while (node) { + const text = node as Text; + const length = text.data.length; + + if (text === range.startContainer) { + start = offset + Math.min(range.startOffset, length); + } + if (text === range.endContainer) { + end = offset + Math.min(range.endOffset, length); + break; + } + offset += length; + node = walker.nextNode(); + } + + // Edge case: range boundaries are element nodes (e.g. the user + // triple-clicked a paragraph). Fall back to the first/last text + // descendant so we still get something saveable. + if (start === -1) start = descendantOffset(root, range.startContainer, range.startOffset); + if (end === -1) end = descendantOffset(root, range.endContainer, range.endOffset); + + if (start < 0 || end < 0 || end <= start) return null; + return { start, end }; +} + +function descendantOffset(root: Element, container: Node, offset: number): number { + // Element-container ranges report offset in terms of child *nodes*, not + // characters. Translate by summing textContent of siblings before the + // child index. + if (container.nodeType === Node.TEXT_NODE) { + // Already a text node but wasn't hit in the main walk — find its absolute offset. + let total = 0; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let n: Node | null = walker.nextNode(); + while (n) { + if (n === container) return total + Math.min(offset, (n as Text).data.length); + total += (n as Text).data.length; + n = walker.nextNode(); + } + return total; + } + const childIndex = Math.min(offset, container.childNodes.length); + let total = textLengthBefore(root, container, childIndex); + return total; +} + +function textLengthBefore(root: Element, container: Node, childIndex: number): number { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let total = 0; + let n: Node | null = walker.nextNode(); + while (n) { + // Stop once we're past the target child. + if (isDescendantAtIndexOrLater(container, childIndex, n)) return total; + total += (n as Text).data.length; + n = walker.nextNode(); + } + return total; +} + +function isDescendantAtIndexOrLater(container: Node, index: number, candidate: Node): boolean { + // Walk up from candidate until we hit a direct child of container. + let node: Node | null = candidate; + while (node && node.parentNode !== container) { + node = node.parentNode; + } + if (!node) return false; + const idx = Array.prototype.indexOf.call(container.childNodes, node); + return idx >= index; +} + +/** + * Resolve `{ start, end }` plain-text offsets back into a list of + * contiguous text-node slices, suitable for wrapping in highlight + * spans. Multi-paragraph selections yield multiple slices, one per + * text node touched. + */ +export function textOffsetsToSlices(root: Element, start: number, end: number): TextSlice[] { + const slices: TextSlice[] = []; + if (end <= start) return slices; + + let offset = 0; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null = walker.nextNode(); + while (node) { + const text = node as Text; + const length = text.data.length; + const nodeStart = offset; + const nodeEnd = offset + length; + + if (nodeEnd > start && nodeStart < end) { + slices.push({ + node: text, + start: Math.max(0, start - nodeStart), + end: Math.min(length, end - nodeStart), + }); + } + + if (nodeEnd >= end) break; + offset = nodeEnd; + node = walker.nextNode(); + } + return slices; +} + +export interface SelectionSnapshot { + start: number; + end: number; + text: string; + contextBefore: string | null; + contextAfter: string | null; +} + +/** + * Package a user Range into everything we need to persist a highlight: + * offsets, selected text, and ~40 chars of surrounding context for + * later re-anchor attempts. + */ +export function extractSelectionSnapshot(range: Range, root: Element): SelectionSnapshot | null { + const offsets = rangeToTextOffsets(range, root); + if (!offsets) return null; + + const whole = root.textContent ?? ''; + const text = whole.slice(offsets.start, offsets.end).trim(); + if (!text) return null; + + const before = whole.slice(Math.max(0, offsets.start - CONTEXT_CHARS), offsets.start) || null; + const after = whole.slice(offsets.end, offsets.end + CONTEXT_CHARS) || null; + + return { + start: offsets.start, + end: offsets.end, + text, + contextBefore: before, + contextAfter: after, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/articles/module.config.ts b/apps/mana/apps/web/src/lib/modules/articles/module.config.ts new file mode 100644 index 000000000..d34f3503d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/module.config.ts @@ -0,0 +1,18 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +/** + * Articles module — saved web articles + highlights + tag links. + * + * `articleTags` is a pure junction into globalTags (the core `tags` + * appId). The junction itself syncs under `articles` appId with its + * owning rows, the same pattern every other tagged module uses + * (noteTags, eventTags, contactTags, placeTags, …). + */ +export const articlesModuleConfig: ModuleConfig = { + appId: 'articles', + tables: [ + { name: 'articles' }, + { name: 'articleHighlights', syncName: 'highlights' }, + { name: 'articleTags' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/queries.ts b/apps/mana/apps/web/src/lib/modules/articles/queries.ts new file mode 100644 index 000000000..1dc81073e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/queries.ts @@ -0,0 +1,118 @@ +/** + * Reactive queries + type converters for the Articles module. + * + * Reads always flow through `scopedForModule` so the current space / + * scene-scope filter applies transparently — module code never needs + * to know which space it's in. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { decryptRecords } from '$lib/data/crypto'; +import { scopedForModule, scopedGet } from '$lib/data/scope'; +import type { LocalArticle, LocalHighlight, Article, Highlight, ArticleStatus } from './types'; + +// ─── Type Converters ───────────────────────────────────── + +export function toArticle(local: LocalArticle): Article { + const now = new Date().toISOString(); + return { + id: local.id, + originalUrl: local.originalUrl, + title: local.title, + excerpt: local.excerpt ?? null, + content: local.content, + htmlContent: local.htmlContent ?? null, + author: local.author ?? null, + siteName: local.siteName ?? null, + imageUrl: local.imageUrl ?? null, + wordCount: local.wordCount ?? null, + readingTimeMinutes: local.readingTimeMinutes ?? null, + publishedAt: local.publishedAt ?? null, + status: local.status, + readingProgress: local.readingProgress ?? 0, + isFavorite: local.isFavorite ?? false, + savedAt: local.savedAt, + readAt: local.readAt ?? null, + userNote: local.userNote ?? null, + extractedVersion: local.extractedVersion ?? 1, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toHighlight(local: LocalHighlight): Highlight { + const now = new Date().toISOString(); + return { + id: local.id, + articleId: local.articleId, + text: local.text, + note: local.note ?? null, + color: local.color, + startOffset: local.startOffset, + endOffset: local.endOffset, + contextBefore: local.contextBefore ?? null, + contextAfter: local.contextAfter ?? null, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +// ─── Live Queries ───────────────────────────────────────── + +export function useAllArticles() { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule('articles', 'articles').toArray(); + const visible = locals.filter((a) => !a.deletedAt); + const decrypted = await decryptRecords('articles', visible); + return decrypted + .map(toArticle) + .sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? '')); + }, [] as Article[]); +} + +export function useArticle(id: string) { + return useLiveQueryWithDefault( + async () => { + // scopedGet returns undefined if the article belongs to another + // space — protects against URL-manipulated deep links. + const local = await scopedGet('articles', id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('articles', [local]); + return decrypted ? toArticle(decrypted) : null; + }, + null as Article | null + ); +} + +export function useArticleHighlights(articleId: string) { + return useLiveQueryWithDefault(async () => { + // scopedForModule returns the scope-filtered Collection; we narrow + // to this article in a post-filter (O(highlights per space), tiny). + // Using scopedForModule instead of a direct indexed where() keeps the + // scope check centralised — same pattern other modules use for + // per-parent lookups (e.g. notes tag subsets). + const locals = await scopedForModule( + 'articles', + 'articleHighlights' + ).toArray(); + const forArticle = locals.filter((h) => h.articleId === articleId && !h.deletedAt); + const decrypted = await decryptRecords('articleHighlights', forArticle); + return decrypted.map(toHighlight).sort((a, b) => a.startOffset - b.startOffset); + }, [] as Highlight[]); +} + +// ─── Pure Helpers ───────────────────────────────────────── + +export function filterByStatus(articles: Article[], status: ArticleStatus): Article[] { + return articles.filter((a) => a.status === status); +} + +export function searchArticles(articles: Article[], query: string): Article[] { + const lower = query.toLowerCase(); + return articles.filter( + (a) => + a.title.toLowerCase().includes(lower) || + (a.author?.toLowerCase().includes(lower) ?? false) || + (a.siteName?.toLowerCase().includes(lower) ?? false) + ); +} diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts new file mode 100644 index 000000000..c584b110b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts @@ -0,0 +1,134 @@ +/** + * Articles store — mutation-only service. + * + * M1 scope is intentionally thin: delete + status/favourite/progress toggles + * that exercise the encryption + event pipeline. `saveFromUrl` (the real + * ingestion path) lands in M2 together with the server extract route and + * AddUrlForm. The pipeline is wired now so the Reader view and CRUD plumbing + * in M2/M3 can slot in without reshaping calls. + */ + +import { encryptRecord, decryptRecords } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { scopedForModule } from '$lib/data/scope'; +import { articleTable } from '../collections'; +import { extractArticle, type ExtractedArticle } from '../api'; +import { toArticle } from '../queries'; +import type { Article, ArticleStatus, LocalArticle } from '../types'; + +export const articlesStore = { + async setStatus(id: string, status: ArticleStatus): Promise { + const diff: Partial = { + status, + updatedAt: new Date().toISOString(), + }; + if (status === 'finished') { + const existing = await articleTable.get(id); + if (existing && !existing.readAt) diff.readAt = diff.updatedAt; + } + await articleTable.update(id, diff); + }, + + async toggleFavorite(id: string): Promise { + const existing = await articleTable.get(id); + if (!existing) return; + await articleTable.update(id, { + isFavorite: !existing.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async setProgress(id: string, progress: number): Promise { + const clamped = Math.max(0, Math.min(1, progress)); + await articleTable.update(id, { + readingProgress: clamped, + updatedAt: new Date().toISOString(), + }); + }, + + async updateNote(id: string, note: string | null): Promise { + const diff: Partial = { + userNote: note, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('articles', diff as LocalArticle); + await articleTable.update(id, diff); + }, + + async deleteArticle(id: string): Promise { + await articleTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Look up an already-saved article by URL in the current space. Used + * by the dedupe path in saveFromUrl and by AddUrlForm to offer + * "already saved — open it" instead of duplicating the row. + * Returns a decrypted snapshot, or null. + */ + async findByUrl(url: string): Promise
{ + const match = await scopedForModule('articles', 'articles') + .filter((r) => r.originalUrl === url && !r.deletedAt) + .first(); + if (!match) return null; + const [decrypted] = await decryptRecords('articles', [match]); + return decrypted ? toArticle(decrypted) : null; + }, + + /** + * Persist an extracted payload as a saved article. Returns the snapshot + * directly so callers can navigate to `/articles/` without waiting + * for the liveQuery to tick. + * + * AddUrlForm passes a pre-extracted payload (after it already called + * extractArticle once to render the preview); the direct path in + * saveFromUrl lets the store do the extract itself. + */ + async saveFromExtracted(extracted: ExtractedArticle): Promise
{ + const now = new Date().toISOString(); + const newLocal: LocalArticle = { + id: crypto.randomUUID(), + originalUrl: extracted.originalUrl, + title: extracted.title, + excerpt: extracted.excerpt ?? null, + content: extracted.content, + htmlContent: extracted.htmlContent ?? null, + author: extracted.author ?? null, + siteName: extracted.siteName ?? null, + imageUrl: null, + wordCount: extracted.wordCount, + readingTimeMinutes: extracted.readingTimeMinutes, + publishedAt: null, + status: 'unread', + readingProgress: 0, + isFavorite: false, + savedAt: now, + readAt: null, + userNote: null, + extractedVersion: 1, + }; + const snapshot = toArticle(newLocal); + await encryptRecord('articles', newLocal); + await articleTable.add(newLocal); + emitDomainEvent('ArticleSaved', 'articles', 'articles', newLocal.id, { + articleId: newLocal.id, + title: newLocal.title, + }); + return snapshot; + }, + + /** + * Full save path: dedupe → extract → persist. Returns the existing + * article when the URL is already saved in the current space (the + * caller can then navigate to it instead of creating a duplicate). + */ + async saveFromUrl(url: string): Promise<{ article: Article; duplicate: boolean }> { + const existing = await this.findByUrl(url); + if (existing) return { article: existing, duplicate: true }; + const extracted = await extractArticle(url); + const article = await this.saveFromExtracted(extracted); + return { article, duplicate: false }; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts new file mode 100644 index 000000000..79a7a598c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts @@ -0,0 +1,66 @@ +/** + * Highlights store — mutation-only service for `articleHighlights`. + * + * Every write routes through encryptRecord so text + note + context + * snippets ship encrypted. Structural fields (articleId, startOffset, + * endOffset, color) stay plaintext for the reader's range-scan query. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { articleHighlightTable } from '../collections'; +import { toHighlight } from '../queries'; +import type { Highlight, HighlightColor, LocalHighlight } from '../types'; + +export interface AddHighlightInput { + articleId: string; + text: string; + color?: HighlightColor; + note?: string | null; + startOffset: number; + endOffset: number; + contextBefore?: string | null; + contextAfter?: string | null; +} + +export const highlightsStore = { + async addHighlight(input: AddHighlightInput): Promise { + const newLocal: LocalHighlight = { + id: crypto.randomUUID(), + articleId: input.articleId, + text: input.text, + note: input.note ?? null, + color: input.color ?? 'yellow', + startOffset: input.startOffset, + endOffset: input.endOffset, + contextBefore: input.contextBefore ?? null, + contextAfter: input.contextAfter ?? null, + }; + const snapshot = toHighlight(newLocal); + await encryptRecord('articleHighlights', newLocal); + await articleHighlightTable.add(newLocal); + return snapshot; + }, + + async setColor(id: string, color: HighlightColor): Promise { + await articleHighlightTable.update(id, { + color, + updatedAt: new Date().toISOString(), + }); + }, + + async setNote(id: string, note: string | null): Promise { + const diff: Partial = { + note, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('articleHighlights', diff as LocalHighlight); + await articleHighlightTable.update(id, diff); + }, + + async deleteHighlight(id: string): Promise { + await articleHighlightTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts new file mode 100644 index 000000000..b6f168070 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts @@ -0,0 +1,23 @@ +/** + * Articles Tags — junction ops into the global tag system. + * + * Mirrors notes/stores/tags.svelte.ts, calendar/stores/tags.svelte.ts, + * contacts/stores/tags.svelte.ts — tag names/colors live in globalTags + * (appId: 'tags'), articles just holds the junction rows. + */ + +import { db } from '$lib/data/database'; +import { createTagLinkOps } from '@mana/shared-stores'; + +export { + tagMutations, + useAllTags, + getTagById, + getTagsByIds, + getTagColor, +} from '@mana/shared-stores'; + +export const articleTagOps = createTagLinkOps({ + table: () => db.table('articleTags'), + entityIdField: 'articleId', +}); diff --git a/apps/mana/apps/web/src/lib/modules/articles/types.ts b/apps/mana/apps/web/src/lib/modules/articles/types.ts new file mode 100644 index 000000000..f886a29e1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/types.ts @@ -0,0 +1,117 @@ +/** + * Articles module — Pocket-style read-it-later. + * + * Three Dexie tables: + * + * articles — saved URLs + extracted Readability content + * (encrypted: title, excerpt, content, htmlContent, + * author, userNote). Reading state + dedupe key + * stay plaintext for indexing. + * articleHighlights — per-selection rows with plain-text offsets. + * Encrypted: text, note, context snippets. + * articleTags — pure junction into globalTags. No user-typed + * content lives here — tag names/colors are in + * the global tag system (appId: 'tags'). + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── Discriminators ────────────────────────────────────── + +export type ArticleStatus = 'unread' | 'reading' | 'finished' | 'archived'; + +export type HighlightColor = 'yellow' | 'green' | 'blue' | 'pink'; + +// ─── Local Records (Dexie) ─────────────────────────────── + +export interface LocalArticle extends BaseRecord { + originalUrl: string; + title: string; + excerpt: string | null; + content: string; + htmlContent: string | null; + author: string | null; + siteName: string | null; + imageUrl: string | null; + wordCount: number | null; + readingTimeMinutes: number | null; + publishedAt: string | null; + status: ArticleStatus; + /** 0..1 scroll position so the reader can restore where the user stopped. */ + readingProgress: number; + isFavorite: boolean; + savedAt: string; + readAt: string | null; + userNote: string | null; + /** Bumped when the article is re-extracted so highlight re-anchoring + * can decide whether to trust cached offsets. */ + extractedVersion: number; +} + +export interface LocalHighlight extends BaseRecord { + articleId: string; + text: string; + note: string | null; + color: HighlightColor; + /** Plain-text char offsets into `LocalArticle.content`. The reader maps + * these back to DOM ranges over the rendered htmlContent. */ + startOffset: number; + endOffset: number; + /** Short fragments (~50 chars) around the selection — used to + * re-anchor the highlight if the article gets re-extracted and + * the offsets shift. */ + contextBefore: string | null; + contextAfter: string | null; +} + +/** + * Junction row linking one article to one global tag. Same shape as + * noteTags / eventTags / contactTags / placeTags — zero user-typed + * content, so the row stays out of the encryption registry and lives + * on the plaintext allowlist. Tag name/color/group come from globalTags + * via @mana/shared-stores helpers. + */ +export interface LocalArticleTag extends BaseRecord { + articleId: string; + tagId: string; +} + +// ─── Public DTOs (rendered by views) ───────────────────── + +export interface Article { + id: string; + originalUrl: string; + title: string; + excerpt: string | null; + content: string; + htmlContent: string | null; + author: string | null; + siteName: string | null; + imageUrl: string | null; + wordCount: number | null; + readingTimeMinutes: number | null; + publishedAt: string | null; + status: ArticleStatus; + readingProgress: number; + isFavorite: boolean; + savedAt: string; + readAt: string | null; + userNote: string | null; + extractedVersion: number; + createdAt: string; + updatedAt: string; +} + +export interface Highlight { + id: string; + articleId: string; + text: string; + note: string | null; + color: HighlightColor; + startOffset: number; + endOffset: number; + contextBefore: string | null; + contextAfter: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte new file mode 100644 index 000000000..416c9b9ec --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte @@ -0,0 +1,366 @@ + + + + + {article?.title ?? 'Artikel'} — Mana + + +
+
+ + + {#if article} +
+ + + + + + + + + +
+ {/if} +
+ + {#if article$.loading} +

Lädt…

+ {:else if !article} +
+

Artikel nicht gefunden.

+ +
+ {:else} +
+

{article.title}

+
+ {#if article.siteName}{article.siteName}{/if} + {#if article.author}· {article.author}{/if} + {#if article.readingTimeMinutes}· {article.readingTimeMinutes} min{/if} + {#if article.wordCount}· {article.wordCount} Wörter{/if} +
+
+ + (readerScroller = el)} + /> + + + +
+ + + + + Original ↗ + + + +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/articles/+page.svelte b/apps/mana/apps/web/src/routes/(app)/articles/+page.svelte new file mode 100644 index 000000000..674eaa63f --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/articles/+page.svelte @@ -0,0 +1,9 @@ + + + + Artikel - Mana + + + diff --git a/apps/mana/apps/web/src/routes/(app)/articles/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/articles/[id]/+page.svelte new file mode 100644 index 000000000..09574c004 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/articles/[id]/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/apps/mana/apps/web/src/routes/(app)/articles/add/+page.svelte b/apps/mana/apps/web/src/routes/(app)/articles/add/+page.svelte new file mode 100644 index 000000000..7195e5cfc --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/articles/add/+page.svelte @@ -0,0 +1,9 @@ + + + + Artikel speichern — Mana + + + diff --git a/docs/plans/articles-module.md b/docs/plans/articles-module.md new file mode 100644 index 000000000..da2cdba06 --- /dev/null +++ b/docs/plans/articles-module.md @@ -0,0 +1,386 @@ +# Articles — Module Plan + +## Status (2026-04-21) + +Proposed. Noch nichts gebaut. + +## 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(['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(['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=` — 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 (``) — 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 → `` 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 …") diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index e86a92dd4..c97cf218b 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -231,6 +231,12 @@ export const APP_ICONS = { // gradient sits next to music/photos/picture in the Kreativität & Medien row. `` ), + articles: svgToDataUrl( + // Bookmark ribbon tucked into a folded document corner — "Für später + // gemerkt". Orange→amber gradient sets it apart from news (emerald) + // and news-research (cyan) in the Wissen & Recherche row. + `` + ), invoices: svgToDataUrl( // Document with a QR-code corner (CH QR-Bill) + a diagonal amount line. // Emerald→teal sits next to finance green in the Arbeit & Finanzen row. diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 50148dd0f..c38c662fe 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'articles', + name: 'Artikel', + description: { + de: 'Später lesen — offline', + en: 'Read later — offline', + }, + longDescription: { + de: 'Speichere Web-Artikel und lies sie offline im Reader — mit Highlights, Tags und Notizen. Ein Zuhause für alles, das du später in Ruhe lesen willst.', + en: 'Save web articles and read them offline in a distraction-free reader — with highlights, tags and notes. A home for everything you want to read properly later.', + }, + icon: APP_ICONS.articles, + color: '#f97316', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, { id: 'broadcast', name: 'Broadcasts',