feat(articles): M4 tags + status filter, M5 migrate news:type='saved'

M4 — Tags + Filter:
- queries.ts: useArticleTagIds(id) + batched useArticleTagMap(ids)
  live queries against articleTagOps (the junction into globalTags).
- DetailView: TagField from @mana/shared-ui with the global tag pool +
  this article's selected ids; onChange fans out through
  articleTagOps.setTags, which diffs add/remove internally.
- ListView: 6 filter chips (Alle | Ungelesen | In Arbeit | Gelesen |
  Favoriten | Archiv) with live counts. Archived articles are hidden
  from the "Alle" view and only surface under the Archiv filter. Tag
  chips render inline on each card using the batched tag map + the
  global tag pool for colour lookup.

M5 — Migration + news deprecation:
- modules/articles/migrations/from-news.ts: boot-gated migration (per-
  device localStorage sentinel). Reads newsArticles with type='saved',
  decrypts under the newsArticles allowlist, re-encrypts under the
  articles allowlist, and copies into the articles table. Status maps
  isArchived→archived, isRead→finished, else unread. Source rows get
  soft-deleted so the sync engine removes them from other devices.
  Ran after crypto init (from (app)/+layout.svelte boot block), not
  in the Dexie .upgrade() hook, because the decrypt→re-encrypt round-
  trip needs Web Crypto + the master key.
- news/stores/articles.svelte.ts: removed saveFromUrl — ad-hoc URL
  saves now live in the articles module.
- news/api.ts: removed extractFromUrl helper + ExtractedArticleDto.
  The /api/v1/news/extract/* routes stay in apps/api for now because
  news-research still hits them for RSS discovery.
- news/index.ts: dropped the extractFromUrl re-export.
- news/tools.ts: the save_news_article AI tool keeps its name (so
  historic Mission iterations in the DB still resolve) but its
  execute body now routes through the articles module's saveFromUrl.
- routes/(app)/news/add + /news/saved: replaced with single-shot
  redirects to /articles/add and /articles respectively.
- news-research ListView + page: "Speichern" buttons now route to
  the articles module and navigate to /articles/[id] on success.

Plan: docs/plans/articles-module.md. M6 (AI tools + proposal inbox),
M7 (share target + bookmarklet), M8 (highlights view + stats) open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 18:17:04 +02:00
parent 9d6a5a53a8
commit 04293ed5e7
14 changed files with 415 additions and 918 deletions

View file

@ -1,17 +1,72 @@
<!--
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.
Articles — ListView
Filter chips (Alle | Ungelesen | In Arbeit | Favoriten | Archiv) + card
list with per-card tag chips. Tag names + colours come from the global
tags table via useAllTags; the per-article tag ids via a batched
getTagIdsForMany to avoid N+1.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllArticles } from './queries';
import { TagChip } from '@mana/shared-ui';
import { useAllArticles, useArticleTagMap } from './queries';
import { useAllTags } from './stores/tags.svelte';
import type { Article } from './types';
type Filter = 'all' | 'unread' | 'reading' | 'finished' | 'favorites' | 'archived';
const FILTERS: { id: Filter; label: string }[] = [
{ id: 'all', label: 'Alle' },
{ id: 'unread', label: 'Ungelesen' },
{ id: 'reading', label: 'In Arbeit' },
{ id: 'finished', label: 'Gelesen' },
{ id: 'favorites', label: 'Favoriten' },
{ id: 'archived', label: 'Archiv' },
];
const articles$ = useAllArticles();
const articles = $derived(articles$.value);
const tagMap$ = $derived.by(() => useArticleTagMap(articles.map((a) => a.id)));
const allTags$ = useAllTags();
let filter = $state<Filter>('all');
const filtered = $derived.by(() => {
const a = articles;
switch (filter) {
case 'all':
return a.filter((x) => x.status !== 'archived');
case 'unread':
return a.filter((x) => x.status === 'unread');
case 'reading':
return a.filter((x) => x.status === 'reading');
case 'finished':
return a.filter((x) => x.status === 'finished');
case 'favorites':
return a.filter((x) => x.isFavorite && x.status !== 'archived');
case 'archived':
return a.filter((x) => x.status === 'archived');
}
});
const counts = $derived.by(() => ({
all: articles.filter((x) => x.status !== 'archived').length,
unread: articles.filter((x) => x.status === 'unread').length,
reading: articles.filter((x) => x.status === 'reading').length,
finished: articles.filter((x) => x.status === 'finished').length,
favorites: articles.filter((x) => x.isFavorite && x.status !== 'archived').length,
archived: articles.filter((x) => x.status === 'archived').length,
}));
function tagsFor(article: Article) {
const ids = tagMap$.value.get(article.id) ?? [];
if (ids.length === 0) return [];
const all = allTags$.value;
return ids
.map((id) => all.find((t) => t.id === id))
.filter((t): t is (typeof all)[number] => !!t);
}
function openArticle(a: Article) {
goto(`/articles/${a.id}`);
}
@ -28,6 +83,22 @@
+ Neu speichern
</button>
</div>
<div class="filter-row" role="tablist" aria-label="Filter">
{#each FILTERS as f (f.id)}
<button
type="button"
class="filter-chip"
class:active={filter === f.id}
role="tab"
aria-selected={filter === f.id}
onclick={() => (filter = f.id)}
>
{f.label}
<span class="count">{counts[f.id]}</span>
</button>
{/each}
</div>
</header>
{#if articles$.loading}
@ -43,9 +114,15 @@
Erste URL speichern
</button>
</div>
{:else if filtered.length === 0}
<div class="empty-state">
<p class="empty-headline">Nichts in diesem Filter.</p>
<p class="empty-sub">Probier einen anderen Filter oder speichere weitere Artikel.</p>
</div>
{:else}
<ul class="article-list">
{#each articles as article (article.id)}
{#each filtered as article (article.id)}
{@const articleTags = tagsFor(article)}
<li>
<button type="button" class="article-card" onclick={() => openArticle(article)}>
<div class="meta">
@ -56,11 +133,21 @@
<span class="reading-time">{article.readingTimeMinutes} min</span>
{/if}
<span class="status status-{article.status}">{article.status}</span>
{#if article.isFavorite}
<span class="fav" title="Favorit"></span>
{/if}
</div>
<div class="title">{article.title}</div>
{#if article.excerpt}
<div class="excerpt">{article.excerpt}</div>
{/if}
{#if articleTags.length > 0}
<div class="tags">
{#each articleTags as tag (tag.id)}
<TagChip name={tag.name} color={tag.color} />
{/each}
</div>
{/if}
</button>
</li>
{/each}
@ -75,13 +162,14 @@
padding: 1.5rem;
}
.header {
margin-bottom: 1.5rem;
margin-bottom: 1.25rem;
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
@ -108,6 +196,39 @@
margin: 0;
font-size: 0.95rem;
}
.filter-row {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.filter-chip {
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.filter-chip:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.25));
}
.filter-chip.active {
background: #f97316;
border-color: #f97316;
color: white;
}
.filter-chip .count {
font-size: 0.72rem;
opacity: 0.8;
padding: 0 0.35rem;
background: color-mix(in srgb, currentColor 15%, transparent);
border-radius: 999px;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
@ -178,10 +299,17 @@
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-reading {
background: rgba(249, 115, 22, 0.12);
color: #f97316;
}
.status-archived {
background: rgba(100, 116, 139, 0.15);
color: #64748b;
}
.fav {
color: #f59e0b;
}
.title {
font-weight: 600;
font-size: 1rem;
@ -197,4 +325,10 @@
line-clamp: 2;
-webkit-box-orient: vertical;
}
.tags {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.1rem;
}
</style>

View file

@ -10,6 +10,8 @@ export {
useAllArticles,
useArticle,
useArticleHighlights,
useArticleTagIds,
useArticleTagMap,
toArticle,
toHighlight,
filterByStatus,

View file

@ -0,0 +1,164 @@
/**
* One-off migration: move `newsArticles` with `type='saved'` into the
* new `articles` module.
*
* Runs at app-shell boot (from routes/(app)/+layout.svelte) rather than
* inside the Dexie `.upgrade()` hook because we need the encryption
* layer initialised: the source rows are encrypted under the
* `newsArticles` field allowlist, the target rows need to be
* re-encrypted under the `articles` allowlist, and both roundtrips
* require Web Crypto + the master key which the Dexie upgrade path
* runs before.
*
* Idempotent: a localStorage sentinel prevents re-runs per device.
* The original rows are soft-deleted (deletedAt stamped) so the sync
* layer propagates the removal to the server and to other devices.
*
* Migration mapping:
* newsArticles.isArchived = true articles.status = 'archived'
* newsArticles.isRead = true articles.status = 'finished'
* otherwise articles.status = 'unread'
*
* isFavorite, createdAt, userId carry across. `sourceSlug` /
* `sourceCuratedId` / `categoryId` don't have a counterpart on
* articles (they're news-feed-specific) and are dropped — the user's
* reading-list view doesn't depend on them.
*/
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import { hasAnyEncryption } from '$lib/data/crypto/registry';
import type { LocalArticle as NewLocalArticle, ArticleStatus } from '../types';
const SENTINEL_KEY = 'mana:articles:from-news-migration:v1';
// Shape of the source rows we care about. Kept narrow so the migration
// stays decoupled from the news module's evolving type file.
interface LegacyNewsArticle {
id: string;
type: 'curated' | 'saved';
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;
isArchived?: boolean;
isRead?: boolean;
isFavorite?: boolean;
userId?: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
}
function statusFor(row: LegacyNewsArticle): ArticleStatus {
if (row.isArchived) return 'archived';
if (row.isRead) return 'finished';
return 'unread';
}
/**
* Run the migration once per device. Returns the number of rows moved.
* Fire-and-forget from app boot; errors are logged but never thrown so
* a single broken row never blocks the rest of the app from starting.
*/
export async function runArticlesFromNewsMigration(): Promise<number> {
if (typeof window === 'undefined') return 0;
if (window.localStorage.getItem(SENTINEL_KEY)) return 0;
// The migration requires the crypto layer to be live. If the app is
// running entirely plaintext (Phase 1 bootstrap or a test harness),
// decryptRecords is a pass-through so this still works — we check
// anyway as a defensive gate and bail if the registry isn't ready.
try {
// Access the flag so linters don't flag the import as unused when
// someone later decides the gate isn't worth keeping. The call is
// cheap either way.
hasAnyEncryption();
} catch {
return 0;
}
try {
const newsTable = db.table<LegacyNewsArticle>('newsArticles');
const articlesTable = db.table<NewLocalArticle>('articles');
const candidates = await newsTable.where('type').equals('saved').toArray();
const visible = candidates.filter((row) => !row.deletedAt);
if (visible.length === 0) {
window.localStorage.setItem(SENTINEL_KEY, new Date().toISOString());
return 0;
}
const decrypted = (await decryptRecords(
'newsArticles',
visible as unknown as Record<string, unknown>[]
)) as unknown as LegacyNewsArticle[];
const now = new Date().toISOString();
let moved = 0;
// Separate transactions: one write batch per row with its own
// encryption roundtrip, so a single bad row doesn't lose the
// batch. Dexie auto-batches the internal index updates either way.
for (const row of decrypted) {
try {
const newRow: NewLocalArticle = {
id: crypto.randomUUID(),
originalUrl: row.originalUrl,
title: row.title,
excerpt: row.excerpt,
content: row.content,
htmlContent: row.htmlContent,
author: row.author,
siteName: row.siteName,
imageUrl: row.imageUrl,
wordCount: row.wordCount,
readingTimeMinutes: row.readingTimeMinutes,
publishedAt: row.publishedAt,
status: statusFor(row),
readingProgress: 0,
isFavorite: row.isFavorite ?? false,
savedAt: row.createdAt ?? now,
readAt: row.isRead ? (row.updatedAt ?? now) : null,
userNote: null,
extractedVersion: 1,
// userId is stamped by the Dexie creating-hook from the active
// session — don't set it manually, let the hook do its job.
};
await encryptRecord('articles', newRow);
await articlesTable.add(newRow);
// Soft-delete the source so the sync engine removes it from
// the server + other devices. Keep it in the local table so
// if someone later rolls back the migration they can still
// see what was there.
await newsTable.update(row.id, { deletedAt: now, updatedAt: now });
moved++;
} catch (rowErr) {
console.warn(`[articles/from-news] skipping row ${row.id}${(rowErr as Error).message}`);
}
}
window.localStorage.setItem(SENTINEL_KEY, now);
if (moved > 0) {
console.info(`[articles/from-news] migrated ${moved} saved article(s) into /articles`);
}
return moved;
} catch (err) {
console.error('[articles/from-news] migration failed:', err);
return 0;
}
}
/** Clear the sentinel so the next boot re-runs. Test / recovery helper only. */
export function resetArticlesFromNewsSentinel(): void {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(SENTINEL_KEY);
}
}

View file

@ -9,6 +9,7 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule, scopedGet } from '$lib/data/scope';
import { articleTagOps } from './stores/tags.svelte';
import type { LocalArticle, LocalHighlight, Article, Highlight, ArticleStatus } from './types';
// ─── Type Converters ─────────────────────────────────────
@ -84,6 +85,27 @@ export function useArticle(id: string) {
);
}
/**
* Tag IDs currently linked to this article. Live reacts to both
* `articleTags` junction writes and tag CRUD on the global `tags`
* table, so the DetailView's TagField stays in sync with both sides.
*/
export function useArticleTagIds(articleId: string) {
return useLiveQueryWithDefault(async () => articleTagOps.getTagIds(articleId), [] as string[]);
}
/**
* Batched tag-id lookup for the ListView. Returns a Map keyed by
* articleId; entries with no tags are absent from the map. Single
* Dexie query regardless of how many articles are shown.
*/
export function useArticleTagMap(articleIds: string[]) {
return useLiveQueryWithDefault(
async () => articleTagOps.getTagIdsForMany(articleIds),
new Map<string, string[]>()
);
}
export function useArticleHighlights(articleId: string) {
return useLiveQueryWithDefault(async () => {
// scopedForModule returns the scope-filtered Collection; we narrow

View file

@ -10,8 +10,10 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useArticle } from '../queries';
import { TagField } from '@mana/shared-ui';
import { useArticle, useArticleTagIds } from '../queries';
import { articlesStore } from '../stores/articles.svelte';
import { articleTagOps, useAllTags } from '../stores/tags.svelte';
import ReaderView from '../components/ReaderView.svelte';
import HighlightLayer from '../components/HighlightLayer.svelte';
@ -27,6 +29,12 @@
const article$ = $derived.by(() => useArticle(id));
const article = $derived(article$.value);
// Tags: globally-available tag pool + the ids linked to *this* article.
// TagField takes the full pool + selected ids; on change we fan-out
// through articleTagOps.setTags which handles add/remove diff internally.
const allTags$ = useAllTags();
const tagIds$ = $derived.by(() => useArticleTagIds(id));
// Typography state — per-session only for now. Persisting into userSettings
// comes later; M2 just gets the UX loop working.
let fontSize = $state(1);
@ -74,6 +82,11 @@
}
await articlesStore.setProgress(article.id, progress);
}
async function onTagsChange(ids: string[]) {
if (!article) return;
await articleTagOps.setTags(article.id, ids);
}
</script>
<svelte:head>
@ -172,6 +185,15 @@
{#if article.readingTimeMinutes}<span>· {article.readingTimeMinutes} min</span>{/if}
{#if article.wordCount}<span>· {article.wordCount} Wörter</span>{/if}
</div>
<div class="tags-row">
<TagField
tags={allTags$.value}
selectedIds={tagIds$.value}
onChange={onTagsChange}
addLabel="Tag"
placeholder="Tag suchen oder erstellen…"
/>
</div>
</div>
<ReaderView
@ -318,6 +340,9 @@
gap: 0.3rem;
flex-wrap: wrap;
}
.tags-row {
margin-top: 0.75rem;
}
.actionbar {
position: sticky;
bottom: 0;

View file

@ -13,7 +13,7 @@
import { goto } from '$app/navigation';
import type { ViewProps } from '$lib/app-registry';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { usePreferences } from '$lib/modules/news/queries';
@ -71,8 +71,8 @@
savingUrl = articleUrl;
saveError = null;
try {
const article = await articlesStore.saveFromUrl(articleUrl);
goto(`/news/${article.id}`);
const { article } = await articlesStore.saveFromUrl(articleUrl);
goto(`/articles/${article.id}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
savingUrl = null;

View file

@ -83,39 +83,7 @@ export async function fetchFeed(
return (await response.json()) as FeedArticleDto[];
}
// ─── Ad-hoc URL extraction ─────────────────────────────────
export interface ExtractedArticleDto {
id: string;
type: 'saved';
sourceOrigin: 'user_saved';
originalUrl: string;
title: string;
content: string;
htmlContent: string;
excerpt: string;
author: string | null;
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
isArchived: boolean;
}
export async function extractFromUrl(
url: string,
fetchImpl: typeof fetch = fetch
): Promise<ExtractedArticleDto> {
const response = await fetchImpl(`${getManaApiUrl()}/api/v1/news/extract/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ url }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`extractFromUrl failed: ${response.status} ${text}`);
}
return (await response.json()) as ExtractedArticleDto;
}
// Ad-hoc URL extraction moved to the `articles` module in M5 — see
// `modules/articles/api.ts` and `modules/articles/stores/articles.svelte.ts`.
// The `/api/v1/news/extract/*` routes in apps/api are kept for now as
// a legacy surface; the `news-research` module still relies on them.

View file

@ -42,7 +42,7 @@ export { preferencesStore } from './stores/preferences.svelte';
export { reactionsStore } from './stores/reactions.svelte';
export { feedCacheStore } from './stores/feed-cache.svelte';
export { fetchFeed, extractFromUrl } from './api';
export { fetchFeed } from './api';
export type { FeedArticleDto, FeedQuery } from './api';
export { SOURCES_META, SOURCE_META_BY_SLUG, sourcesForTopic, TOPIC_LABELS } from './sources-meta';

View file

@ -1,12 +1,13 @@
/**
* Articles store the user's saved reading list.
*
* Two paths in:
* - saveFromCurated(article) copies a row from the local pool
* mirror into the encrypted reading list. Used when the user
* hits "speichern" on a feed card.
* - saveFromUrl(url) hits POST /api/v1/news/extract/save and
* stores the result. Used by /news/add for ad-hoc URLs.
* Now single-purpose: saveFromCurated copies a row from the local pool
* mirror into the encrypted reading list (hit when the user presses
* "speichern" on a feed card). The ad-hoc URL path (`saveFromUrl` +
* the `type: 'saved'` discriminator) moved to the Articles module in
* M5 see `modules/articles/migrations/from-news.ts` for the one-off
* data migration and `modules/articles/stores/articles.svelte.ts` for
* the replacement flow.
*
* All other operations (read/archive/favorite/delete) are plain
* updates against `newsArticles`.
@ -15,7 +16,6 @@
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { articleTable } from '../collections';
import { extractFromUrl } from '../api';
import { toArticle } from '../queries';
import type { Article, LocalArticle, LocalCachedArticle } from '../types';
@ -58,35 +58,6 @@ export const articlesStore = {
return snapshot;
},
async saveFromUrl(url: string): Promise<Article> {
const extracted = await extractFromUrl(url);
const newLocal: LocalArticle = {
id: crypto.randomUUID(),
type: 'saved',
sourceCuratedId: null,
originalUrl: extracted.originalUrl,
title: extracted.title,
excerpt: extracted.excerpt,
content: extracted.content,
htmlContent: extracted.htmlContent,
author: extracted.author,
siteName: extracted.siteName,
sourceSlug: null,
imageUrl: null,
categoryId: null,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
publishedAt: null,
isArchived: false,
isRead: false,
isFavorite: false,
};
const snapshot = toArticle(newLocal);
await encryptRecord('newsArticles', newLocal);
await articleTable.add(newLocal);
return snapshot;
},
async markRead(id: string, isRead = true): Promise<void> {
await articleTable.update(id, {
isRead,

View file

@ -2,15 +2,19 @@
* News Tools LLM-accessible operations for the news module.
*
* `save_news_article` is the agent's path into the user's reading list.
* On approve, the executor calls `articlesStore.saveFromUrl(url)` which
* routes through `apps/api /api/v1/news/extract/save` (Readability) and
* stores the encrypted result in `newsArticles`. `title` and `summary`
* are display hints the canonical title/excerpt come back from the
* extractor so the AI can't lie about content.
* M5 moved the saved-article storage to the `articles` module; this
* tool now routes through `articlesStore.saveFromUrl(url)` there. The
* tool name stays `save_news_article` because historic AI mission
* iterations in the DB reference it renaming would break the audit
* trail. A future `save_article` can be added as an alias in M6.
*
* `title` and `summary` are display hints for the approval dialog
* the canonical title/excerpt come from the extractor so the AI can't
* lie about content.
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { articlesStore } from './stores/articles.svelte';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
export const newsTools: ModuleTool[] = [
{
@ -35,11 +39,13 @@ export const newsTools: ModuleTool[] = [
],
async execute(params) {
const url = params.url as string;
const article = await articlesStore.saveFromUrl(url);
const { article, duplicate } = await articlesStore.saveFromUrl(url);
return {
success: true,
message: `Artikel gespeichert: ${article.title}`,
data: { articleId: article.id, title: article.title },
message: duplicate
? `Artikel bereits gespeichert: ${article.title}`
: `Artikel gespeichert: ${article.title}`,
data: { articleId: article.id, title: article.title, duplicate },
};
},
},

View file

@ -8,6 +8,7 @@
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
import { startMissionTick, stopMissionTick } from '$lib/data/ai/missions/setup';
import { runArticlesFromNewsMigration } from '$lib/modules/articles/migrations/from-news';
import {
startServerIterationExecutor,
stopServerIterationExecutor,
@ -546,6 +547,10 @@
// Apply server-planned iterations locally on sync — see
// data/ai/missions/server-iteration-executor.ts.
startServerIterationExecutor();
// One-off migration: legacy news `type='saved'` rows → new
// articles module. Sentinel-gated so it runs once per device.
// See modules/articles/migrations/from-news.ts.
void runArticlesFromNewsMigration();
});
// Restore nav collapsed state (cheap, keep inline)

View file

@ -8,7 +8,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { usePreferences } from '$lib/modules/news/queries';
@ -75,8 +75,8 @@
savingUrl = articleUrl;
saveError = null;
try {
const article = await articlesStore.saveFromUrl(articleUrl);
goto(`/news/${article.id}`);
const { article } = await articlesStore.saveFromUrl(articleUrl);
goto(`/articles/${article.id}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
savingUrl = null;

View file

@ -1,137 +1,23 @@
<!--
/news/add — paste an arbitrary URL, hit save, get a saved article.
Calls POST /api/v1/news/extract/save which runs Mozilla Readability
on the server, returns the cleaned article shape, and we drop it
into the encrypted reading list via articlesStore.saveFromUrl.
Legacy redirect: /news/add → /articles/add.
The ad-hoc URL-save flow moved to the articles module in M5.
Kept here so existing bookmarks and cross-links keep working.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { onMount } from 'svelte';
let url = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
function looksLikeUrl(s: string): boolean {
try {
const u = new URL(s.trim());
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
async function submit(e: Event) {
e.preventDefault();
if (busy || !looksLikeUrl(url)) return;
busy = true;
error = null;
try {
const article = await articlesStore.saveFromUrl(url.trim());
goto(`/news/${article.id}`);
} catch (err) {
error = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
} finally {
busy = false;
}
}
onMount(() => {
goto('/articles/add', { replaceState: true });
});
</script>
<svelte:head>
<title>URL hinzufügen — News — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/news/saved')}> Gespeichert</button>
<h1>Artikel speichern</h1>
<p class="hint">
Füge eine URL ein. Wir extrahieren den Volltext (Mozilla Readability) und legen ihn in deine
verschlüsselte Leseliste.
</p>
</header>
<form class="form" onsubmit={submit}>
<!-- svelte-ignore a11y_autofocus -->
<input type="url" placeholder="https://…" bind:value={url} disabled={busy} autofocus required />
<button type="submit" disabled={busy || !looksLikeUrl(url)}>
{busy ? 'Lade…' : 'Speichern'}
</button>
</form>
{#if error}
<div class="error">{error}</div>
{/if}
</div>
<p class="redirect">Verschoben nach <a href="/articles/add">/articles/add</a></p>
<style>
.page {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.hint {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
margin-top: 0.5rem;
}
.form {
display: flex;
gap: 0.5rem;
}
.form input {
flex: 1;
padding: 0.625rem 0.875rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
outline: none;
}
.form input:focus {
border-color: hsl(var(--color-primary));
}
.form button {
padding: 0.625rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
}
.form button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.error {
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-destructive) / 0.15);
border: 1px solid hsl(var(--color-destructive) / 0.4);
color: hsl(var(--color-destructive));
font-size: 0.875rem;
.redirect {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -1,709 +1,23 @@
<!--
Saved articles — the user's personal reading list.
Three tabs: Ungelesen / Favoriten / Archiv. Each card opens the
shared reader at /news/[id]; the reader's dual-source lookup means
the same URL works whether the article was saved from the curated
pool or pasted as an ad-hoc URL.
Legacy redirect: /news/saved → /articles.
The "saved reading list" moved into the articles module in M5.
Kept here so existing bookmarks and cross-links keep working.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import {
useSavedArticles,
useCategories,
formatRelativeTime,
toArticle,
} from '$lib/modules/news/queries';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { categoriesStore } from '$lib/modules/news/stores/categories.svelte';
import { articleTable } from '$lib/modules/news/collections';
import { decryptRecords } from '$lib/data/crypto';
import type { Article } from '$lib/modules/news/types';
import { onMount } from 'svelte';
const saved$ = useSavedArticles();
const categories$ = useCategories();
const all = $derived(saved$.value);
const categories = $derived(categories$.value);
type Tab = 'unread' | 'favorites' | 'archive';
let tab = $state<Tab>('unread');
// `null` = no filter (show all in the active tab). Otherwise the
// categoryId we're scoped to.
let activeCategoryId = $state<string | null>(null);
let showCategoryEditor = $state(false);
let newCategoryName = $state('');
let renamingId = $state<string | null>(null);
let renamingName = $state('');
const filtered = $derived.by(() => {
const base = (() => {
switch (tab) {
case 'unread':
return all.filter((a) => !a.isRead && !a.isArchived);
case 'favorites':
return all.filter((a) => a.isFavorite && !a.isArchived);
case 'archive':
// `archived` is filled by the effect below.
return [] as Article[];
}
})();
if (activeCategoryId == null) return base;
return base.filter((a) => a.categoryId === activeCategoryId);
onMount(() => {
goto('/articles', { replaceState: true });
});
// For "archive" tab: read isArchived rows directly from Dexie. Kept
// minimal — not worth a second liveQuery hook for the MVP.
let archived = $state<Article[]>([]);
$effect(() => {
if (tab !== 'archive') return;
void (async () => {
const rows = (await articleTable.toArray()).filter((a) => !a.deletedAt && a.isArchived);
const decrypted = await decryptRecords('newsArticles', rows);
archived = decrypted.map(toArticle).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
})();
});
const visible = $derived(
tab === 'archive'
? activeCategoryId == null
? archived
: archived.filter((a) => a.categoryId === activeCategoryId)
: filtered
);
// Counts per category for the filter pills, computed against the
// currently visible base set (so the numbers reflect the active tab).
const baseSet = $derived(tab === 'archive' ? archived : filtered);
const countByCategory = $derived.by(() => {
const map: Record<string, number> = {};
// `filtered` already applies the category filter, so we need a
// pre-filter base. Re-derive it here.
const pre =
tab === 'unread'
? all.filter((a) => !a.isRead && !a.isArchived)
: tab === 'favorites'
? all.filter((a) => a.isFavorite && !a.isArchived)
: archived;
for (const a of pre) {
const key = a.categoryId ?? '__none__';
map[key] = (map[key] ?? 0) + 1;
}
map.__all__ = pre.length;
return map;
});
// Touch baseSet to silence the unused-binding linter — it's used to
// keep the derivation of countByCategory reactive in case the upstream
// query refreshes during a tab switch.
$effect(() => {
void baseSet.length;
});
function open(article: Article) {
// Curated saves keep their server uuid in sourceCuratedId, but
// the local id is the primary key the reader prefers. Either id
// works — the reader resolves both.
goto(`/news/${article.sourceCuratedId ?? article.id}`);
}
async function toggleFav(e: Event, id: string) {
e.stopPropagation();
await articlesStore.toggleFavorite(id);
}
async function archive(e: Event, id: string) {
e.stopPropagation();
await articlesStore.archive(id);
}
async function unarchive(e: Event, id: string) {
e.stopPropagation();
await articleTable.update(id, {
isArchived: false,
updatedAt: new Date().toISOString(),
});
}
async function remove(e: Event, id: string) {
e.stopPropagation();
await articlesStore.delete(id);
}
async function setCategory(articleId: string, categoryId: string | null) {
await articlesStore.setCategory(articleId, categoryId);
}
async function createCategory() {
const name = newCategoryName.trim();
if (!name) return;
const created = await categoriesStore.create({ name });
newCategoryName = '';
activeCategoryId = created.id;
}
function startRename(id: string, currentName: string) {
renamingId = id;
renamingName = currentName;
}
async function commitRename() {
if (!renamingId) return;
await categoriesStore.rename(renamingId, renamingName);
renamingId = null;
}
async function deleteCategory(id: string) {
if (!confirm('Kategorie löschen? Artikel bleiben erhalten.')) return;
await categoriesStore.delete(id);
if (activeCategoryId === id) activeCategoryId = null;
}
</script>
<svelte:head>
<title>Gespeichert — News — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<div>
<button type="button" class="back" onclick={() => goto('/news')}> Feed</button>
<h1>Gespeichert</h1>
</div>
<a class="add-link" href="/news/add">+ URL hinzufügen</a>
</header>
<nav class="tabs">
<button
type="button"
class="tab"
class:active={tab === 'unread'}
onclick={() => (tab = 'unread')}
>
Ungelesen
</button>
<button
type="button"
class="tab"
class:active={tab === 'favorites'}
onclick={() => (tab = 'favorites')}
>
Favoriten
</button>
<button
type="button"
class="tab"
class:active={tab === 'archive'}
onclick={() => (tab = 'archive')}
>
Archiv
</button>
</nav>
<!-- Category filter strip -->
<div class="categories-bar">
<div class="cat-pills">
<button
type="button"
class="cat-pill"
class:active={activeCategoryId === null}
onclick={() => (activeCategoryId = null)}
>
Alle
<span class="count">{countByCategory.__all__ ?? 0}</span>
</button>
{#each categories as cat (cat.id)}
<button
type="button"
class="cat-pill"
class:active={activeCategoryId === cat.id}
style:--cat-color={cat.color}
onclick={() => (activeCategoryId = cat.id)}
>
<span class="dot" style:background={cat.color}></span>
{#if renamingId === cat.id}
<input
type="text"
bind:value={renamingName}
onblur={commitRename}
onkeydown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') renamingId = null;
}}
/>
{:else}
<span ondblclick={() => startRename(cat.id, cat.name)} role="button" tabindex="0">
{cat.name}
</span>
{/if}
<span class="count">{countByCategory[cat.id] ?? 0}</span>
</button>
{/each}
<button
type="button"
class="cat-edit"
onclick={() => (showCategoryEditor = !showCategoryEditor)}
title="Kategorien verwalten"
>
{showCategoryEditor ? '✕' : ''}
</button>
</div>
{#if showCategoryEditor}
<div class="cat-editor">
<form
class="cat-add"
onsubmit={(e) => {
e.preventDefault();
void createCategory();
}}
>
<input
type="text"
placeholder="Neue Kategorie…"
bind:value={newCategoryName}
maxlength="40"
/>
<button type="submit" disabled={!newCategoryName.trim()}>Hinzufügen</button>
</form>
{#if categories.length > 0}
<ul class="cat-list">
{#each categories as cat (cat.id)}
<li>
<span class="dot" style:background={cat.color}></span>
<span class="cat-name">{cat.name}</span>
<button type="button" class="link" onclick={() => startRename(cat.id, cat.name)}>
umbenennen
</button>
<button type="button" class="link danger" onclick={() => deleteCategory(cat.id)}>
löschen
</button>
</li>
{/each}
</ul>
{:else}
<p class="hint">Noch keine Kategorien. Erstelle eine oben.</p>
{/if}
</div>
{/if}
</div>
{#if visible.length === 0}
<div class="empty">
{#if tab === 'unread'}
<p>Keine ungelesenen Artikel.</p>
<p class="hint">Reagiere im Feed mit „❤️ Interessiert" um Artikel hier zu sammeln.</p>
{:else if tab === 'favorites'}
<p>Noch keine Favoriten.</p>
{:else}
<p>Archiv ist leer.</p>
{/if}
</div>
{:else}
<div class="list">
{#each visible as article (article.id)}
<article class="row">
{#if article.imageUrl}
<button
type="button"
class="thumb-btn"
onclick={() => open(article)}
aria-label="Öffnen"
>
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="row-body">
<div class="row-meta">
<span class="site">{article.siteName ?? 'Eigener Link'}</span>
{#if article.publishedAt}
<span>·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
{/if}
{#if article.readingTimeMinutes}
<span>·</span>
<span>{article.readingTimeMinutes} min</span>
{/if}
{#if article.type === 'saved'}
<span class="badge">eigen</span>
{/if}
</div>
<button type="button" class="row-title" onclick={() => open(article)}>
{article.title}
</button>
{#if article.excerpt}
<p class="row-excerpt">{article.excerpt}</p>
{/if}
</div>
<div class="row-actions">
<select
class="cat-select"
value={article.categoryId ?? ''}
onchange={(e) => {
const v = (e.currentTarget as HTMLSelectElement).value;
void setCategory(article.id, v === '' ? null : v);
}}
onclick={(e) => e.stopPropagation()}
title="Kategorie"
>
<option value="">— Keine —</option>
{#each categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
<button
type="button"
class="icon"
class:active={article.isFavorite}
onclick={(e) => toggleFav(e, article.id)}
title="Favorit"
>
</button>
{#if tab === 'archive'}
<button
type="button"
class="icon"
onclick={(e) => unarchive(e, article.id)}
title="Wiederherstellen"
>
↩︎
</button>
{:else}
<button
type="button"
class="icon"
onclick={(e) => archive(e, article.id)}
title="Archivieren"
>
📦
</button>
{/if}
<button
type="button"
class="icon danger"
onclick={(e) => remove(e, article.id)}
title="Löschen"
>
🗑
</button>
</div>
</article>
{/each}
</div>
{/if}
</div>
<p class="redirect">Verschoben nach <a href="/articles">/articles</a></p>
<style>
.page {
max-width: 880px;
margin: 0 auto;
padding: 0 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.add-link {
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
text-decoration: none;
font-size: 0.8125rem;
font-weight: 500;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.tab {
padding: 0.625rem 0.875rem;
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active {
color: hsl(var(--color-foreground));
border-bottom-color: hsl(var(--color-primary));
}
.categories-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cat-pills {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.cat-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.625rem;
border-radius: 999px;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.cat-pill.active {
background: hsl(var(--color-primary) / 0.18);
border-color: hsl(var(--color-primary) / 0.5);
}
.cat-pill .dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.cat-pill .count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-background));
padding: 0 0.35rem;
border-radius: 999px;
min-width: 1.1rem;
.redirect {
padding: 2rem;
text-align: center;
}
.cat-pill input {
background: transparent;
border: none;
color: hsl(var(--color-foreground));
font: inherit;
min-width: 4rem;
outline: none;
}
.cat-edit {
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
background: hsl(var(--color-background));
border: 1px dashed hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.cat-editor {
padding: 0.875rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.cat-add {
display: flex;
gap: 0.375rem;
}
.cat-add input {
flex: 1;
padding: 0.4rem 0.625rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.4rem;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
outline: none;
}
.cat-add button {
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.8125rem;
cursor: pointer;
}
.cat-add button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cat-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.cat-list li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.cat-list .dot {
display: inline-block;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
}
.cat-list .cat-name {
flex: 1;
color: hsl(var(--color-foreground));
}
.cat-list .link {
background: none;
border: none;
padding: 0;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
text-decoration: underline;
}
.cat-list .link.danger {
color: hsl(var(--color-destructive));
}
.cat-editor .hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.cat-select {
max-width: 9rem;
padding: 0.25rem 0.4rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
}
.empty {
text-align: center;
padding: 4rem 0;
color: hsl(var(--color-muted-foreground));
}
.empty .hint {
font-size: 0.875rem;
margin-top: 0.5rem;
}
.list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.875rem;
padding: 0.875rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
}
.thumb-btn {
width: 96px;
height: 64px;
border: none;
padding: 0;
background: hsl(var(--color-background));
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
}
.thumb-btn img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.row-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.row-meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.row-meta .site {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.row-meta .badge {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
padding: 0 0.4rem;
border-radius: 999px;
font-weight: 600;
}
.row-title {
text-align: left;
background: none;
border: none;
padding: 0;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
cursor: pointer;
line-height: 1.35;
}
.row-title:hover {
color: hsl(var(--color-primary));
}
.row-excerpt {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-self: center;
}
.icon {
width: 1.875rem;
height: 1.875rem;
border-radius: 0.375rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
font-size: 0.875rem;
}
.icon.active {
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary) / 0.4);
}
.icon.danger:hover {
background: hsl(var(--color-destructive) / 0.15);
border-color: hsl(var(--color-destructive) / 0.4);
color: var(--color-text-muted, #64748b);
}
</style>