From 04293ed5e7b720fb446f4587dd6c64b18590c02e Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 21 Apr 2026 18:17:04 +0200 Subject: [PATCH] feat(articles): M4 tags + status filter, M5 migrate news:type='saved' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/modules/articles/ListView.svelte | 148 +++- .../web/src/lib/modules/articles/index.ts | 2 + .../modules/articles/migrations/from-news.ts | 164 ++++ .../web/src/lib/modules/articles/queries.ts | 22 + .../modules/articles/views/DetailView.svelte | 27 +- .../lib/modules/news-research/ListView.svelte | 6 +- .../mana/apps/web/src/lib/modules/news/api.ts | 40 +- .../apps/web/src/lib/modules/news/index.ts | 2 +- .../modules/news/stores/articles.svelte.ts | 43 +- .../apps/web/src/lib/modules/news/tools.ts | 24 +- .../apps/web/src/routes/(app)/+layout.svelte | 5 + .../routes/(app)/news-research/+page.svelte | 6 +- .../src/routes/(app)/news/add/+page.svelte | 138 +--- .../src/routes/(app)/news/saved/+page.svelte | 706 +----------------- 14 files changed, 415 insertions(+), 918 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/articles/migrations/from-news.ts diff --git a/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte b/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte index 3f4ddbea5..effe32cee 100644 --- a/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte @@ -1,17 +1,72 @@ @@ -172,6 +185,15 @@ {#if article.readingTimeMinutes}· {article.readingTimeMinutes} min{/if} {#if article.wordCount}· {article.wordCount} Wörter{/if} +
+ +
{ - 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. diff --git a/apps/mana/apps/web/src/lib/modules/news/index.ts b/apps/mana/apps/web/src/lib/modules/news/index.ts index 5bcb12abf..e041a5453 100644 --- a/apps/mana/apps/web/src/lib/modules/news/index.ts +++ b/apps/mana/apps/web/src/lib/modules/news/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts index fdfba8ee5..6087d0382 100644 --- a/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts @@ -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
{ - 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 { await articleTable.update(id, { isRead, diff --git a/apps/mana/apps/web/src/lib/modules/news/tools.ts b/apps/mana/apps/web/src/lib/modules/news/tools.ts index 4ffef8e99..99be9aa19 100644 --- a/apps/mana/apps/web/src/lib/modules/news/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/news/tools.ts @@ -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 }, }; }, }, diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 64d082533..a75c1a70d 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -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) diff --git a/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte b/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte index 7d41b95cb..cd361ce20 100644 --- a/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte @@ -8,7 +8,7 @@ - - URL hinzufügen — News — Mana - - -
-
- -

Artikel speichern

-

- Füge eine URL ein. Wir extrahieren den Volltext (Mozilla Readability) und legen ihn in deine - verschlüsselte Leseliste. -

-
- -
- - - -
- - {#if error} -
{error}
- {/if} -
+

Verschoben nach /articles/add

diff --git a/apps/mana/apps/web/src/routes/(app)/news/saved/+page.svelte b/apps/mana/apps/web/src/routes/(app)/news/saved/+page.svelte index adcbcbb7b..af8127534 100644 --- a/apps/mana/apps/web/src/routes/(app)/news/saved/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/news/saved/+page.svelte @@ -1,709 +1,23 @@ - - Gespeichert — News — Mana - - -
-
-
- -

Gespeichert

-
- + URL hinzufügen -
- - - - -
-
- - {#each categories as cat (cat.id)} - - {/each} - -
- {#if showCategoryEditor} -
-
{ - e.preventDefault(); - void createCategory(); - }} - > - - -
- {#if categories.length > 0} -
    - {#each categories as cat (cat.id)} -
  • - - {cat.name} - - -
  • - {/each} -
- {:else} -

Noch keine Kategorien. Erstelle eine oben.

- {/if} -
- {/if} -
- - {#if visible.length === 0} -
- {#if tab === 'unread'} -

Keine ungelesenen Artikel.

-

Reagiere im Feed mit „❤️ Interessiert" um Artikel hier zu sammeln.

- {:else if tab === 'favorites'} -

Noch keine Favoriten.

- {:else} -

Archiv ist leer.

- {/if} -
- {:else} -
- {#each visible as article (article.id)} -
- {#if article.imageUrl} - - {/if} -
-
- {article.siteName ?? 'Eigener Link'} - {#if article.publishedAt} - · - {formatRelativeTime(article.publishedAt)} - {/if} - {#if article.readingTimeMinutes} - · - {article.readingTimeMinutes} min - {/if} - {#if article.type === 'saved'} - eigen - {/if} -
- - {#if article.excerpt} -

{article.excerpt}

- {/if} -
-
- - - {#if tab === 'archive'} - - {:else} - - {/if} - -
-
- {/each} -
- {/if} -
+

Verschoben nach /articles