feat(articles): new read-it-later module — save / read / highlight

Pocket-style module for saving arbitrary web URLs, extracting readable
content server-side via @mana/shared-rss (Readability + JSDOM), and
storing it AES-GCM encrypted in IndexedDB for offline reading.

M1 skeleton: Dexie v33 (articles, articleHighlights, articleTags),
crypto registry entries, module registration, app-registry entry with
orange icon, empty-state ListView. articleTags is a pure junction
into the existing globalTags system (appId 'tags') — same pattern as
noteTags, eventTags, placeTags.

M2 URL save + reader: POST /api/v1/articles/extract (one endpoint,
not two — client caches the preview payload to avoid a double
server fetch). AddUrlForm with scope-aware dedupe, DetailView with
ReaderView typography shell (serif/sans, light/sepia/dark, size
slider), auto-tracked reading progress with scroll restore.

M3 highlights: TreeWalker-based plain-text offset resolution
(lib/offsets.ts), highlights store, floating HighlightMenu with
create + edit modes, HighlightLayer orchestrator that wraps/unwraps
highlight spans whenever highlights or htmlVersion changes. Four
colours (yellow/green/blue/pink), optional notes, click-to-edit,
dark-mode-aware overlay colours.

Drive-by: removed stale 'pendingProposals' entry from the plaintext
allowlist — the table was dropped in Dexie v29 and the allowlist
audit was flagging it as a dead entry.

Plan: docs/plans/articles-module.md. M4 (tags + filter + progress),
M5 (news:type='saved' migration), M6 (AI tools), M7 (share target),
M8 (highlights view + stats) still open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 16:20:23 +02:00
parent 8f6a4efddd
commit 3357e88a1c
28 changed files with 2819 additions and 1 deletions

View file

@ -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);

View file

@ -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 };

View file

@ -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

View file

@ -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<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -572,6 +573,44 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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<LocalArticle>([
'title',
'excerpt',
'content',
'htmlContent',
'author',
'userNote',
]),
articleHighlights: entry<LocalHighlight>(['text', 'note', 'contextBefore', 'contextAfter']),
// ─── Library ─────────────────────────────────────────────
// Reading / watching log with a kind discriminator (book / movie /
// series / comic) in one table. User-typed text (title, original

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1,200 @@
<!--
Articles — ListView (M1 skeleton)
Shows saved articles sorted by savedAt desc. Empty-state points
at /articles/add (route lands in M2). Typography, reader, highlights,
tags, filters all land in later milestones.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllArticles } from './queries';
import type { Article } from './types';
const articles$ = useAllArticles();
const articles = $derived(articles$.value);
function openArticle(a: Article) {
goto(`/articles/${a.id}`);
}
</script>
<div class="articles-shell">
<header class="header">
<div class="header-row">
<div>
<h1>Artikel</h1>
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
</div>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
+ Neu speichern
</button>
</div>
</header>
{#if articles$.loading}
<p class="muted center">Lädt…</p>
{:else if articles.length === 0}
<div class="empty-state">
<p class="empty-headline">Noch nichts gespeichert.</p>
<p class="empty-sub">
URL einfügen, der Server extrahiert den Artikel mit Readability, alles bleibt verschlüsselt
offline verfügbar.
</p>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
Erste URL speichern
</button>
</div>
{:else}
<ul class="article-list">
{#each articles as article (article.id)}
<li>
<button type="button" class="article-card" onclick={() => openArticle(article)}>
<div class="meta">
{#if article.siteName}
<span class="site">{article.siteName}</span>
{/if}
{#if article.readingTimeMinutes}
<span class="reading-time">{article.readingTimeMinutes} min</span>
{/if}
<span class="status status-{article.status}">{article.status}</span>
</div>
<div class="title">{article.title}</div>
{#if article.excerpt}
<div class="excerpt">{article.excerpt}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.articles-shell {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.5rem;
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
}
.add-btn {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
border: 1px solid #f97316;
background: #f97316;
color: white;
font: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.add-btn:hover {
background: #ea580c;
border-color: #ea580c;
}
.subtitle {
color: var(--color-text-muted, #64748b);
margin: 0;
font-size: 0.95rem;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.empty-state {
margin-top: 3rem;
padding: 2rem;
text-align: center;
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.15));
border-radius: 0.75rem;
}
.empty-headline {
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.empty-sub {
margin: 0 0 1.25rem 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.article-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.article-card {
width: 100%;
text-align: left;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.6rem;
background: var(--color-surface, transparent);
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.article-card:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.2));
}
.meta {
display: flex;
gap: 0.6rem;
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
align-items: center;
}
.site {
font-weight: 500;
}
.status {
padding: 0.08rem 0.45rem;
border-radius: 999px;
font-size: 0.7rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.status-finished {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-archived {
background: rgba(100, 116, 139, 0.15);
color: #64748b;
}
.title {
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
.excerpt {
color: var(--color-text-muted, #64748b);
font-size: 0.88rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View file

@ -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<Record<string, string>> {
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<ExtractedArticle> {
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;
}

View file

@ -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<LocalArticle>('articles');
export const articleHighlightTable = db.table<LocalHighlight>('articleHighlights');
export const articleTagTable = db.table<LocalArticleTag>('articleTags');

View file

@ -0,0 +1,256 @@
<!--
AddUrlForm — paste URL → preview → save.
Flow:
1. User pastes (or types) a URL
2. On "Vorschau abrufen": check scope-local dedupe first; if found,
offer "öffnen" instead of re-extracting (saves one round-trip).
Otherwise call /api/v1/articles/extract and render the preview.
3. On "Speichern": the already-extracted payload is persisted via
articlesStore.saveFromExtracted — no second server call.
4. Navigate into the new article so the user lands directly in the
reader view.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { articlesStore } from '../stores/articles.svelte';
import { extractArticle, type ExtractedArticle } from '../api';
import type { Article } from '../types';
let url = $state('');
let preview = $state<ExtractedArticle | null>(null);
let duplicate = $state<Article | null>(null);
let loading = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
// a11y: don't use the `autofocus` attribute — route the focus through a
// use:action so screen-readers announce the page first and the focus
// happens deliberately after mount.
function focusOnMount(node: HTMLInputElement) {
node.focus();
}
function reset() {
preview = null;
duplicate = null;
error = null;
}
async function handlePreview() {
reset();
const trimmed = url.trim();
if (!trimmed) {
error = 'Bitte eine URL einfügen.';
return;
}
try {
new URL(trimmed);
} catch {
error = 'Das sieht nicht nach einer gültigen URL aus.';
return;
}
loading = true;
try {
const alreadySaved = await articlesStore.findByUrl(trimmed);
if (alreadySaved) {
duplicate = alreadySaved;
return;
}
preview = await extractArticle(trimmed);
} catch (e) {
error = e instanceof Error ? e.message : 'Extraktion fehlgeschlagen.';
} finally {
loading = false;
}
}
async function handleSave() {
if (!preview) return;
saving = true;
try {
const saved = await articlesStore.saveFromExtracted(preview);
goto(`/articles/${saved.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
saving = false;
}
}
</script>
<div class="add-shell">
<header class="header">
<h1>Artikel speichern</h1>
<p class="subtitle">URL einfügen, Vorschau prüfen, speichern.</p>
</header>
<div class="input-row">
<input
type="url"
class="url-input"
bind:value={url}
placeholder="https://…"
onkeydown={(e) => {
if (e.key === 'Enter') handlePreview();
}}
use:focusOnMount
/>
<button type="button" class="primary" disabled={loading} onclick={handlePreview}>
{loading ? 'Lädt…' : 'Vorschau abrufen'}
</button>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
{#if duplicate}
<div class="duplicate">
<p class="dup-headline">Den hast du schon gespeichert.</p>
<p class="dup-title">{duplicate.title}</p>
<div class="dup-actions">
<button type="button" class="primary" onclick={() => goto(`/articles/${duplicate!.id}`)}>
Zum gespeicherten Artikel
</button>
<button type="button" class="secondary" onclick={reset}>Andere URL</button>
</div>
</div>
{/if}
{#if preview}
<article class="preview">
<h2 class="preview-title">{preview.title}</h2>
<div class="preview-meta">
{#if preview.siteName}<span>{preview.siteName}</span>{/if}
{#if preview.author}<span>· {preview.author}</span>{/if}
{#if preview.readingTimeMinutes}<span>· {preview.readingTimeMinutes} min</span>{/if}
{#if preview.wordCount}<span>· {preview.wordCount} Wörter</span>{/if}
</div>
{#if preview.excerpt}
<p class="preview-excerpt">{preview.excerpt}</p>
{/if}
<div class="preview-actions">
<button type="button" class="primary" disabled={saving} onclick={handleSave}>
{saving ? 'Speichere…' : 'In Leseliste speichern'}
</button>
<button type="button" class="secondary" onclick={reset} disabled={saving}>
Abbrechen
</button>
</div>
</article>
{/if}
</div>
<style>
.add-shell {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.25rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
}
.subtitle {
color: var(--color-text-muted, #64748b);
margin: 0;
font-size: 0.95rem;
}
.input-row {
display: flex;
gap: 0.55rem;
margin-bottom: 0.9rem;
}
.url-input {
flex: 1;
padding: 0.6rem 0.85rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
.url-input:focus {
outline: 2px solid #f97316;
outline-offset: 1px;
border-color: transparent;
}
button {
padding: 0.55rem 1rem;
border-radius: 0.55rem;
font: inherit;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
button:disabled {
opacity: 0.6;
cursor: progress;
}
.primary {
background: #f97316;
color: white;
border-color: #f97316;
}
.primary:hover:not(:disabled) {
background: #ea580c;
border-color: #ea580c;
}
.secondary {
background: transparent;
color: inherit;
border-color: var(--color-border, rgba(0, 0, 0, 0.15));
}
.secondary:hover:not(:disabled) {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.error {
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.9rem;
}
.preview,
.duplicate {
margin-top: 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.75rem;
background: color-mix(in srgb, #f97316 3%, transparent);
}
.preview-title {
margin: 0 0 0.4rem 0;
font-size: 1.2rem;
line-height: 1.35;
}
.preview-meta {
font-size: 0.82rem;
color: var(--color-text-muted, #64748b);
margin-bottom: 0.7rem;
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.preview-excerpt {
margin: 0 0 1rem 0;
line-height: 1.55;
}
.preview-actions,
.dup-actions {
display: flex;
gap: 0.5rem;
}
.dup-headline {
margin: 0 0 0.3rem 0;
font-weight: 600;
}
.dup-title {
margin: 0 0 0.9rem 0;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -0,0 +1,285 @@
<!--
HighlightLayer — orchestrates highlight overlays + selection menu.
Pattern:
- On every `highlights` change (or when the Reader re-renders because
`html` changed) we unwrap all previously-applied highlight spans
and re-apply fresh. The DOM is the source of truth for offset
resolution, so we tolerate the "rebuild on change" cost.
- `mouseup` on the scroller checks for a live selection; if found, we
show the create-menu at the selection rect.
- `click` on an existing highlight span (`span[data-hl-id]`) opens
the edit-menu for that one.
- `mousedown` elsewhere dismisses the menu.
Coordinates: the menu is positioned relative to `container` (the
`detail-shell` from DetailView). We project viewport rects into the
container's local frame by subtracting its bounding-rect origin.
-->
<script lang="ts">
import { useArticleHighlights } from '../queries';
import { highlightsStore } from '../stores/highlights.svelte';
import {
extractSelectionSnapshot,
textOffsetsToSlices,
type SelectionSnapshot,
} from '../lib/offsets';
import HighlightMenu from './HighlightMenu.svelte';
import type { Highlight, HighlightColor } from '../types';
interface Props {
articleId: string;
/** The Reader's scrollable content root — where text lives. */
scroller: HTMLElement | null;
/** The positioning ancestor — menu coordinates are relative to this. */
container: HTMLElement | null;
/** Re-apply when the Reader's HTML changes (theme swap → re-render). */
htmlVersion: unknown;
}
let { articleId, scroller, container, htmlVersion }: Props = $props();
const highlights$ = $derived.by(() => useArticleHighlights(articleId));
const highlights = $derived(highlights$.value);
type MenuState =
| { kind: 'create'; snapshot: SelectionSnapshot; top: number; left: number }
| { kind: 'edit'; highlight: Highlight; top: number; left: number }
| null;
let menu = $state<MenuState>(null);
// ─── Overlay application ──────────────────────────────
//
// Re-runs whenever `highlights` or `htmlVersion` changes. `htmlVersion`
// is bumped by the parent whenever ReaderView replaces its DOM (e.g. a
// new article loaded), so we know to re-wrap.
$effect(() => {
// Track dependencies. Without these reads Svelte wouldn't know to
// re-run when highlights or htmlVersion changes.
const list = highlights;
void htmlVersion;
if (!scroller) return;
unwrapAll(scroller);
for (const h of list) applyHighlight(scroller, h);
});
function unwrapAll(root: HTMLElement) {
const spans = root.querySelectorAll<HTMLSpanElement>('span[data-hl-id]');
for (const span of Array.from(spans)) {
const parent = span.parentNode;
if (!parent) continue;
while (span.firstChild) parent.insertBefore(span.firstChild, span);
parent.removeChild(span);
// Merge adjacent text nodes so future offset walks stay stable.
parent.normalize();
}
}
function applyHighlight(root: HTMLElement, h: Highlight) {
const slices = textOffsetsToSlices(root, h.startOffset, h.endOffset);
for (const slice of slices) {
const range = document.createRange();
range.setStart(slice.node, slice.start);
range.setEnd(slice.node, slice.end);
const span = document.createElement('span');
span.dataset.hlId = h.id;
span.dataset.hlColor = h.color;
span.className = `article-highlight article-highlight-${h.color}`;
if (h.note) span.dataset.hlNote = h.note;
try {
range.surroundContents(span);
} catch {
// surroundContents throws when the range crosses element
// boundaries — shouldn't happen here since textOffsetsToSlices
// splits per text node, but we still guard so a single bad
// highlight doesn't kill the whole overlay pass.
}
}
}
// ─── Selection → create menu ─────────────────────────
function onSelectionEnd(event: MouseEvent) {
// Ignore mouseups that land on existing highlights — those open the
// edit menu via the separate click handler.
const target = event.target as HTMLElement | null;
if (target?.closest('span[data-hl-id]')) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return;
}
if (!scroller || !container) return;
const range = selection.getRangeAt(0);
if (!scroller.contains(range.commonAncestorContainer)) return;
const snapshot = extractSelectionSnapshot(range, scroller);
if (!snapshot) return;
const { top, left } = rectToLocal(range.getBoundingClientRect(), container);
menu = { kind: 'create', snapshot, top, left };
}
// ─── Click on existing highlight → edit menu ──────────
function onClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const span = target?.closest('span[data-hl-id]') as HTMLSpanElement | null;
if (!span) return;
const id = span.dataset.hlId;
if (!id) return;
const existing = highlights.find((h) => h.id === id);
if (!existing || !container) return;
const { top, left } = rectToLocal(span.getBoundingClientRect(), container);
menu = { kind: 'edit', highlight: existing, top, left };
event.stopPropagation();
}
function onMousedown(event: MouseEvent) {
if (!menu) return;
const target = event.target as HTMLElement | null;
if (target?.closest('.article-highlight-menu-anchor')) return;
if (target?.closest('span[data-hl-id]')) return;
// Clicking into the menu itself is fine; it lives under
// .article-highlight-menu-anchor too.
menu = null;
}
function rectToLocal(rect: DOMRect, anchor: HTMLElement) {
const origin = anchor.getBoundingClientRect();
return {
top: rect.bottom - origin.top + 8,
left: rect.left - origin.left,
};
}
$effect(() => {
if (!scroller) return;
scroller.addEventListener('mouseup', onSelectionEnd);
scroller.addEventListener('click', onClick);
document.addEventListener('mousedown', onMousedown);
return () => {
scroller.removeEventListener('mouseup', onSelectionEnd);
scroller.removeEventListener('click', onClick);
document.removeEventListener('mousedown', onMousedown);
};
});
// ─── Menu actions ─────────────────────────────────────
async function handleCreate(color: HighlightColor, note: string | null) {
if (menu?.kind !== 'create') return;
const s = menu.snapshot;
menu = null;
window.getSelection()?.removeAllRanges();
await highlightsStore.addHighlight({
articleId,
text: s.text,
color,
note,
startOffset: s.start,
endOffset: s.end,
contextBefore: s.contextBefore,
contextAfter: s.contextAfter,
});
}
async function handleUpdate(color: HighlightColor, note: string | null) {
if (menu?.kind !== 'edit') return;
const id = menu.highlight.id;
menu = null;
await highlightsStore.setColor(id, color);
await highlightsStore.setNote(id, note);
}
async function handleDelete() {
if (menu?.kind !== 'edit') return;
const id = menu.highlight.id;
menu = null;
await highlightsStore.deleteHighlight(id);
}
</script>
<div class="article-highlight-menu-anchor">
{#if menu?.kind === 'create'}
<HighlightMenu
mode="create"
top={menu.top}
left={menu.left}
onsave={handleCreate}
oncancel={() => (menu = null)}
/>
{:else if menu?.kind === 'edit'}
<HighlightMenu
mode="edit"
top={menu.top}
left={menu.left}
initialColor={menu.highlight.color}
initialNote={menu.highlight.note}
onupdate={handleUpdate}
ondelete={handleDelete}
onclose={() => (menu = null)}
/>
{/if}
</div>
<style>
.article-highlight-menu-anchor {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
}
.article-highlight-menu-anchor :global(.menu) {
pointer-events: auto;
}
/* Highlight-Spans werden programmatisch eingefügt; die Styles müssen */
/* global greifen, weil die Spans nicht zum Markup dieser Komponente */
/* gehören sondern im Reader-DOM leben. */
:global(.article-highlight) {
border-radius: 0.1rem;
padding: 0 0.1em;
cursor: pointer;
transition: filter 120ms ease;
}
:global(.article-highlight:hover) {
filter: brightness(0.94);
}
:global(.article-highlight-yellow) {
background: #fde68a;
color: #1e293b;
}
:global(.article-highlight-green) {
background: #bbf7d0;
color: #1e293b;
}
:global(.article-highlight-blue) {
background: #bfdbfe;
color: #1e293b;
}
:global(.article-highlight-pink) {
background: #fbcfe8;
color: #1e293b;
}
/* Dunkler Reader-Modus bekommt eigene Farben: weniger Saturation, Text */
/* bleibt lesbar auf dunklem Hintergrund. */
:global(.reader-dark .article-highlight-yellow) {
background: rgba(253, 224, 71, 0.35);
color: inherit;
}
:global(.reader-dark .article-highlight-green) {
background: rgba(134, 239, 172, 0.3);
color: inherit;
}
:global(.reader-dark .article-highlight-blue) {
background: rgba(147, 197, 253, 0.3);
color: inherit;
}
:global(.reader-dark .article-highlight-pink) {
background: rgba(249, 168, 212, 0.3);
color: inherit;
}
</style>

View file

@ -0,0 +1,208 @@
<!--
HighlightMenu — floating popover anchored near a selection or an
existing highlight span.
Two modes:
- mode="create" → shown right after the user makes a selection.
Color swatches + optional note, "Speichern" / "Abbrechen".
- mode="edit" → shown when the user clicks an existing highlight.
Color change + note edit + delete.
The component is positioned absolutely inside a positioned parent;
HighlightLayer computes the `top`/`left` props from the selection
rect and passes them in.
-->
<script lang="ts">
import { untrack } from 'svelte';
import type { HighlightColor } from '../types';
const COLORS: HighlightColor[] = ['yellow', 'green', 'blue', 'pink'];
interface CreateProps {
mode: 'create';
top: number;
left: number;
initialColor?: HighlightColor;
onsave: (color: HighlightColor, note: string | null) => void;
oncancel: () => void;
}
interface EditProps {
mode: 'edit';
top: number;
left: number;
initialColor: HighlightColor;
initialNote: string | null;
onupdate: (color: HighlightColor, note: string | null) => void;
ondelete: () => void;
onclose: () => void;
}
type Props = CreateProps | EditProps;
const props: Props = $props();
// The menu is destroyed + re-mounted whenever the parent switches
// between create/edit branches, so reading props once at mount for
// the initial local state is intentional. untrack() tells Svelte's
// analyzer "I know this isn't reactive, that's the point."
let color = $state<HighlightColor>(
untrack(() => (props.mode === 'edit' ? props.initialColor : (props.initialColor ?? 'yellow')))
);
let note = $state<string>(
untrack(() => (props.mode === 'edit' ? (props.initialNote ?? '') : ''))
);
let showNote = $state(untrack(() => props.mode === 'edit' && (props.initialNote ?? '') !== ''));
function submit() {
const finalNote = note.trim() || null;
if (props.mode === 'create') {
props.onsave(color, finalNote);
} else {
props.onupdate(color, finalNote);
}
}
</script>
<div class="menu" style:top="{props.top}px" style:left="{props.left}px" role="dialog">
<div class="swatches" role="radiogroup" aria-label="Farbe">
{#each COLORS as c (c)}
<button
type="button"
class="swatch swatch-{c}"
class:active={color === c}
onclick={() => (color = c)}
aria-label={c}
aria-pressed={color === c}
></button>
{/each}
</div>
{#if showNote}
<textarea
class="note"
bind:value={note}
placeholder="Notiz (optional)…"
rows="2"
onkeydown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
}}
></textarea>
{:else}
<button type="button" class="add-note" onclick={() => (showNote = true)}>+ Notiz</button>
{/if}
<div class="actions">
{#if props.mode === 'create'}
<button type="button" class="primary" onclick={submit}>Speichern</button>
<button type="button" class="secondary" onclick={props.oncancel}>Abbrechen</button>
{:else}
<button type="button" class="primary" onclick={submit}>Übernehmen</button>
<button type="button" class="danger" onclick={props.ondelete}>Löschen</button>
<button type="button" class="secondary" onclick={props.onclose}>Schließen</button>
{/if}
</div>
</div>
<style>
.menu {
position: absolute;
z-index: 50;
background: #ffffff;
color: #1e293b;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0.55rem;
padding: 0.55rem;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.08),
0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 220px;
max-width: 320px;
}
.swatches {
display: flex;
gap: 0.35rem;
}
.swatch {
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
}
.swatch.active {
border-color: rgba(0, 0, 0, 0.55);
}
.swatch-yellow {
background: #fde68a;
}
.swatch-green {
background: #bbf7d0;
}
.swatch-blue {
background: #bfdbfe;
}
.swatch-pink {
background: #fbcfe8;
}
.note {
font: inherit;
padding: 0.45rem 0.55rem;
border-radius: 0.4rem;
border: 1px solid rgba(0, 0, 0, 0.15);
background: white;
color: inherit;
resize: vertical;
min-height: 2.5rem;
}
.add-note {
font: inherit;
font-size: 0.82rem;
padding: 0.35rem 0.6rem;
border: 1px dashed rgba(0, 0, 0, 0.2);
background: transparent;
color: inherit;
border-radius: 0.4rem;
cursor: pointer;
align-self: flex-start;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.actions button {
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
border: 1px solid transparent;
cursor: pointer;
}
.primary {
background: #f97316;
color: white;
border-color: #f97316;
}
.primary:hover {
background: #ea580c;
}
.secondary {
background: transparent;
color: inherit;
border-color: rgba(0, 0, 0, 0.15);
}
.secondary:hover {
border-color: rgba(0, 0, 0, 0.35);
}
.danger {
background: transparent;
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
</style>

View file

@ -0,0 +1,190 @@
<!--
ReaderView — pure typography shell.
Renders the sanitised htmlContent that came back from Readability. We
DON'T sanitise client-side: Readability already emits a clean subset
(no <script>, no inline handlers) and the content landed in IndexedDB
only after an authenticated server call to our own extraction route.
Same approach as the news reader at /news/[id].
The shell is completely dumb — parent passes html + typography props
and listens for progress updates on scroll. No mutation happens here.
-->
<script lang="ts">
import { untrack } from 'svelte';
interface Props {
html: string | null;
plainFallback: string;
theme?: 'light' | 'dark' | 'sepia';
fontSize?: number;
fontFamily?: 'serif' | 'sans';
initialProgress?: number;
onprogress?: (progress: number) => void;
/** Callback fires once the scroller div is mounted — the HighlightLayer
* needs this ref to attach selection listeners and wrap text-node
* ranges. Fires with null on unmount for cleanup.
*/
onscroller?: (el: HTMLDivElement | null) => void;
}
let {
html,
plainFallback,
theme = 'light',
fontSize = 1,
fontFamily = 'serif',
initialProgress = 0,
onprogress,
onscroller,
}: Props = $props();
let scroller: HTMLDivElement | undefined = $state();
let lastReported = $state(0);
$effect(() => {
onscroller?.(scroller ?? null);
return () => onscroller?.(null);
});
$effect(() => {
if (!scroller) return;
// Restore last-read position ONCE when the scroller mounts. Reading
// `initialProgress` inside `untrack` stops our own progress updates
// — which flow back as new initialProgress values — from kicking the
// scroll back every time the user moves.
untrack(() => {
if (!scroller) return;
const target = initialProgress * (scroller.scrollHeight - scroller.clientHeight);
if (target > 0 && Number.isFinite(target)) scroller.scrollTop = target;
lastReported = initialProgress;
});
});
let scrollRaf = 0;
function onScroll() {
if (!scroller || !onprogress) return;
// Coalesce scroll events — reporting every pixel would hammer Dexie.
if (scrollRaf) cancelAnimationFrame(scrollRaf);
scrollRaf = requestAnimationFrame(() => {
if (!scroller) return;
const max = scroller.scrollHeight - scroller.clientHeight;
const progress = max > 0 ? scroller.scrollTop / max : 0;
// Only emit on meaningful deltas (>1%) to spare the DB.
if (Math.abs(progress - lastReported) > 0.01) {
lastReported = progress;
onprogress?.(progress);
}
});
}
</script>
<div
bind:this={scroller}
class="reader reader-{theme} reader-{fontFamily}"
style:--reader-font-size="{fontSize}rem"
onscroll={onScroll}
>
{#if html}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html html}
{:else}
<pre class="plain">{plainFallback}</pre>
{/if}
</div>
<style>
.reader {
overflow-y: auto;
padding: 1.5rem clamp(1rem, 5vw, 3rem) 4rem;
font-size: var(--reader-font-size);
line-height: 1.65;
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.reader-serif {
font-family: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
}
.reader-sans {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.reader-light {
color: #1e293b;
background: #ffffff;
}
.reader-dark {
color: #e2e8f0;
background: #0f172a;
}
.reader-sepia {
color: #433422;
background: #f4ecd8;
}
.reader :global(h1),
.reader :global(h2),
.reader :global(h3) {
line-height: 1.3;
margin-top: 1.6em;
margin-bottom: 0.5em;
}
.reader :global(h1) {
font-size: 1.55em;
}
.reader :global(h2) {
font-size: 1.3em;
}
.reader :global(h3) {
font-size: 1.1em;
}
.reader :global(p) {
margin: 0 0 1.05em 0;
}
.reader :global(a) {
color: #ea580c;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.reader-dark :global(a) {
color: #fdba74;
}
.reader :global(img) {
max-width: 100%;
height: auto;
border-radius: 0.35rem;
margin: 1em 0;
}
.reader :global(blockquote) {
border-left: 3px solid currentColor;
opacity: 0.85;
margin: 1.2em 0;
padding: 0.15em 0 0.15em 1em;
font-style: italic;
}
.reader :global(pre),
.reader :global(code) {
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.88em;
}
.reader :global(pre) {
background: rgba(0, 0, 0, 0.06);
padding: 0.8em 1em;
border-radius: 0.4rem;
overflow-x: auto;
}
.reader-dark :global(pre) {
background: rgba(255, 255, 255, 0.08);
}
.reader :global(ul),
.reader :global(ol) {
padding-left: 1.4em;
margin: 0 0 1.05em 0;
}
.plain {
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
margin: 0;
}
</style>

View file

@ -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';

View file

@ -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 `<p>Hello <strong>world</strong></p>`
* has the offsets `H=0, e=1, …, w=6, o=7, …`. <br>, <img>, 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,
};
}

View file

@ -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' },
],
};

View file

@ -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<LocalArticle, string>('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<LocalArticle>('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<LocalHighlight, string>(
'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)
);
}

View file

@ -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<void> {
const diff: Partial<LocalArticle> = {
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<void> {
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<void> {
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<void> {
const diff: Partial<LocalArticle> = {
userNote: note,
updatedAt: new Date().toISOString(),
};
await encryptRecord('articles', diff as LocalArticle);
await articleTable.update(id, diff);
},
async deleteArticle(id: string): Promise<void> {
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<Article | null> {
const match = await scopedForModule<LocalArticle, string>('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/<id>` 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<Article> {
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 };
},
};

View file

@ -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<Highlight> {
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<void> {
await articleHighlightTable.update(id, {
color,
updatedAt: new Date().toISOString(),
});
},
async setNote(id: string, note: string | null): Promise<void> {
const diff: Partial<LocalHighlight> = {
note,
updatedAt: new Date().toISOString(),
};
await encryptRecord('articleHighlights', diff as LocalHighlight);
await articleHighlightTable.update(id, diff);
},
async deleteHighlight(id: string): Promise<void> {
await articleHighlightTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -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',
});

View file

@ -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;
}

View file

@ -0,0 +1,366 @@
<!--
DetailView — article reader + action bar.
Composes the ReaderView typography shell with an action bar (status,
favourite, archive, delete, external link) and a size/theme-picker
that sits sticky at the top.
Reading progress is persisted per scroll event (throttled in the
Reader). Re-opening the article restores the last scroll position.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useArticle } from '../queries';
import { articlesStore } from '../stores/articles.svelte';
import ReaderView from '../components/ReaderView.svelte';
import HighlightLayer from '../components/HighlightLayer.svelte';
interface Props {
id: string;
}
let { id }: Props = $props();
// Re-create the live query when [id] changes. Without $derived.by the
// subscription binds to the initial id only, so navigating directly from
// one article's detail view to another's (same mount) would keep showing
// the old one.
const article$ = $derived.by(() => useArticle(id));
const article = $derived(article$.value);
// Typography state — per-session only for now. Persisting into userSettings
// comes later; M2 just gets the UX loop working.
let fontSize = $state(1);
let theme = $state<'light' | 'dark' | 'sepia'>('light');
let fontFamily = $state<'serif' | 'sans'>('serif');
// Refs handed off to HighlightLayer: `shell` is the positioning anchor
// for the floating menu, `readerScroller` is where text lives + where
// selection events fire.
let shell: HTMLDivElement | undefined = $state();
let readerScroller = $state<HTMLDivElement | null>(null);
async function toggleRead() {
if (!article) return;
await articlesStore.setStatus(
article.id,
article.status === 'finished' ? 'unread' : 'finished'
);
}
async function toggleFavorite() {
if (!article) return;
await articlesStore.toggleFavorite(article.id);
}
async function archive() {
if (!article) return;
await articlesStore.setStatus(article.id, 'archived');
goto('/articles');
}
async function deleteArticle() {
if (!article) return;
if (!confirm('Artikel wirklich löschen?')) return;
await articlesStore.deleteArticle(article.id);
goto('/articles');
}
async function onProgress(progress: number) {
if (!article) return;
// First meaningful scroll flips unread → reading; reader handles the
// rest of the lifecycle (mark-finished is an explicit user action).
if (article.status === 'unread' && progress > 0.05) {
await articlesStore.setStatus(article.id, 'reading');
}
await articlesStore.setProgress(article.id, progress);
}
</script>
<svelte:head>
<title>{article?.title ?? 'Artikel'} — Mana</title>
</svelte:head>
<div class="detail-shell detail-{theme}" bind:this={shell}>
<header class="topbar">
<button type="button" class="topbtn" onclick={() => goto('/articles')} aria-label="Zurück">
← Zurück
</button>
{#if article}
<div class="type-controls">
<button
type="button"
class="topbtn"
onclick={() => (fontSize = Math.max(0.85, fontSize - 0.075))}
title="Kleiner"
aria-label="Schrift kleiner"
>
A
</button>
<button
type="button"
class="topbtn"
onclick={() => (fontSize = Math.min(1.35, fontSize + 0.075))}
title="Größer"
aria-label="Schrift größer"
>
A+
</button>
<span class="divider"></span>
<button
type="button"
class="topbtn"
class:active={fontFamily === 'serif'}
onclick={() => (fontFamily = 'serif')}
title="Serif"
>
Serif
</button>
<button
type="button"
class="topbtn"
class:active={fontFamily === 'sans'}
onclick={() => (fontFamily = 'sans')}
title="Sans"
>
Sans
</button>
<span class="divider"></span>
<button
type="button"
class="topbtn swatch swatch-light"
class:active={theme === 'light'}
onclick={() => (theme = 'light')}
aria-label="Heller Modus"
title="Hell"
></button>
<button
type="button"
class="topbtn swatch swatch-sepia"
class:active={theme === 'sepia'}
onclick={() => (theme = 'sepia')}
aria-label="Sepia-Modus"
title="Sepia"
></button>
<button
type="button"
class="topbtn swatch swatch-dark"
class:active={theme === 'dark'}
onclick={() => (theme = 'dark')}
aria-label="Dunkler Modus"
title="Dunkel"
></button>
</div>
{/if}
</header>
{#if article$.loading}
<p class="placeholder">Lädt…</p>
{:else if !article}
<div class="placeholder">
<p>Artikel nicht gefunden.</p>
<button type="button" class="topbtn" onclick={() => goto('/articles')}>
Zurück zur Liste
</button>
</div>
{:else}
<div class="meta-bar">
<h1 class="title">{article.title}</h1>
<div class="meta-row">
{#if article.siteName}<span>{article.siteName}</span>{/if}
{#if article.author}<span>· {article.author}</span>{/if}
{#if article.readingTimeMinutes}<span>· {article.readingTimeMinutes} min</span>{/if}
{#if article.wordCount}<span>· {article.wordCount} Wörter</span>{/if}
</div>
</div>
<ReaderView
html={article.htmlContent}
plainFallback={article.content}
{theme}
{fontSize}
{fontFamily}
initialProgress={article.readingProgress}
onprogress={onProgress}
onscroller={(el) => (readerScroller = el)}
/>
<HighlightLayer
articleId={article.id}
scroller={readerScroller}
container={shell ?? null}
htmlVersion={article.htmlContent}
/>
<footer class="actionbar">
<button
type="button"
class="actionbtn"
class:active={article.status === 'finished'}
onclick={toggleRead}
>
{article.status === 'finished' ? '✓ Gelesen' : 'Als gelesen markieren'}
</button>
<button
type="button"
class="actionbtn"
class:active={article.isFavorite}
onclick={toggleFavorite}
aria-label="Favorit umschalten"
>
{article.isFavorite ? '★ Favorit' : '☆ Favorit'}
</button>
<button type="button" class="actionbtn" onclick={archive}>Archivieren</button>
<a class="actionbtn" href={article.originalUrl} target="_blank" rel="noopener noreferrer">
Original ↗
</a>
<span class="spacer"></span>
<button type="button" class="actionbtn danger" onclick={deleteArticle}>Löschen</button>
</footer>
{/if}
</div>
<style>
.detail-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
/* Positioning anchor for the floating HighlightMenu: its `top`/`left` */
/* coordinates are computed relative to this box. */
position: relative;
}
.detail-light {
background: #ffffff;
color: #1e293b;
}
.detail-sepia {
background: #f4ecd8;
color: #433422;
}
.detail-dark {
background: #0f172a;
color: #e2e8f0;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: inherit;
}
.type-controls {
display: flex;
gap: 0.3rem;
margin-left: auto;
flex-wrap: wrap;
}
.divider {
width: 1px;
height: 1.2em;
background: color-mix(in srgb, currentColor 25%, transparent);
align-self: center;
margin: 0 0.2rem;
}
.topbtn {
font: inherit;
font-size: 0.82rem;
padding: 0.3rem 0.65rem;
background: transparent;
color: inherit;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.45rem;
cursor: pointer;
text-decoration: none;
line-height: 1.1;
}
.topbtn:hover {
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.topbtn.active {
background: color-mix(in srgb, currentColor 10%, transparent);
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.swatch {
width: 1.7rem;
height: 1.7rem;
padding: 0;
border-radius: 50%;
overflow: hidden;
}
.swatch-light {
background: #ffffff;
}
.swatch-sepia {
background: #f4ecd8;
}
.swatch-dark {
background: #0f172a;
}
.meta-bar {
max-width: 700px;
margin: 1.2rem auto 0;
padding: 0 clamp(1rem, 5vw, 3rem);
width: 100%;
}
.title {
font-size: 1.8rem;
line-height: 1.25;
margin: 0 0 0.4rem 0;
}
.meta-row {
font-size: 0.85rem;
opacity: 0.75;
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.actionbar {
position: sticky;
bottom: 0;
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: inherit;
flex-wrap: wrap;
align-items: center;
}
.actionbtn {
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
background: transparent;
color: inherit;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.45rem;
cursor: pointer;
text-decoration: none;
}
.actionbtn:hover {
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.actionbtn.active {
background: color-mix(in srgb, #f97316 85%, transparent);
color: white;
border-color: #f97316;
}
.actionbtn.danger {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 30%, transparent);
}
.actionbtn.danger:hover {
background: color-mix(in srgb, #ef4444 10%, transparent);
}
.spacer {
flex: 1;
}
.placeholder {
text-align: center;
margin: 3rem auto;
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/articles/ListView.svelte';
</script>
<svelte:head>
<title>Artikel - Mana</title>
</svelte:head>
<ListView />

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import DetailView from '$lib/modules/articles/views/DetailView.svelte';
const id = $derived($page.params.id ?? '');
</script>
<DetailView {id} />

View file

@ -0,0 +1,9 @@
<script lang="ts">
import AddUrlForm from '$lib/modules/articles/components/AddUrlForm.svelte';
</script>
<svelte:head>
<title>Artikel speichern — Mana</title>
</svelte:head>
<AddUrlForm />

View file

@ -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<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 …")

View file

@ -231,6 +231,12 @@ export const APP_ICONS = {
// gradient sits next to music/photos/picture in the Kreativität & Medien row.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="lb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a855f7"/><stop offset="100%" style="stop-color:#d946ef"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#lb)"/><rect x="22" y="28" width="10" height="44" rx="2" fill="white" fill-opacity="0.95"/><rect x="34" y="24" width="10" height="48" rx="2" fill="white" fill-opacity="0.8"/><rect x="46" y="30" width="10" height="42" rx="2" fill="white" fill-opacity="0.95"/><rect x="58" y="28" width="22" height="44" rx="3" fill="white"/><rect x="62" y="34" width="4" height="4" fill="#a855f7"/><rect x="72" y="34" width="4" height="4" fill="#a855f7"/><rect x="62" y="44" width="4" height="4" fill="#a855f7"/><rect x="72" y="44" width="4" height="4" fill="#a855f7"/><rect x="62" y="54" width="4" height="4" fill="#a855f7"/><rect x="72" y="54" width="4" height="4" fill="#a855f7"/><rect x="62" y="64" width="4" height="4" fill="#a855f7"/><rect x="72" y="64" width="4" height="4" fill="#a855f7"/><rect x="20" y="74" width="62" height="4" rx="2" fill="white" fill-opacity="0.5"/></svg>`
),
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.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#f59e0b"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><path d="M28 22h30l18 18v38a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M58 22v14a4 4 0 0 0 4 4h14" fill="none" stroke="#f97316" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="48" width="26" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><rect x="32" y="56" width="22" height="3" rx="1.5" fill="#f97316" fill-opacity="0.45"/><rect x="32" y="64" width="24" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><path d="M62 54v22l8-6 8 6V54a4 4 0 0 0-4-4h-8a4 4 0 0 0-4 4z" fill="#f97316"/></svg>`
),
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.

View file

@ -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',