From 0112161e78c2b844bad858a55709747edaf0cdd0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 19 May 2026 16:43:54 +0200 Subject: [PATCH] =?UTF-8?q?chore(mana+api):=20articles=20+=20Backend-Worke?= =?UTF-8?q?r=20raus,=20pageta=20tr=C3=A4gt=20allein?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pageta ist seit 2026-05-17 standalone live (pageta.mana.how + pageta.com, voll-featured laut STATUS.md) und deckt alle Articles-Module-Features ab + mehr (research, reactions, feed, share, snapshot, preferences). Keine User-Daten im managarten/articles-Modul (Till bestätigt). Frontend entfernt: - apps/mana/apps/web/src/routes/(app)/articles/ (9 Routes inkl. (tabs), [id], add, import, import/[jobId], settings) - apps/mana/apps/web/src/lib/modules/articles/ (5 Stores, Queries, Collections, Types, Tools, Components, Widgets, ArticlesTabShell, consume-pickup, tab-context, parse-urls) - apps/mana/apps/web/src/lib/i18n/locales/articles/ (DE/EN/ES/FR/IT) Backend entfernt: - apps/api/src/modules/articles/ (routes, import-worker, import-projection, import-extractor, consent-wall, field-meta, plus Tests) - apps/api/src/index.ts: articlesRoutes + startArticleImportWorker raus - apps/api/src/lib/metrics.ts: 5 articles-Metrics raus (articlesImportTicks/Items/Extract/JobsCompleted/PickupGc) "Save-to-Articles"-Features in anderen Modulen entfernt (User kann später direkt in pageta speichern via Share-Sheet): - news-research/ListView + routes/(app)/news-research/+page.svelte: "Speichern"-Button raus - writing/tools.ts: save_draft_as_article-Tool raus - writing/components/ExportMenu.svelte: "Als Artikel speichern"-Option raus - writing/components/ReferencePicker.svelte: 'article'-Mode raus - writing/components/ReferenceChip.svelte: KIND_ICON/LABEL ohne 'article' - writing/utils/reference-resolver.ts: resolveArticle + 'article'-case raus - writing/utils/reference-resolver.test.ts: kind: 'article' → 'note' in Aggregate-Budget-Tests - writing/utils/prompt-builder.test.ts: 'article'-Resolved-Reference raus - writing/views/DetailView.svelte: 'articles'-published-Chip raus - writing/types.ts: DraftReferenceKind ohne 'article', DraftPublishModule ohne 'articles' Aktualisiert (Cross-Refs raus): - module-registry.ts (articlesModuleConfig) - module-registry.test.ts (articles-Tabellen + sync-name-Mappings) - data-layer-listeners.ts (startArticlePickupConsumer) - app-registry/apps.ts (registerApp 'articles') - packages/shared-branding/src/mana-apps.ts (articles-Eintrag) - components/dashboard/widget-registry.ts (ArticlesUnreadWidget) - types/dashboard.ts (WidgetType 'articles-unread') - data/crypto/registry.ts (LocalArticle/LocalHighlight) - data/crypto/plaintext-allowlist.ts (articleTags/articleImportJobs/ articleImportItems/articleExtractPickup) - data/tools/init.ts (articlesTools) NICHT angefasst (mit Absicht): - data/database.ts db.version()-Stores — Schema-Snapshots sind frozen. Tabellen articles, articleHighlights, articleTags, articleImportJobs, articleImportItems, articleExtractPickup bleiben im IndexedDB-Schema, werden aber nicht mehr beschrieben. - packages/shared-branding/src/app-icons.ts APP_ICONS.articles (für Native-PNG-Generator, harmlos). - apps/api/src/lib/sync-db.ts Z6 Kommentar (historisches Beispiel). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/index.ts | 8 - apps/api/src/lib/metrics.ts | 68 --- .../src/modules/articles/consent-wall.test.ts | 47 -- apps/api/src/modules/articles/consent-wall.ts | 37 -- .../src/modules/articles/field-meta.test.ts | 51 -- apps/api/src/modules/articles/field-meta.ts | 32 - .../src/modules/articles/import-extractor.ts | 226 -------- .../src/modules/articles/import-projection.ts | 250 -------- .../modules/articles/import-worker.test.ts | 80 --- .../api/src/modules/articles/import-worker.ts | 327 ----------- apps/api/src/modules/articles/routes.ts | 128 ---- .../apps/web/src/lib/app-registry/apps.ts | 38 +- .../components/dashboard/widget-registry.ts | 2 - .../lib/data/crypto/plaintext-allowlist.ts | 4 - .../apps/web/src/lib/data/crypto/registry.ts | 15 - .../web/src/lib/data/data-layer-listeners.ts | 8 - .../web/src/lib/data/module-registry.test.ts | 12 - .../apps/web/src/lib/data/module-registry.ts | 2 - apps/mana/apps/web/src/lib/data/tools/init.ts | 2 - .../web/src/lib/i18n/locales/articles/de.json | 100 ---- .../web/src/lib/i18n/locales/articles/en.json | 100 ---- .../web/src/lib/i18n/locales/articles/es.json | 100 ---- .../web/src/lib/i18n/locales/articles/fr.json | 100 ---- .../web/src/lib/i18n/locales/articles/it.json | 100 ---- .../modules/articles/ArticlesTabShell.svelte | 150 ----- .../src/lib/modules/articles/ListView.svelte | 329 ----------- .../apps/web/src/lib/modules/articles/api.ts | 131 ----- .../src/lib/modules/articles/collections.ts | 25 - .../articles/components/AddUrlForm.svelte | 529 ----------------- .../articles/components/ArticleCard.svelte | 170 ------ .../articles/components/BulkImportForm.svelte | 234 -------- .../articles/components/HighlightLayer.svelte | 294 ---------- .../articles/components/HighlightMenu.svelte | 208 ------- .../components/HomeSectionSources.svelte | 97 ---- .../components/HomeSectionStats.svelte | 63 -- .../components/HomeSectionWeiterlesen.svelte | 71 --- .../articles/components/JobDetailView.svelte | 435 -------------- .../articles/components/JobsList.svelte | 260 --------- .../articles/components/QuickAddInput.svelte | 133 ----- .../articles/components/ReaderView.svelte | 194 ------- .../lib/modules/articles/consume-pickup.ts | 202 ------- .../web/src/lib/modules/articles/index.ts | 35 -- .../modules/articles/lib/markdown-export.ts | 68 --- .../src/lib/modules/articles/lib/offsets.ts | 195 ------- .../src/lib/modules/articles/module.config.ts | 28 - .../src/lib/modules/articles/parse-urls.ts | 60 -- .../web/src/lib/modules/articles/queries.ts | 371 ------------ .../articles/stores/articles.svelte.ts | 129 ----- .../articles/stores/highlights.svelte.ts | 63 -- .../modules/articles/stores/imports.svelte.ts | 160 ----- .../modules/articles/stores/imports.test.ts | 120 ---- .../modules/articles/stores/tags.svelte.ts | 23 - .../src/lib/modules/articles/tab-context.ts | 28 - .../web/src/lib/modules/articles/tools.ts | 356 ------------ .../web/src/lib/modules/articles/types.ts | 253 -------- .../modules/articles/views/DetailView.svelte | 547 ------------------ .../articles/views/HighlightsView.svelte | 257 -------- .../modules/articles/views/StatsView.svelte | 95 --- .../widgets/ArticlesUnreadWidget.svelte | 78 --- .../lib/modules/news-research/ListView.svelte | 38 -- .../writing/components/ExportMenu.svelte | 57 +- .../writing/components/ReferenceChip.svelte | 2 - .../writing/components/ReferencePicker.svelte | 53 +- .../apps/web/src/lib/modules/writing/tools.ts | 52 -- .../apps/web/src/lib/modules/writing/types.ts | 11 +- .../writing/utils/prompt-builder.test.ts | 7 - .../writing/utils/reference-resolver.test.ts | 103 +--- .../writing/utils/reference-resolver.ts | 25 - .../modules/writing/views/DetailView.svelte | 11 +- apps/mana/apps/web/src/lib/types/dashboard.ts | 9 - .../routes/(app)/articles/(tabs)/+page.svelte | 13 - .../articles/(tabs)/favorites/+page.svelte | 12 - .../articles/(tabs)/highlights/+page.svelte | 12 - .../(app)/articles/(tabs)/list/+page.svelte | 12 - .../(app)/articles/(tabs)/stats/+page.svelte | 12 - .../routes/(app)/articles/[id]/+page.svelte | 11 - .../routes/(app)/articles/add/+page.svelte | 12 - .../routes/(app)/articles/import/+page.svelte | 14 - .../articles/import/[jobId]/+page.svelte | 17 - .../(app)/articles/settings/+page.svelte | 309 ---------- .../routes/(app)/news-research/+page.svelte | 41 -- packages/shared-branding/src/mana-apps.ts | 17 - 82 files changed, 21 insertions(+), 9057 deletions(-) delete mode 100644 apps/api/src/modules/articles/consent-wall.test.ts delete mode 100644 apps/api/src/modules/articles/consent-wall.ts delete mode 100644 apps/api/src/modules/articles/field-meta.test.ts delete mode 100644 apps/api/src/modules/articles/field-meta.ts delete mode 100644 apps/api/src/modules/articles/import-extractor.ts delete mode 100644 apps/api/src/modules/articles/import-projection.ts delete mode 100644 apps/api/src/modules/articles/import-worker.test.ts delete mode 100644 apps/api/src/modules/articles/import-worker.ts delete mode 100644 apps/api/src/modules/articles/routes.ts delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/articles/de.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/articles/en.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/articles/es.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/articles/fr.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/articles/it.json delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/ArticlesTabShell.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/ListView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/api.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/collections.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/ArticleCard.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/BulkImportForm.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionSources.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionStats.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionWeiterlesen.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/JobDetailView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/JobsList.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/QuickAddInput.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/index.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/lib/markdown-export.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/module.config.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/parse-urls.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/queries.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/stores/imports.svelte.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/stores/imports.test.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/tab-context.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/tools.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/types.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/views/HighlightsView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/views/StatsView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/articles/widgets/ArticlesUnreadWidget.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/(tabs)/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/(tabs)/favorites/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/(tabs)/highlights/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/(tabs)/list/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/(tabs)/stats/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/[id]/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/add/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/import/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/import/[jobId]/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(app)/articles/settings/+page.svelte diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2881f3fcc..c5716142e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -35,8 +35,6 @@ import { storageRoutes } from './modules/storage/routes'; import { todoRoutes } from './modules/todo/routes'; import { guidesRoutes } from './modules/guides/routes'; import { newsResearchRoutes } from './modules/news-research/routes'; -import { articlesRoutes } from './modules/articles/routes'; -import { startArticleImportWorker } from './modules/articles/import-worker'; import { tracesRoutes } from './modules/traces/routes'; import { writingRoutes } from './modules/writing/routes'; import { presiRoutes } from './modules/presi/routes'; @@ -129,7 +127,6 @@ app.route('/api/v1/storage', storageRoutes); app.route('/api/v1/todo', todoRoutes); app.route('/api/v1/guides', guidesRoutes); 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); @@ -139,11 +136,6 @@ app.route('/api/v1/writing', writingRoutes); app.route('/api/v1/personas/admin', personasAdminRoutes); // ─── Background Workers ───────────────────────────────────── -// Articles bulk-import: ticks every 2s, advisory-lock-gated so multiple -// apps/api replicas never double-process. See -// docs/plans/articles-bulk-import.md. -startArticleImportWorker(); - // Forms wave-cron (M10d): scans unlisted snapshots with internal_meta // for forms-recurrence configs, fires due waves via mana-mail's // internal bulk-send route. Advisory-lock-gated. See diff --git a/apps/api/src/lib/metrics.ts b/apps/api/src/lib/metrics.ts index 79440aa3a..140408979 100644 --- a/apps/api/src/lib/metrics.ts +++ b/apps/api/src/lib/metrics.ts @@ -104,71 +104,3 @@ export const websitePublicReadAge = new Histogram({ buckets: [1, 10, 60, 300, 1800, 3600, 21600, 86400], registers: [register], }); - -// ── Articles bulk-import worker ───────────────────────── - -/** - * Every worker tick, regardless of outcome. `result`: - * - `processed` — lock acquired, jobs scanned - * - `skipped` — advisory lock taken by another instance - * - `error` — tick threw (logged + rethrown) - */ -export const articlesImportTicksTotal = new Counter({ - name: 'mana_api_articles_import_ticks_total', - help: 'Articles bulk-import worker tick outcomes.', - labelNames: ['result'] as const, - registers: [register], -}); - -/** - * Each per-item terminal-state transition the worker observes. - * `result`: - * - `extracted` — server fetch + Readability succeeded, pickup row written - * - `error` — 3 attempts exhausted, item parked as 'error' - * - `consent_wall` — extracted but flagged probable_consent_wall - * - `cancelled` — flipped from pending → cancelled because parent - * job was cancelled mid-flight - */ -export const articlesImportItemsTotal = new Counter({ - name: 'mana_api_articles_import_items_total', - help: 'Articles bulk-import items by terminal-from-worker state.', - labelNames: ['result'] as const, - registers: [register], -}); - -/** - * End-to-end latency of one extractFromUrl call (network fetch + - * JSDOM parse + Readability). Exclude consent-wall flagging — that's - * a synchronous post-process. Buckets cover anything from a snappy - * blog (250ms) to the shared-rss timeout ceiling (15s). - */ -export const articlesImportExtractDuration = new Histogram({ - name: 'mana_api_articles_import_extract_duration_seconds', - help: 'extractFromUrl roundtrip time inside the bulk-import worker.', - buckets: [0.25, 0.5, 1, 2, 4, 8, 15, 30], - registers: [register], -}); - -/** - * Job-completion counter. `result`: - * - `done` — every item terminal, status flipped to done - * - `cancelled` — user cancelled before completion - */ -export const articlesImportJobsCompletedTotal = new Counter({ - name: 'mana_api_articles_import_jobs_completed_total', - help: 'Articles bulk-import jobs by terminal status.', - labelNames: ['result'] as const, - registers: [register], -}); - -/** - * Pickup-row GC sweep — how many stale rows were hard-deleted on each - * 30-tick run. Steady-state should be 0 (consumer drains them within - * seconds); a non-zero value over time signals a stuck consumer - * somewhere (closed tabs, broken Web-Lock). - */ -export const articlesImportPickupGcRows = new Counter({ - name: 'mana_api_articles_import_pickup_gc_rows_total', - help: 'articleExtractPickup rows hard-deleted by the worker GC sweep.', - registers: [register], -}); diff --git a/apps/api/src/modules/articles/consent-wall.test.ts b/apps/api/src/modules/articles/consent-wall.test.ts deleted file mode 100644 index 8cf35579d..000000000 --- a/apps/api/src/modules/articles/consent-wall.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { looksLikeConsentWall } from './consent-wall'; - -describe('looksLikeConsentWall', () => { - it('flags short text containing German consent vocabulary', () => { - const text = - 'Cookies zustimmen — Wir und unsere Partner speichern Informationen auf einem Endgerät.'; - expect(looksLikeConsentWall(text, 14)).toBe(true); - }); - - it('flags short English consent dialogs', () => { - const text = 'Please accept all cookies to continue using this website.'; - expect(looksLikeConsentWall(text, 9)).toBe(true); - }); - - it('flags JavaScript-disabled walls', () => { - const text = 'JavaScript is disabled. Please enable JavaScript to continue.'; - expect(looksLikeConsentWall(text, 7)).toBe(true); - }); - - it('does NOT flag long articles even if they mention cookies', () => { - // Long-form article that happens to mention cookies in body. The - // heuristic only fires below the wordcount threshold (300) so a - // real article about cookies isn't misclassified. - const text = 'cookie consent ' + 'lorem '.repeat(400); - expect(looksLikeConsentWall(text, 800)).toBe(false); - }); - - it('does NOT flag short text without consent vocabulary', () => { - const text = 'A short blog post about hiking trails in the Black Forest.'; - expect(looksLikeConsentWall(text, 11)).toBe(false); - }); - - it('is case-insensitive', () => { - const text = 'COOKIES ZUSTIMMEN — KLICKE HIER'; - expect(looksLikeConsentWall(text, 4)).toBe(true); - }); - - it('returns false on empty content', () => { - expect(looksLikeConsentWall('', 0)).toBe(false); - }); - - it('returns false at exactly the wordcount threshold (boundary check)', () => { - const text = 'cookie consent ' + 'lorem '.repeat(300); - expect(looksLikeConsentWall(text, 300)).toBe(false); - }); -}); diff --git a/apps/api/src/modules/articles/consent-wall.ts b/apps/api/src/modules/articles/consent-wall.ts deleted file mode 100644 index b74cee52f..000000000 --- a/apps/api/src/modules/articles/consent-wall.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Consent-wall heuristic shared by every server-side article-extract - * path: - * - `/api/v1/articles/extract` and `/extract/html` (single-URL) - * - The bulk-import worker's `extractOneItem` (background) - * - * When the extracted text is suspiciously short AND contains GDPR / - * cookie-consent vocabulary, the server's anonymous fetch most likely - * hit a consent dialog instead of the article itself. The caller can - * use the flag to nudge the user toward the browser-HTML bookmarklet - * (which fetches with the user's existing session cookies) rather - * than silently persisting the GDPR overlay text as the article body. - */ - -const CONSENT_KEYWORDS = [ - 'cookies zustimmen', - 'cookie consent', - 'zustimmung', - 'accept all cookies', - 'consent to the use', - 'enable javascript', - 'javascript is disabled', - 'please enable', - 'privacy center', - 'datenschutz­einstellungen', - 'datenschutzeinstellungen', -]; - -/** Wordcount floor below which the heuristic is considered. Real - * articles are typically >300 words; consent dialogs are <50. */ -const CONSENT_WORDCOUNT_THRESHOLD = 300; - -export function looksLikeConsentWall(content: string, wordCount: number): boolean { - if (wordCount >= CONSENT_WORDCOUNT_THRESHOLD) return false; - const haystack = content.toLowerCase(); - return CONSENT_KEYWORDS.some((needle) => haystack.includes(needle)); -} diff --git a/apps/api/src/modules/articles/field-meta.test.ts b/apps/api/src/modules/articles/field-meta.test.ts deleted file mode 100644 index 52012b2af..000000000 --- a/apps/api/src/modules/articles/field-meta.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { fieldMetaTime } from './field-meta'; - -describe('fieldMetaTime — wire-shape adapter for sync_changes.field_meta', () => { - it('passes through legacy plain ISO strings unchanged', () => { - expect(fieldMetaTime('2026-04-28T21:14:30.000Z')).toBe('2026-04-28T21:14:30.000Z'); - }); - - it('extracts the `at` field from F3 object stamps', () => { - expect( - fieldMetaTime({ - at: '2026-04-28T21:14:30.000Z', - actor: { kind: 'system', principalId: 'system:foo', displayName: 'Foo' }, - origin: 'system', - }) - ).toBe('2026-04-28T21:14:30.000Z'); - }); - - it('returns "" for undefined / null (so callers can fall back)', () => { - expect(fieldMetaTime(undefined)).toBe(''); - expect(fieldMetaTime(null)).toBe(''); - }); - - it('returns "" for malformed objects without an at-string', () => { - expect(fieldMetaTime({})).toBe(''); - expect(fieldMetaTime({ at: 12345 })).toBe(''); - expect(fieldMetaTime({ at: null })).toBe(''); - }); - - it('returns "" for non-string non-object inputs', () => { - expect(fieldMetaTime(42)).toBe(''); - expect(fieldMetaTime(true)).toBe(''); - expect(fieldMetaTime([])).toBe(''); - }); - - // Regression: this is the bug that triggered the cross-service fix. - // Before fieldMetaTime, a string >= object compare evaluated to false - // stably and the older value won. Now both shapes fold to comparable - // ISO strings. - it('makes string-vs-object comparison work correctly across both shapes', () => { - const earlierLegacy = '2026-04-28T21:00:00.000Z'; - const laterF3 = { - at: '2026-04-28T22:00:00.000Z', - actor: { kind: 'user', principalId: 'u', displayName: 'Du' }, - origin: 'user', - }; - // The F3 stamp is later in time, so its normalised form must - // compare strictly greater than the legacy stamp. - expect(fieldMetaTime(laterF3) > fieldMetaTime(earlierLegacy)).toBe(true); - }); -}); diff --git a/apps/api/src/modules/articles/field-meta.ts b/apps/api/src/modules/articles/field-meta.ts deleted file mode 100644 index 8766cc9c5..000000000 --- a/apps/api/src/modules/articles/field-meta.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Wire-shape adapter for `sync_changes.field_meta`. - * - * Two shapes coexist on the wire today: - * - * - Legacy plaintext writes: { state: 'ISO-8601' } - * - Field-meta-overhaul (F3): { state: { at, actor, origin } } - * - * Any LWW projection that string-compares per-field timestamps MUST - * fold both into a comparable form, otherwise the moment one side is - * an F3 object the comparison becomes `'[object Object]' >= 'ISO…'` - * (false), the older value wins and the projection lies. - * - * Sister helper at `services/mana-ai/src/db/field-meta.ts` — same - * logic, deliberately duplicated. Both services treat sync_changes as - * a read-only event log; sharing infrastructure code across services - * (apps/api ↔ services/mana-ai) is out of scope. - */ - -/** Returns the ISO-string timestamp of a single `field_meta[k]` slot, - * regardless of whether the wire format is the legacy plain string - * or the F3 `{ at, actor, origin }` object. Returns the empty string - * when no usable value is present so the LWW comparison treats the - * field as never-stamped (callers fall back to row.created_at). */ -export function fieldMetaTime(meta: unknown): string { - if (typeof meta === 'string') return meta; - if (meta && typeof meta === 'object') { - const at = (meta as { at?: unknown }).at; - if (typeof at === 'string') return at; - } - return ''; -} diff --git a/apps/api/src/modules/articles/import-extractor.ts b/apps/api/src/modules/articles/import-extractor.ts deleted file mode 100644 index f047a11b9..000000000 --- a/apps/api/src/modules/articles/import-extractor.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Articles Bulk-Import — per-item extraction + write-back. - * - * For one `articleImportItems` row in state='pending': - * - * 1. Flip to state='extracting' (so other ticks / the UI see progress). - * 2. Run `extractFromUrl` against the URL. - * 3a. On success → write a `articleExtractPickup` row carrying the - * full ExtractedArticle payload + flip the item to 'extracted'. - * The client-side pickup-consumer picks it up, encrypts the - * article into the user's IndexedDB, and flips the item to 'saved' - * (or 'consent-wall' if the warning fired). - * 3b. On failure → bump `attempts`, flip back to 'pending' if - * attempts < 3, else flip to state='error' with the technical - * error message. - * - * Every state-change is one `sync_changes` row attributed to the - * `system:articles-import-worker` actor (built inline below — kept out - * of the shared-ai SystemSource union for now to keep the worker self- - * contained; can be hoisted later). Origin is `'system'` so the - * conflict-detection gate on the client doesn't surface these as - * user-visible toasts. - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -import { extractFromUrl } from '@mana/shared-rss'; -import { - makeFieldMeta, - makeSystemActor, - originFromActor, - SYSTEM_ARTICLES_IMPORT_WORKER, - type Actor, - type FieldOrigin, -} from '@mana/shared-ai'; -import { getSyncConnection } from '../../lib/sync-db'; -import { articlesImportExtractDuration, articlesImportItemsTotal } from '../../lib/metrics'; -import { looksLikeConsentWall } from './consent-wall'; -import type { ImportItemRow } from './import-projection'; - -const MAX_ATTEMPTS = 3; -const CLIENT_ID = 'articles-import-worker'; - -/** System-actor blob stamped on every worker write — sourced from the - * blessed SystemSource union in @mana/shared-ai so the actor.ts audit - * + Workbench filters know about it. */ -const WORKER_ACTOR: Actor = makeSystemActor(SYSTEM_ARTICLES_IMPORT_WORKER); -const WORKER_ORIGIN: FieldOrigin = originFromActor(WORKER_ACTOR); - -export interface ExtractStats { - itemId: string; - terminal: 'pending' | 'extracted' | 'error'; -} - -/** - * Run one extraction round-trip for a single item. Idempotent at the - * sync_changes level — if two ticks race the same item the field-LWW - * merge yields a single coherent state on the client. - */ -export async function extractOneItem(item: ImportItemRow): Promise { - if (item.state !== 'pending') { - return { - itemId: item.id, - terminal: item.state === 'error' ? 'error' : 'extracted', - }; - } - - // Step 1 — claim. Flip the item to 'extracting' before the slow - // fetch so concurrent ticks (and the UI) see we own it. - const nowClaim = new Date().toISOString(); - await writeItemUpdate(item.userId, item.id, { - state: 'extracting', - lastAttemptAt: nowClaim, - attempts: item.attempts + 1, - }); - - // Step 2 — fetch + parse. Hard-failure path returns null; we treat - // that as a single failed attempt and recycle. - const extractStart = Date.now(); - const extracted = await extractFromUrl(item.url); - articlesImportExtractDuration.observe((Date.now() - extractStart) / 1000); - const nowDone = new Date().toISOString(); - - if (!extracted) { - const nextAttempts = item.attempts + 1; - const nextState = nextAttempts >= MAX_ATTEMPTS ? 'error' : 'pending'; - await writeItemUpdate(item.userId, item.id, { - state: nextState, - error: nextState === 'error' ? 'Extraktion fehlgeschlagen nach mehreren Versuchen.' : null, - lastAttemptAt: nowDone, - }); - if (nextState === 'error') { - articlesImportItemsTotal.inc({ result: 'error' }); - } - return { itemId: item.id, terminal: nextState === 'error' ? 'error' : 'pending' }; - } - - // Step 3 — write the Pickup row (server payload for the client) and - // flip item state to 'extracted' so the consume-pickup path picks it - // up. Pickup row first so a client liveQuery seeing the 'extracted' - // state can immediately find the matching pickup payload. - const pickupId = `pickup-${item.id}`; - const wordCount = extracted.wordCount ?? 0; - const readingTimeMinutes = extracted.readingTimeMinutes ?? 0; - const warning = looksLikeConsentWall(extracted.content, wordCount) - ? 'probable_consent_wall' - : null; - - await writePickupInsert(item.userId, pickupId, { - itemId: item.id, - spaceId: item.spaceId ?? null, - payload: { - originalUrl: item.url, - title: extracted.title ?? '', - excerpt: extracted.excerpt ?? null, - content: extracted.content, - htmlContent: extracted.htmlContent ?? '', - author: extracted.byline ?? null, - siteName: extracted.siteName ?? null, - wordCount, - readingTimeMinutes, - ...(warning && { warning }), - }, - }); - - await writeItemUpdate(item.userId, item.id, { - state: 'extracted', - warning, - error: null, - lastAttemptAt: nowDone, - }); - - articlesImportItemsTotal.inc({ result: warning ? 'consent_wall' : 'extracted' }); - return { itemId: item.id, terminal: 'extracted' }; -} - -// ─── Sync-changes write helpers (worker-attributed) ────────── - -/** - * Worker-attributed update on an `articleImportItems` row. Exported so - * the worker tick can flip pending items to 'cancelled' when the parent - * job is cancelled, without going through the extraction pipeline. - */ -export async function writeItemUpdate( - userId: string, - itemId: string, - patch: Record -): Promise { - await insertSyncChange({ - userId, - recordId: itemId, - appId: 'articles', - tableName: 'articleImportItems', - op: 'update', - data: patch, - }); -} - -async function writePickupInsert( - userId: string, - pickupId: string, - data: Record -): Promise { - await insertSyncChange({ - userId, - recordId: pickupId, - appId: 'articles', - tableName: 'articleExtractPickup', - op: 'insert', - data, - }); -} - -/** - * Worker-attributed update on an `articleImportJobs` row. Counter-only - * for now (savedCount, errorCount, …) plus status flips like - * 'queued' → 'running' and 'running' → 'done'. - */ -export async function writeJobUpdate( - userId: string, - jobId: string, - patch: Record -): Promise { - await insertSyncChange({ - userId, - recordId: jobId, - appId: 'articles', - tableName: 'articleImportJobs', - op: 'update', - data: patch, - }); -} - -interface InsertParams { - userId: string; - recordId: string; - appId: string; - tableName: string; - op: 'insert' | 'update' | 'delete'; - data: Record; -} - -async function insertSyncChange(params: InsertParams): Promise { - const sql = getSyncConnection(); - const now = new Date().toISOString(); - const fieldMeta: Record = {}; - for (const key of Object.keys(params.data)) { - fieldMeta[key] = makeFieldMeta(now, WORKER_ACTOR, WORKER_ORIGIN); - } - const actorJson = WORKER_ACTOR as unknown; - const dataJson = params.data as unknown; - const fmJson = fieldMeta as unknown; - await sql.begin(async (tx) => { - await tx`SELECT set_config('app.current_user_id', ${params.userId}, true)`; - await tx` - INSERT INTO sync_changes - (app_id, table_name, record_id, user_id, op, data, field_meta, client_id, schema_version, actor, origin) - VALUES - (${params.appId}, ${params.tableName}, ${params.recordId}, ${params.userId}, ${params.op}, - ${tx.json(dataJson as never)}, ${tx.json(fmJson as never)}, - ${CLIENT_ID}, 1, ${tx.json(actorJson as never)}, ${WORKER_ORIGIN}) - `; - }); -} - -// looksLikeConsentWall lives in ./consent-wall.ts — shared with routes.ts. diff --git a/apps/api/src/modules/articles/import-projection.ts b/apps/api/src/modules/articles/import-projection.ts deleted file mode 100644 index 51a005afa..000000000 --- a/apps/api/src/modules/articles/import-projection.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Articles Bulk-Import — sync_changes → live record projection. - * - * Mirror of `services/mana-ai/src/db/missions-projection.ts` and - * `apps/api/src/lib/sync-db.ts:readLatestRecords()`, specialised for the - * two tables the import-worker tick reads each cycle: - * - * articleImportJobs — to find running jobs whose lease is free - * articleImportItems — to find pending items inside those jobs - * - * No materialized snapshots yet — this is the simple "replay every row - * for these tables" path. The total volume is small (a few hundred rows - * per active job, all import history per user) and the worker tick is - * the only consumer. If the table grows we can plug in the same - * `mission_snapshots` pattern mana-ai uses; the projection API stays - * the same. - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -import { getSyncConnection } from '../../lib/sync-db'; -import { fieldMetaTime } from './field-meta'; - -type Row = Record; -interface ChangeRow { - user_id: string; - record_id: string; - op: string; - data: Row | null; - /** See `./field-meta.ts` — wire shape is two-tone (legacy ISO string - * vs. F3 `{at, actor, origin}` object). */ - field_meta: Record | null; - created_at: Date; -} - -export interface ImportJobRow { - id: string; - userId: string; - spaceId: string | null; - totalUrls: number; - status: 'queued' | 'running' | 'paused' | 'done' | 'cancelled'; - startedAt: string | null; - finishedAt: string | null; - savedCount: number; - duplicateCount: number; - errorCount: number; - warningCount: number; -} - -export type ImportItemState = - | 'pending' - | 'extracting' - | 'extracted' - | 'saved' - | 'duplicate' - | 'consent-wall' - | 'error' - | 'cancelled'; - -export interface ImportItemRow { - id: string; - userId: string; - spaceId: string | null; - jobId: string; - idx: number; - url: string; - state: ImportItemState; - articleId: string | null; - warning: 'probable_consent_wall' | null; - error: string | null; - attempts: number; - lastAttemptAt: string | null; -} - -/** - * Cross-user scan: which jobs need attention this tick. RLS is - * intentionally NOT applied — the worker is a privileged consumer that - * needs to see all users' running jobs in one pass. Per-user RLS - * scoping is applied on the write-back path in import-extractor.ts. - */ -export async function listClaimableJobs(): Promise { - const sql = getSyncConnection(); - const rows = await sql` - SELECT user_id, record_id, op, data, field_meta, created_at - FROM sync_changes - WHERE app_id = 'articles' AND table_name = 'articleImportJobs' - ORDER BY user_id, record_id, created_at ASC - `; - const out: ImportJobRow[] = []; - for (const m of mergeByUserAndRecord(rows).values()) { - const job = projectJob(m.userId, m.recordId, m.merged); - if (!job) continue; - if (job.status !== 'running' && job.status !== 'queued') continue; - out.push(job); - } - return out; -} - -/** - * Per-job item scan. Returns ALL items so the worker can compute - * job-completion + counter deltas in one pass. - */ -export async function listItemsForJob(userId: string, jobId: string): Promise { - const sql = getSyncConnection(); - const rows = await sql` - SELECT user_id, record_id, op, data, field_meta, created_at - FROM sync_changes - WHERE app_id = 'articles' - AND table_name = 'articleImportItems' - AND user_id = ${userId} - ORDER BY record_id, created_at ASC - `; - const out: ImportItemRow[] = []; - for (const m of mergeByUserAndRecord(rows).values()) { - const item = projectItem(m.userId, m.recordId, m.merged); - if (!item || item.jobId !== jobId) continue; - out.push(item); - } - out.sort((a, b) => a.idx - b.idx); - return out; -} - -// ─── Internal: LWW merge per (userId, recordId) ────────────── - -interface MergedEntry { - userId: string; - recordId: string; - merged: Row | null; -} - -function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map { - const out = new Map(); - type Cur = { - key: string; - userId: string; - recordId: string; - record: Row | null; - /** Per-field LWW timestamps (normalised to ISO strings — see - * fieldMetaTime). Both wire shapes are folded down to plain - * strings here so the projection comparison stays trivial. */ - fm: Record; - }; - let current: Cur | null = null; - const flush = (c: Cur) => { - out.set(c.key, { userId: c.userId, recordId: c.recordId, merged: c.record }); - }; - for (const r of rows) { - const key = `${r.user_id}:${r.record_id}`; - if (!current || current.key !== key) { - if (current) flush(current); - current = { key, userId: r.user_id, recordId: r.record_id, record: null, fm: {} }; - } - if (r.op === 'delete') { - current.record = null; - continue; - } - if (!r.data) continue; - const rowCreatedAt = r.created_at.toISOString(); - if (!current.record) { - current.record = { id: r.record_id, ...r.data }; - const initFM = r.field_meta ?? {}; - current.fm = {}; - for (const k of Object.keys(initFM)) { - current.fm[k] = fieldMetaTime(initFM[k]) || rowCreatedAt; - } - continue; - } - const rowFM = r.field_meta ?? {}; - for (const [k, v] of Object.entries(r.data)) { - const serverTime = fieldMetaTime(rowFM[k]) || rowCreatedAt; - const localTime = current.fm[k] ?? ''; - if (serverTime >= localTime) { - current.record[k] = v; - current.fm[k] = serverTime; - } - } - } - if (current) flush(current); - return out; -} - -function projectJob(userId: string, recordId: string, merged: Row | null): ImportJobRow | null { - if (!merged || merged.deletedAt) return null; - const totalUrls = num(merged.totalUrls); - const status = str(merged.status); - if (totalUrls == null || !isJobStatus(status)) return null; - return { - id: recordId, - userId, - spaceId: optStr(merged.spaceId), - totalUrls, - status, - startedAt: optStr(merged.startedAt), - finishedAt: optStr(merged.finishedAt), - savedCount: num(merged.savedCount) ?? 0, - duplicateCount: num(merged.duplicateCount) ?? 0, - errorCount: num(merged.errorCount) ?? 0, - warningCount: num(merged.warningCount) ?? 0, - }; -} - -function projectItem(userId: string, recordId: string, merged: Row | null): ImportItemRow | null { - if (!merged || merged.deletedAt) return null; - const jobId = str(merged.jobId); - const url = str(merged.url); - const state = str(merged.state); - const idx = num(merged.idx); - if (!jobId || !url || !isItemState(state) || idx == null) return null; - return { - id: recordId, - userId, - spaceId: optStr(merged.spaceId), - jobId, - idx, - url, - state, - articleId: optStr(merged.articleId), - warning: merged.warning === 'probable_consent_wall' ? 'probable_consent_wall' : null, - error: optStr(merged.error), - attempts: num(merged.attempts) ?? 0, - lastAttemptAt: optStr(merged.lastAttemptAt), - }; -} - -function isJobStatus(s: string): s is ImportJobRow['status'] { - return s === 'queued' || s === 'running' || s === 'paused' || s === 'done' || s === 'cancelled'; -} - -function isItemState(s: string): s is ImportItemState { - return ( - s === 'pending' || - s === 'extracting' || - s === 'extracted' || - s === 'saved' || - s === 'duplicate' || - s === 'consent-wall' || - s === 'error' || - s === 'cancelled' - ); -} - -function num(v: unknown): number | null { - return typeof v === 'number' && Number.isFinite(v) ? v : null; -} -function str(v: unknown): string { - return typeof v === 'string' ? v : ''; -} -function optStr(v: unknown): string | null { - return typeof v === 'string' && v ? v : null; -} diff --git a/apps/api/src/modules/articles/import-worker.test.ts b/apps/api/src/modules/articles/import-worker.test.ts deleted file mode 100644 index d9f63fc19..000000000 --- a/apps/api/src/modules/articles/import-worker.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { countByState } from './import-worker'; -import type { ImportItemRow } from './import-projection'; - -function item(state: ImportItemRow['state'], idx = 0): ImportItemRow { - return { - id: `i-${idx}`, - userId: 'u-1', - spaceId: 'sp-1', - jobId: 'j-1', - idx, - url: `https://example.com/${idx}`, - state, - articleId: null, - warning: null, - error: null, - attempts: 0, - lastAttemptAt: null, - }; -} - -describe('countByState — worker job-counter rollup', () => { - it('returns zeros for empty input + allTerminal=false', () => { - const c = countByState([]); - expect(c).toEqual({ - saved: 0, - duplicate: 0, - error: 0, - consentWall: 0, - cancelled: 0, - allTerminal: false, - }); - }); - - it('counts each terminal state independently', () => { - const c = countByState([ - item('saved', 0), - item('saved', 1), - item('duplicate', 2), - item('error', 3), - item('cancelled', 4), - ]); - expect(c.saved).toBe(2); - expect(c.duplicate).toBe(1); - expect(c.error).toBe(1); - expect(c.cancelled).toBe(1); - expect(c.allTerminal).toBe(true); - }); - - it('treats consent-wall as semantically saved (so progress UI advances)', () => { - // One real-saved + two consent-wall = three "saved" from the - // user's perspective, but the warning counter tracks the wall hits. - const c = countByState([item('saved', 0), item('consent-wall', 1), item('consent-wall', 2)]); - expect(c.saved).toBe(3); - expect(c.consentWall).toBe(2); - expect(c.allTerminal).toBe(true); - }); - - it('does not flag allTerminal when any item is non-terminal', () => { - const states: ImportItemRow['state'][] = ['pending', 'extracting', 'extracted']; - for (const nonTerminal of states) { - const c = countByState([item('saved', 0), item(nonTerminal, 1)]); - expect(c.allTerminal).toBe(false); - } - }); - - it('preserves the saved + consent-wall sum when both are present', () => { - // Regression check: saved must include consent-wall items so the - // finished-counter UI doesn't off-by-one. - const c = countByState([ - item('saved', 0), - item('saved', 1), - item('consent-wall', 2), - item('error', 3), - ]); - expect(c.saved).toBe(3); // 2 saved + 1 consent-wall - expect(c.consentWall).toBe(1); - expect(c.error).toBe(1); - }); -}); diff --git a/apps/api/src/modules/articles/import-worker.ts b/apps/api/src/modules/articles/import-worker.ts deleted file mode 100644 index f05ee09d7..000000000 --- a/apps/api/src/modules/articles/import-worker.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * Articles Bulk-Import — background worker. - * - * Boots from `apps/api/src/index.ts`. On every tick: - * - * 1. Try `pg_try_advisory_xact_lock` on a fixed key. If another - * apps/api instance already holds it, skip this tick. The lock - * is per-transaction so we never need a heartbeat — a crashed - * worker's tx auto-aborts and the next tick claims it cleanly. - * 2. Project the live state of `articleImportJobs` and pick the - * ones still 'queued' or 'running'. - * 3. For each job: project items, take up to N pending items, - * extract concurrently. Each extraction writes a Pickup row + - * flips the item state via `import-extractor.ts`. - * 4. Fold terminal item states into job counters - * (savedCount / duplicateCount / errorCount / warningCount). - * When every item is terminal, flip the job to 'done'. - * - * No own state — every meaningful transition is a `sync_changes` row. - * The worker is therefore stateless across restarts. - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -// Operational logs (boot, tick errors, GC summary, stale-recovery -// sweep) go to console intentionally — same pattern as -// services/mana-ai/src/cron/tick.ts. Captured by the apps/api stdout -// aggregator; structured signal lives in Prometheus counters. -/* eslint-disable no-console */ - -import { getSyncConnection } from '../../lib/sync-db'; -import { - articlesImportJobsCompletedTotal, - articlesImportPickupGcRows, - articlesImportTicksTotal, -} from '../../lib/metrics'; -import { - listClaimableJobs, - listItemsForJob, - type ImportItemRow, - type ImportJobRow, -} from './import-projection'; -import { extractOneItem, writeItemUpdate, writeJobUpdate } from './import-extractor'; - -/** Counts ticks so the pickup-GC sweep can run every Nth one rather - * than on every 2-second cycle (the DELETE is cheap but not free). */ -let tickCount = 0; -/** Run pickup-GC every 30 ticks ≈ once per minute. */ -const PICKUP_GC_EVERY_N_TICKS = 30; - -const TICK_INTERVAL_MS = 2_000; -const PER_JOB_CONCURRENCY = 3; -/** - * If an item has been in `state='extracting'` longer than this without - * a follow-up state-write, it's orphaned (worker crashed mid-fetch, - * pod restart, OOM, …) and gets bounced back to `pending` so the next - * tick can re-claim it. - * - * Tuned so a slow but live extraction (15 s shared-rss fetch timeout + - * a few seconds of JSDOM parse on a 2 MB page) doesn't reset - * prematurely — 5 minutes is comfortable headroom. - */ -const STALE_EXTRACTING_MS = 5 * 60 * 1000; -/** TTL for `articleExtractPickup` rows. The pickup-consumer normally - * deletes them seconds after the worker writes them; anything older - * than this is garbage from a stuck consumer (all tabs closed, - * Web-Lock mismatch, …) and would otherwise accumulate without bound. */ -const PICKUP_TTL_MS = 24 * 60 * 60 * 1000; -/** Fixed int8 lock key — derived from the ASCII bytes of 'ARTI'. */ -const ADVISORY_LOCK_KEY = 0x4152_5449; - -let timer: ReturnType | null = null; -let running = false; - -/** - * Start the recurring tick. Idempotent — safe to call multiple times. - * Intended to be called once from `apps/api/src/index.ts` at boot. - * - * Disable via `ARTICLES_IMPORT_WORKER_DISABLED=true` (for tests, or - * when running multiple apps/api instances and you want to designate - * a different one as the worker). - */ -export function startArticleImportWorker(): void { - if (timer) return; - if (process.env.ARTICLES_IMPORT_WORKER_DISABLED === 'true') { - console.log('[articles-import] worker disabled via env'); - return; - } - console.log( - `[articles-import] worker starting — tick=${TICK_INTERVAL_MS}ms, concurrency=${PER_JOB_CONCURRENCY}` - ); - timer = setInterval(() => { - void runTickGuarded(); - }, TICK_INTERVAL_MS); -} - -export function stopArticleImportWorker(): void { - if (timer) { - clearInterval(timer); - timer = null; - } -} - -async function runTickGuarded(): Promise { - if (running) return; - running = true; - try { - const result = await runTickOnce(); - articlesImportTicksTotal.inc({ result: result.skipped ? 'skipped' : 'processed' }); - if (typeof result.pickupGcRows === 'number' && result.pickupGcRows > 0) { - articlesImportPickupGcRows.inc(result.pickupGcRows); - } - } catch (err) { - articlesImportTicksTotal.inc({ result: 'error' }); - console.error('[articles-import] tick error:', err); - } finally { - running = false; - } -} - -/** - * One tick body. Exported for tests + a potential - * `/internal/articles-import/tick`-style admin route. - */ -export async function runTickOnce(): Promise<{ - skipped: boolean; - jobsConsidered: number; - itemsProcessed: number; - pickupGcRows?: number; -}> { - if (!(await tryAcquireLock())) { - return { skipped: true, jobsConsidered: 0, itemsProcessed: 0 }; - } - tickCount++; - let pickupGcRows: number | undefined; - if (tickCount % PICKUP_GC_EVERY_N_TICKS === 0) { - pickupGcRows = await runPickupGc(); - } - const jobs = await listClaimableJobs(); - let itemsProcessed = 0; - for (const job of jobs) { - itemsProcessed += await processOneJob(job); - } - return { skipped: false, jobsConsidered: jobs.length, itemsProcessed, pickupGcRows }; -} - -/** - * Hard-delete pickup rows older than `PICKUP_TTL_MS`. The - * pickup-consumer on a healthy client removes each row seconds after - * the worker writes it; anything older is residue from a stuck - * consumer (all tabs closed, Web-Lock mismatch). Without this sweep - * the rows would accumulate without bound in sync_changes. - * - * Runs against `sync_changes` directly, not via a soft-delete on the - * row data — pickup rows are server-write inbox only, never editable - * by users; a hard DELETE keeps the table tight. - */ -async function runPickupGc(): Promise { - const sql = getSyncConnection(); - const cutoff = new Date(Date.now() - PICKUP_TTL_MS).toISOString(); - const rows = await sql<{ count: string }[]>` - WITH deleted AS ( - DELETE FROM sync_changes - WHERE app_id = 'articles' - AND table_name = 'articleExtractPickup' - AND created_at < ${cutoff} - RETURNING 1 - ) - SELECT count(*)::text AS count FROM deleted - `; - const n = parseInt(rows[0]?.count ?? '0', 10); - if (n > 0) console.log(`[articles-import] pickup-gc: removed ${n} rows older than 24h`); - return n; -} - -/** - * Brief advisory-lock probe via a single short transaction. Returns - * true if we won the probe — that's a soft signal for "you're the - * worker for this tick"; the lock releases as the probe tx commits. - * For multi-instance deploys this is a soft-only coordination — if - * two probes happen to interleave their work, the field-LWW merge on - * the client still produces a coherent state. - */ -async function tryAcquireLock(): Promise { - const sql = getSyncConnection(); - let acquired = false; - await sql.begin(async (tx) => { - const rows = await tx<{ acquired: boolean }[]>` - SELECT pg_try_advisory_xact_lock(${ADVISORY_LOCK_KEY}) AS acquired - `; - acquired = rows[0]?.acquired === true; - }); - return acquired; -} - -async function processOneJob(job: ImportJobRow): Promise { - const items = await listItemsForJob(job.userId, job.id); - - // Crash-recovery sweep — bounce items that have been 'extracting' - // for too long back to 'pending'. Without this, a worker that - // crashed (or got OOM'd, restarted mid-extract) leaves orphaned - // items in 'extracting' forever; the job never completes. Worker - // re-attribution happens via the next tick's claim path. - const now = Date.now(); - for (const it of items) { - if (it.state !== 'extracting') continue; - const since = it.lastAttemptAt ? Date.parse(it.lastAttemptAt) : 0; - if (!Number.isFinite(since)) continue; - if (now - since < STALE_EXTRACTING_MS) continue; - console.warn( - `[articles-import] resetting stale extracting item ${it.id} (job=${job.id}) — ${Math.round((now - since) / 1000)}s old` - ); - await writeItemUpdate(it.userId, it.id, { state: 'pending' }); - } - - // Flip 'queued' → 'running' so the UI shows progress. - if (job.status === 'queued') { - await writeJobUpdate(job.userId, job.id, { - status: 'running', - startedAt: new Date().toISOString(), - }); - } - - // Counter-derivation from current item states. - const counts = countByState(items); - const counterPatch: Record = {}; - let dirty = false; - if (counts.saved !== job.savedCount) { - counterPatch.savedCount = counts.saved; - dirty = true; - } - if (counts.duplicate !== job.duplicateCount) { - counterPatch.duplicateCount = counts.duplicate; - dirty = true; - } - if (counts.error !== job.errorCount) { - counterPatch.errorCount = counts.error; - dirty = true; - } - if (counts.consentWall !== job.warningCount) { - counterPatch.warningCount = counts.consentWall; - dirty = true; - } - if (counts.allTerminal && job.status !== 'done') { - counterPatch.status = 'done'; - counterPatch.finishedAt = new Date().toISOString(); - dirty = true; - articlesImportJobsCompletedTotal.inc({ result: 'done' }); - } - if (dirty) { - await writeJobUpdate(job.userId, job.id, counterPatch); - } - - if (counts.allTerminal) return 0; - - // Cancelled → flip every still-pending item to 'cancelled'. - if (job.status === 'cancelled') { - const pending = items.filter((i) => i.state === 'pending'); - for (const it of pending) { - await writeItemUpdate(it.userId, it.id, { state: 'cancelled' }); - } - return pending.length; - } - - // Paused → already-extracting items finish their roundtrip; nothing - // new gets claimed. - if (job.status === 'paused') return 0; - - // Running → claim up to PER_JOB_CONCURRENCY pending items in - // parallel. We deliberately don't try to rescue 'extracting' items: - // if a worker died mid-fetch they stay 'extracting' forever for - // now. Future polish: time-out 'extracting' rows older than ~5min - // and bounce them back to 'pending'. - const claimable = items.filter((i) => i.state === 'pending').slice(0, PER_JOB_CONCURRENCY); - if (claimable.length === 0) return 0; - - await Promise.allSettled(claimable.map((it) => extractOneItem(it))); - return claimable.length; -} - -export interface StateCounts { - saved: number; - duplicate: number; - error: number; - consentWall: number; - cancelled: number; - allTerminal: boolean; -} - -export function countByState(items: readonly ImportItemRow[]): StateCounts { - let saved = 0; - let duplicate = 0; - let error = 0; - let consentWall = 0; - let cancelled = 0; - let nonTerminal = 0; - for (const it of items) { - switch (it.state) { - case 'saved': - saved++; - break; - case 'duplicate': - duplicate++; - break; - case 'error': - error++; - break; - case 'consent-wall': - saved++; // consent-wall is "saved with warning" semantically - consentWall++; - break; - case 'cancelled': - cancelled++; - break; - default: - nonTerminal++; - } - } - return { - saved, - duplicate, - error, - consentWall, - cancelled, - allTerminal: items.length > 0 && nonTerminal === 0, - }; -} diff --git a/apps/api/src/modules/articles/routes.ts b/apps/api/src/modules/articles/routes.ts deleted file mode 100644 index 060d5e5fa..000000000 --- a/apps/api/src/modules/articles/routes.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Articles module — server-side URL extraction. - * - * Two endpoints, both thin wrappers around `@mana/shared-rss`: - * - * POST /extract ← server fetches the URL itself, then runs - * Readability on the HTML it got back. Works - * for simple sites but fails on anything behind - * a cookie-consent wall or a paywall — the - * server has no user session. - * POST /extract/html ← client already has the rendered HTML (from a - * browser bookmarklet running in the user's - * own tab with all their cookies applied). - * Server just runs Readability on that. This - * is how we bypass Golem / Spiegel / Zeit / - * Heise-style consent dialogs: use the user's - * already-consented session, not the server's - * anonymous fetch. - * - * Consent-wall heuristic: when /extract returns a suspiciously short - * payload that contains consent-dialog vocabulary we still hand the - * extracted text back but flag it with `warning: 'probable_consent_wall'` - * so the client can offer the bookmarklet-v2 path instead of pretending - * a 4-line "Cookies zustimmen" blob is the article. - */ - -import { Hono } from 'hono'; -import { extractFromUrl, extractFromHtml } from '@mana/shared-rss'; -import { looksLikeConsentWall } from './consent-wall'; - -const routes = new Hono(); - -function isValidHttpUrl(url: string): boolean { - try { - const u = new URL(url); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; - } -} - -// POST /extract — server fetches the URL + extracts. Legacy path. -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); - } - if (!isValidHttpUrl(url)) { - return c.json({ error: 'Invalid URL' }, 400); - } - - const extracted = await extractFromUrl(url); - if (!extracted) { - return c.json({ error: 'Extraction failed' }, 502); - } - - const warning = looksLikeConsentWall(extracted.content, extracted.wordCount) - ? 'probable_consent_wall' - : undefined; - - 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, - ...(warning && { warning }), - }); -}); - -// POST /extract/html — client supplies HTML (from the user's browser -// tab, where cookies + JS rendering already happened). We only run -// Readability on it. Cap payload to 10 MiB so a pathological site -// can't exhaust server memory via the bookmarklet — typical rendered -// article HTML is 200-800 KB. -const MAX_HTML_BYTES = 10 * 1024 * 1024; - -routes.post('/extract/html', async (c) => { - const body = await c.req - .json<{ url?: string; html?: string }>() - .catch(() => ({}) as { url?: string; html?: string }); - const url = body.url; - const html = body.html; - if (!url || typeof url !== 'string') { - return c.json({ error: 'URL is required' }, 400); - } - if (!html || typeof html !== 'string') { - return c.json({ error: 'HTML is required' }, 400); - } - if (!isValidHttpUrl(url)) { - return c.json({ error: 'Invalid URL' }, 400); - } - if (html.length > MAX_HTML_BYTES) { - return c.json({ error: 'HTML payload too large' }, 413); - } - - const extracted = await extractFromHtml(html, url); - if (!extracted) { - return c.json({ error: 'Extraction failed' }, 502); - } - - // The consent-wall heuristic still applies here — a rare case is - // that the user bookmarklet-fires BEFORE the consent dialog is - // dismissed. Flag it so the client doesn't silently persist garbage. - const warning = looksLikeConsentWall(extracted.content, extracted.wordCount) - ? 'probable_consent_wall' - : undefined; - - 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, - ...(warning && { warning }), - }); -}); - -export { routes as articlesRoutes }; diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index fdd47a845..f86d025b2 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -729,42 +729,8 @@ registerApp({ }, }); -registerApp({ - id: 'articles', - name: 'Artikel', - color: '#F97316', - icon: BookOpen, - views: { - // ArticlesTabShell enthält intern alle drei Tabs (Übersicht / - // Leseliste / Highlights). Im Workbench-Karten-Kontext lassen - // sich die Tabs ohne Page-Navigation wechseln. In den direkten - // SvelteKit-Routen (/articles, /articles/list, /articles/highlights) - // wird dieselbe Shell mit passendem initialTab gemountet. - list: { load: () => import('$lib/modules/articles/ArticlesTabShell.svelte') }, - detail: { load: () => import('$lib/modules/articles/views/DetailView.svelte') }, - }, - contextMenuActions: [ - { - id: 'new-article', - label: 'URL speichern', - icon: Plus, - action: () => - window.dispatchEvent( - new CustomEvent('mana:quick-action', { detail: { app: 'articles', action: 'new' } }) - ), - }, - ], - collection: 'articles', - paramKey: 'articleId', - // dragType: 'article' absichtlich weggelassen — der DragType-Union in - // @mana/shared-ui/dnd kennt noch keinen 'article'-Slot. Wenn später - // Drag-to-calendar / Drag-to-todo gebraucht wird, erweitern wir den - // Union dort und hängen es hier ein. - getDisplayData: (item) => ({ - title: (item.title as string) || 'Artikel', - subtitle: (item.siteName as string) || undefined, - }), -}); +// Articles-Modul: dekommissioniert 2026-05-19, lebt als pageta standalone +// auf pageta.mana.how / pageta.com (Code/pageta). registerApp({ id: 'research-lab', diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts index 148814124..0dbae6986 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts +++ b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts @@ -25,7 +25,6 @@ import PresiDecksWidget from './widgets/PresiDecksWidget.svelte'; import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte'; import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte'; import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte'; -import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte'; import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte'; import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte'; import BroadcastsWidget from '$lib/modules/broadcasts/widgets/BroadcastsWidget.svelte'; @@ -51,7 +50,6 @@ export const widgetComponents: Record = { 'day-timeline': DayTimelineWidget, 'activity-feed': ActivityFeedWidget, period: PeriodWidget, - 'articles-unread': ArticlesUnreadWidget, 'body-stats': BodyStatsWidget, 'invoices-open': InvoicesOpenWidget, broadcasts: BroadcastsWidget, diff --git a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts index 92847c37b..fd629c6e2 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts @@ -21,10 +21,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [ 'activities', // TODO: audit 'albumItems', // TODO: audit 'albums', // TODO: audit - 'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags. - 'articleImportJobs', // Bulk-import job header (counters, status, lease metadata). Pure operational state, no user-typed content. See docs/plans/articles-bulk-import.md. - 'articleImportItems', // One row per URL in a bulk job. URL is plaintext by necessity — server-worker reads it without master-key access (same rationale as articles.originalUrl). - 'articleExtractPickup', // Short-lived server-write inbox; the client picks up the extracted payload, encrypts it into the articles table, deletes the row. Plaintext by necessity (server has no master key); empty in steady state. 'automations', // TODO: audit 'boardViews', // TODO: audit 'budgets', // TODO: audit diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index ec7a04b5b..2e1577d33 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -87,7 +87,6 @@ import type { LocalBroadcastTemplate, LocalBroadcastSettings, } from '../../modules/broadcasts/types'; -import type { LocalArticle, LocalHighlight } from '../../modules/articles/types'; import type { LocalMeImage } from '../../modules/profile/types'; import type { LocalDraft, @@ -682,20 +681,6 @@ export const ENCRYPTION_REGISTRY: Record = { // and are themselves encrypted. Offsets + color + articleId are // structural — the reader needs them for range scans and rendering. // - // articleTags is intentionally NOT registered — pure FK junction - // (articleId, tagId), zero user-typed content. Tag names live in - // globalTags, which has its own encryption policy. Lives on the - // plaintext-allowlist alongside noteTags / eventTags / placeTags. - articles: entry([ - 'title', - 'excerpt', - 'content', - 'htmlContent', - 'author', - 'userNote', - ]), - articleHighlights: entry(['text', 'note', 'contextBefore', 'contextAfter']), - // ─── Library ───────────────────────────────────────────── // Reading / watching log with a kind discriminator (book / movie / // series / comic) in one table. User-typed text (title, original diff --git a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts index dc763f0f3..e38a6c090 100644 --- a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts +++ b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts @@ -23,7 +23,6 @@ import { cleanupTombstones } from './quota'; import { pruneActivityLog } from './activity'; import { SYNC_TELEMETRY_EVENT, type SyncTelemetryDetail } from './sync-telemetry'; import { installConflictListener } from './conflict-store.svelte'; -import { startArticlePickupConsumer } from '$lib/modules/articles/consume-pickup'; /** How often to run the tombstone cleanup. 24h is a comfortable cadence * given that the cutoff is 30 days — runs roughly once per app session. */ @@ -107,12 +106,6 @@ export function installDataLayerListeners(): () => void { // coalescing, auto-dismiss, and the restore-write path. const disposeConflict = installConflictListener(); - // ─── Articles bulk-import: pickup consumer ───────────────── - // Drains `articleExtractPickup` rows the server-worker drops for - // successful URL extractions. Web-Lock-coordinated for multi-tab - // safety. See docs/plans/articles-bulk-import.md. - const disposeArticlePickup = startArticlePickupConsumer(); - // ─── Periodic cleanup loop ───────────────────────────────── // Runs once on boot, then daily. Two independent jobs share the // schedule so we never have a third interval competing for the same @@ -158,6 +151,5 @@ export function installDataLayerListeners(): () => void { window.removeEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry); window.clearInterval(cleanupTimer); disposeConflict(); - disposeArticlePickup(); }; } diff --git a/apps/mana/apps/web/src/lib/data/module-registry.test.ts b/apps/mana/apps/web/src/lib/data/module-registry.test.ts index 23ba4920e..3497fe9bb 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.test.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.test.ts @@ -254,14 +254,6 @@ describe('module-registry — snapshot', () => { quiz: ['quizzes', 'quizQuestions', 'quizAttempts'], profile: ['userContext', 'meImages'], library: ['libraryEntries'], - articles: [ - 'articles', - 'articleHighlights', - 'articleTags', - 'articleImportJobs', - 'articleImportItems', - 'articleExtractPickup', - ], invoices: ['invoices', 'invoiceClients', 'invoiceSettings'], broadcasts: ['broadcastCampaigns', 'broadcastTemplates', 'broadcastSettings'], wetter: ['wetterLocations', 'wetterSettings'], @@ -303,10 +295,6 @@ describe('module-registry — snapshot', () => { playgroundMessages: 'messages', quizQuestions: 'questions', quizAttempts: 'attempts', - articleHighlights: 'highlights', - articleImportJobs: 'importJobs', - articleImportItems: 'importItems', - articleExtractPickup: 'extractPickup', wetterLocations: 'locations', wetterSettings: 'settings', }); diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 232e9d8fb..fda410bf8 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -87,7 +87,6 @@ import { moodModuleConfig } from '$lib/modules/mood/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/broadcasts/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; @@ -138,7 +137,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ quizModuleConfig, profileModuleConfig, libraryModuleConfig, - articlesModuleConfig, invoicesModuleConfig, broadcastModuleConfig, wetterModuleConfig, diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 166d9dad6..8e3272345 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -26,7 +26,6 @@ import { lastsTools } from '$lib/modules/lasts/tools'; import { guidesTools } from '$lib/modules/guides/tools'; import { inventoryTools } from '$lib/modules/inventory/tools'; import { newsResearchTools } from '$lib/modules/news-research/tools'; -import { articlesTools } from '$lib/modules/articles/tools'; import { recipesTools } from '$lib/modules/recipes/tools'; import { questionsTools } from '$lib/modules/questions/tools'; import { meditateTools } from '$lib/modules/meditate/tools'; @@ -71,7 +70,6 @@ export function initTools(): void { registerTools(guidesTools); registerTools(inventoryTools); registerTools(newsResearchTools); - registerTools(articlesTools); registerTools(recipesTools); registerTools(questionsTools); registerTools(meditateTools); diff --git a/apps/mana/apps/web/src/lib/i18n/locales/articles/de.json b/apps/mana/apps/web/src/lib/i18n/locales/articles/de.json deleted file mode 100644 index 75c50cd14..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/articles/de.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "detail_view": { - "page_title_html": "{title} — Mana", - "untitled_fallback": "Artikel", - "loading": "Lädt…", - "not_found": "Artikel nicht gefunden.", - "back_to_list": "Zurück zur Liste", - "meta_word_count": "{n} Wörter", - "meta_reading_minutes": "{n} min", - "tag_add_label": "Tag", - "tag_placeholder": "Tag suchen oder erstellen…", - "toolbar_aria": "Lese-Werkzeuge", - "back_aria": "Zurück zur Liste", - "back_tip": "Zurück zur Leseliste", - "font_smaller_aria": "Schrift kleiner", - "font_smaller_tip": "Schrift kleiner", - "font_larger_aria": "Schrift größer", - "font_larger_tip": "Schrift größer", - "font_serif_tip": "Serif-Schrift", - "font_serif_label": "Serif", - "font_sans_tip": "Sans-Serif-Schrift", - "font_sans_label": "Sans", - "theme_light_aria": "Heller Modus", - "theme_light_tip": "Heller Modus", - "theme_sepia_aria": "Sepia-Modus", - "theme_sepia_tip": "Sepia-Modus", - "theme_dark_aria": "Dunkler Modus", - "theme_dark_tip": "Dunkler Modus", - "mark_unread_label": "Als ungelesen markieren", - "mark_read_label": "Als gelesen markieren", - "fav_remove": "Favorit entfernen", - "fav_mark": "Als Favorit markieren", - "archive_label": "Artikel archivieren", - "open_original": "Original-Seite öffnen", - "delete_label": "Artikel löschen", - "confirm_delete": "Artikel wirklich löschen?" - }, - "import": { - "bulk_link": "Mehrere URLs auf einmal? → Bulk-Import", - "form_title": "Mehrere Artikel importieren", - "form_subtitle": "Eine URL pro Zeile (oder durch Leerzeichen / Komma getrennt). Mana extrahiert sie nacheinander im Hintergrund.", - "form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…", - "count_valid": "{n} gültig", - "count_overlimit_suffix": " / max {max}", - "count_dup": "{n} doppelt (übersprungen)", - "count_invalid": "{n} ungültig", - "invalid_details_summary": "Ungültige Zeilen anzeigen ({n})", - "error_no_urls": "Mindestens eine gültige URL einfügen.", - "error_overlimit": "Zu viele URLs ({n}). Maximal {max} pro Job — splitte den Import.", - "error_failed": "Job konnte nicht erstellt werden.", - "submit_label": "{n} URLs importieren", - "submit_busy": "Erstelle Job…", - "hint": "Im Hintergrund — du kannst den Tab schließen und später zurückkommen. Bei 50 URLs dauert es grob 5–10 Minuten. Den Fortschritt siehst du auf der Detailseite.", - "jobs_heading": "Bisherige Imports", - "filter_all": "Alle ({n})", - "filter_active": "Aktiv ({n})", - "filter_done": "Fertig ({n})", - "filter_errors": "Mit Fehlern ({n})", - "empty_filter": "Keine Jobs in dieser Ansicht.", - "status_queued": "Wartet", - "status_running": "Läuft", - "status_paused": "Pausiert", - "status_done": "Fertig", - "status_cancelled": "Abgebrochen", - "jobs_meta_errors": "{n} Fehler", - "jobs_meta_dups": "{n} Duplikate", - "jobs_meta_warnings": "{n} Warnungen", - "detail_title": "Import-Job", - "detail_not_found": "Job nicht gefunden.", - "detail_progress_aria": "Fortschritt", - "detail_counter_total": "{done} / {total} verarbeitet", - "detail_counter_saved": "{n} gespeichert", - "detail_counter_dups": "{n} Duplikate", - "detail_counter_warns": "{n} mit Cookie-Wand", - "detail_counter_errors": "{n} Fehler", - "action_pause": "Pause", - "action_resume": "Fortsetzen", - "action_cancel": "Abbrechen", - "action_retry": "Fehler wiederholen", - "action_delete": "Löschen", - "confirm_cancel": "Job wirklich abbrechen? Bisherige Artikel bleiben gespeichert.", - "confirm_delete": "Job-Historie löschen? Artikel bleiben.", - "consent_hint_strong": "Cookie-Wand erkannt", - "consent_hint_body": "{n, plural, one {# Artikel hat} other {# Artikel haben}} nur den Cookie-Zustimmungs-Dialog gespeichert (der Server sieht keine Cookies). Lösung:", - "consent_hint_link": "Browser-HTML-Bookmarklet", - "consent_hint_after_link": "aus dem Tab in dem du dem Cookie zugestimmt hast benutzen — überschreibt den Teaser durch den echten Artikel.", - "item_pending": "Wartet", - "item_extracting": "Extrahiert…", - "item_extracted": "Server fertig", - "item_saved": "✓ Gespeichert", - "item_duplicate": "· Duplikat", - "item_consent_wall": "⚠ Cookie-Wand", - "item_error": "✗ Fehler", - "item_cancelled": "Abgebrochen", - "item_action_view_teaser": "Teaser ansehen", - "item_action_rescue": "Erneut speichern", - "item_action_rescue_tip": "Mit Bookmarklet erneut speichern — überschreibt den Teaser durch den echten Artikel", - "item_action_open": "Öffnen" - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/articles/en.json b/apps/mana/apps/web/src/lib/i18n/locales/articles/en.json deleted file mode 100644 index 8d1efa16c..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/articles/en.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "detail_view": { - "page_title_html": "{title} — Mana", - "untitled_fallback": "Article", - "loading": "Loading…", - "not_found": "Article not found.", - "back_to_list": "Back to list", - "meta_word_count": "{n} words", - "meta_reading_minutes": "{n} min", - "tag_add_label": "Tag", - "tag_placeholder": "Search or create tag…", - "toolbar_aria": "Reading tools", - "back_aria": "Back to list", - "back_tip": "Back to reading list", - "font_smaller_aria": "Smaller font", - "font_smaller_tip": "Smaller font", - "font_larger_aria": "Larger font", - "font_larger_tip": "Larger font", - "font_serif_tip": "Serif font", - "font_serif_label": "Serif", - "font_sans_tip": "Sans-serif font", - "font_sans_label": "Sans", - "theme_light_aria": "Light mode", - "theme_light_tip": "Light mode", - "theme_sepia_aria": "Sepia mode", - "theme_sepia_tip": "Sepia mode", - "theme_dark_aria": "Dark mode", - "theme_dark_tip": "Dark mode", - "mark_unread_label": "Mark as unread", - "mark_read_label": "Mark as read", - "fav_remove": "Remove favorite", - "fav_mark": "Mark as favorite", - "archive_label": "Archive article", - "open_original": "Open original page", - "delete_label": "Delete article", - "confirm_delete": "Really delete article?" - }, - "import": { - "bulk_link": "Multiple URLs at once? → Bulk import", - "form_title": "Import multiple articles", - "form_subtitle": "One URL per line (or separated by spaces / commas). Mana extracts them one after the other in the background.", - "form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…", - "count_valid": "{n} valid", - "count_overlimit_suffix": " / max {max}", - "count_dup": "{n} duplicate (skipped)", - "count_invalid": "{n} invalid", - "invalid_details_summary": "Show invalid lines ({n})", - "error_no_urls": "Add at least one valid URL.", - "error_overlimit": "Too many URLs ({n}). Maximum {max} per job — split the import.", - "error_failed": "Could not create job.", - "submit_label": "Import {n} URLs", - "submit_busy": "Creating job…", - "hint": "Runs in the background — you can close the tab and come back later. 50 URLs take roughly 5–10 minutes. Progress is on the detail page.", - "jobs_heading": "Past imports", - "filter_all": "All ({n})", - "filter_active": "Active ({n})", - "filter_done": "Done ({n})", - "filter_errors": "With errors ({n})", - "empty_filter": "No jobs in this view.", - "status_queued": "Queued", - "status_running": "Running", - "status_paused": "Paused", - "status_done": "Done", - "status_cancelled": "Cancelled", - "jobs_meta_errors": "{n} errors", - "jobs_meta_dups": "{n} duplicates", - "jobs_meta_warnings": "{n} warnings", - "detail_title": "Import job", - "detail_not_found": "Job not found.", - "detail_progress_aria": "Progress", - "detail_counter_total": "{done} / {total} processed", - "detail_counter_saved": "{n} saved", - "detail_counter_dups": "{n} duplicates", - "detail_counter_warns": "{n} with cookie wall", - "detail_counter_errors": "{n} errors", - "action_pause": "Pause", - "action_resume": "Resume", - "action_cancel": "Cancel", - "action_retry": "Retry errors", - "action_delete": "Delete", - "confirm_cancel": "Really cancel job? Already-saved articles stay.", - "confirm_delete": "Delete job history? Articles stay.", - "consent_hint_strong": "Cookie wall detected", - "consent_hint_body": "{n, plural, one {# article has} other {# articles have}} only saved the cookie consent dialog (the server sees no cookies). Fix:", - "consent_hint_link": "Browser HTML bookmarklet", - "consent_hint_after_link": "from the tab where you already accepted — overwrites the teaser with the real article.", - "item_pending": "Waiting", - "item_extracting": "Extracting…", - "item_extracted": "Server done", - "item_saved": "✓ Saved", - "item_duplicate": "· Duplicate", - "item_consent_wall": "⚠ Cookie wall", - "item_error": "✗ Error", - "item_cancelled": "Cancelled", - "item_action_view_teaser": "View teaser", - "item_action_rescue": "Save again", - "item_action_rescue_tip": "Save again with the bookmarklet — overwrites the teaser with the real article", - "item_action_open": "Open" - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/articles/es.json b/apps/mana/apps/web/src/lib/i18n/locales/articles/es.json deleted file mode 100644 index 7f935286b..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/articles/es.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "detail_view": { - "page_title_html": "{title} — Mana", - "untitled_fallback": "Artículo", - "loading": "Cargando…", - "not_found": "Artículo no encontrado.", - "back_to_list": "Volver a la lista", - "meta_word_count": "{n} palabras", - "meta_reading_minutes": "{n} min", - "tag_add_label": "Tag", - "tag_placeholder": "Buscar o crear etiqueta…", - "toolbar_aria": "Herramientas de lectura", - "back_aria": "Volver a la lista", - "back_tip": "Volver a la lista de lectura", - "font_smaller_aria": "Fuente más pequeña", - "font_smaller_tip": "Fuente más pequeña", - "font_larger_aria": "Fuente más grande", - "font_larger_tip": "Fuente más grande", - "font_serif_tip": "Fuente serif", - "font_serif_label": "Serif", - "font_sans_tip": "Fuente sans-serif", - "font_sans_label": "Sans", - "theme_light_aria": "Modo claro", - "theme_light_tip": "Modo claro", - "theme_sepia_aria": "Modo sepia", - "theme_sepia_tip": "Modo sepia", - "theme_dark_aria": "Modo oscuro", - "theme_dark_tip": "Modo oscuro", - "mark_unread_label": "Marcar como no leído", - "mark_read_label": "Marcar como leído", - "fav_remove": "Quitar de favoritos", - "fav_mark": "Marcar como favorito", - "archive_label": "Archivar artículo", - "open_original": "Abrir página original", - "delete_label": "Eliminar artículo", - "confirm_delete": "¿Eliminar realmente el artículo?" - }, - "import": { - "bulk_link": "¿Varias URLs a la vez? → Importación masiva", - "form_title": "Importar varios artículos", - "form_subtitle": "Una URL por línea (o separadas por espacios / comas). Mana las extrae una tras otra en segundo plano.", - "form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…", - "count_valid": "{n} válidas", - "count_overlimit_suffix": " / máx {max}", - "count_dup": "{n} duplicadas (omitidas)", - "count_invalid": "{n} inválidas", - "invalid_details_summary": "Mostrar líneas inválidas ({n})", - "error_no_urls": "Añade al menos una URL válida.", - "error_overlimit": "Demasiadas URLs ({n}). Máximo {max} por job — divide la importación.", - "error_failed": "No se pudo crear el job.", - "submit_label": "Importar {n} URLs", - "submit_busy": "Creando job…", - "hint": "Se ejecuta en segundo plano — puedes cerrar la pestaña y volver más tarde. 50 URLs tardan unos 5–10 minutos. El progreso está en la página de detalle.", - "jobs_heading": "Importaciones anteriores", - "filter_all": "Todos ({n})", - "filter_active": "Activos ({n})", - "filter_done": "Completados ({n})", - "filter_errors": "Con errores ({n})", - "empty_filter": "Ningún job en esta vista.", - "status_queued": "En cola", - "status_running": "En ejecución", - "status_paused": "Pausado", - "status_done": "Completado", - "status_cancelled": "Cancelado", - "jobs_meta_errors": "{n} errores", - "jobs_meta_dups": "{n} duplicados", - "jobs_meta_warnings": "{n} advertencias", - "detail_title": "Job de importación", - "detail_not_found": "Job no encontrado.", - "detail_progress_aria": "Progreso", - "detail_counter_total": "{done} / {total} procesados", - "detail_counter_saved": "{n} guardados", - "detail_counter_dups": "{n} duplicados", - "detail_counter_warns": "{n} con muro de cookies", - "detail_counter_errors": "{n} errores", - "action_pause": "Pausar", - "action_resume": "Reanudar", - "action_cancel": "Cancelar", - "action_retry": "Reintentar errores", - "action_delete": "Eliminar", - "confirm_cancel": "¿Cancelar realmente el job? Los artículos ya guardados permanecen.", - "confirm_delete": "¿Eliminar el historial del job? Los artículos permanecen.", - "consent_hint_strong": "Muro de cookies detectado", - "consent_hint_body": "{n, plural, one {# artículo solo ha} other {# artículos solo han}} guardado el diálogo de consentimiento de cookies (el servidor no ve cookies). Solución:", - "consent_hint_link": "bookmarklet HTML del navegador", - "consent_hint_after_link": "desde la pestaña donde ya aceptaste — sobrescribe el teaser con el artículo real.", - "item_pending": "En espera", - "item_extracting": "Extrayendo…", - "item_extracted": "Servidor listo", - "item_saved": "✓ Guardado", - "item_duplicate": "· Duplicado", - "item_consent_wall": "⚠ Muro de cookies", - "item_error": "✗ Error", - "item_cancelled": "Cancelado", - "item_action_view_teaser": "Ver teaser", - "item_action_rescue": "Guardar de nuevo", - "item_action_rescue_tip": "Guardar de nuevo con el bookmarklet — sobrescribe el teaser con el artículo real", - "item_action_open": "Abrir" - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/articles/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/articles/fr.json deleted file mode 100644 index f881222cb..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/articles/fr.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "detail_view": { - "page_title_html": "{title} — Mana", - "untitled_fallback": "Article", - "loading": "Chargement…", - "not_found": "Article introuvable.", - "back_to_list": "Retour à la liste", - "meta_word_count": "{n} mots", - "meta_reading_minutes": "{n} min", - "tag_add_label": "Tag", - "tag_placeholder": "Rechercher ou créer un tag…", - "toolbar_aria": "Outils de lecture", - "back_aria": "Retour à la liste", - "back_tip": "Retour à la liste de lecture", - "font_smaller_aria": "Police plus petite", - "font_smaller_tip": "Police plus petite", - "font_larger_aria": "Police plus grande", - "font_larger_tip": "Police plus grande", - "font_serif_tip": "Police serif", - "font_serif_label": "Serif", - "font_sans_tip": "Police sans-serif", - "font_sans_label": "Sans", - "theme_light_aria": "Mode clair", - "theme_light_tip": "Mode clair", - "theme_sepia_aria": "Mode sépia", - "theme_sepia_tip": "Mode sépia", - "theme_dark_aria": "Mode sombre", - "theme_dark_tip": "Mode sombre", - "mark_unread_label": "Marquer comme non lu", - "mark_read_label": "Marquer comme lu", - "fav_remove": "Retirer des favoris", - "fav_mark": "Marquer comme favori", - "archive_label": "Archiver l'article", - "open_original": "Ouvrir la page d'origine", - "delete_label": "Supprimer l'article", - "confirm_delete": "Vraiment supprimer l'article ?" - }, - "import": { - "bulk_link": "Plusieurs URLs à la fois ? → Import groupé", - "form_title": "Importer plusieurs articles", - "form_subtitle": "Une URL par ligne (ou séparées par des espaces / virgules). Mana les extrait l'une après l'autre en arrière-plan.", - "form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…", - "count_valid": "{n} valides", - "count_overlimit_suffix": " / max {max}", - "count_dup": "{n} doublons (ignorés)", - "count_invalid": "{n} invalides", - "invalid_details_summary": "Afficher les lignes invalides ({n})", - "error_no_urls": "Ajoute au moins une URL valide.", - "error_overlimit": "Trop d'URLs ({n}). Maximum {max} par job — divise l'import.", - "error_failed": "Impossible de créer le job.", - "submit_label": "Importer {n} URLs", - "submit_busy": "Création du job…", - "hint": "S'exécute en arrière-plan — tu peux fermer l'onglet et revenir plus tard. 50 URLs prennent environ 5–10 minutes. La progression est visible sur la page de détail.", - "jobs_heading": "Imports passés", - "filter_all": "Tous ({n})", - "filter_active": "Actifs ({n})", - "filter_done": "Terminés ({n})", - "filter_errors": "Avec erreurs ({n})", - "empty_filter": "Aucun job dans cette vue.", - "status_queued": "En attente", - "status_running": "En cours", - "status_paused": "En pause", - "status_done": "Terminé", - "status_cancelled": "Annulé", - "jobs_meta_errors": "{n} erreurs", - "jobs_meta_dups": "{n} doublons", - "jobs_meta_warnings": "{n} avertissements", - "detail_title": "Job d'import", - "detail_not_found": "Job introuvable.", - "detail_progress_aria": "Progression", - "detail_counter_total": "{done} / {total} traités", - "detail_counter_saved": "{n} enregistrés", - "detail_counter_dups": "{n} doublons", - "detail_counter_warns": "{n} avec mur de cookies", - "detail_counter_errors": "{n} erreurs", - "action_pause": "Pause", - "action_resume": "Reprendre", - "action_cancel": "Annuler", - "action_retry": "Réessayer les erreurs", - "action_delete": "Supprimer", - "confirm_cancel": "Vraiment annuler le job ? Les articles déjà enregistrés restent.", - "confirm_delete": "Supprimer l'historique du job ? Les articles restent.", - "consent_hint_strong": "Mur de cookies détecté", - "consent_hint_body": "{n, plural, one {# article n'a enregistré} other {# articles n'ont enregistré}} que la boîte de dialogue de consentement (le serveur ne voit aucun cookie). Solution :", - "consent_hint_link": "bookmarklet HTML du navigateur", - "consent_hint_after_link": "depuis l'onglet où tu as déjà accepté — remplace le teaser par l'article réel.", - "item_pending": "En attente", - "item_extracting": "Extraction…", - "item_extracted": "Serveur terminé", - "item_saved": "✓ Enregistré", - "item_duplicate": "· Doublon", - "item_consent_wall": "⚠ Mur de cookies", - "item_error": "✗ Erreur", - "item_cancelled": "Annulé", - "item_action_view_teaser": "Voir le teaser", - "item_action_rescue": "Réenregistrer", - "item_action_rescue_tip": "Réenregistrer avec le bookmarklet — remplace le teaser par l'article réel", - "item_action_open": "Ouvrir" - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/articles/it.json b/apps/mana/apps/web/src/lib/i18n/locales/articles/it.json deleted file mode 100644 index 4fb13c839..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/articles/it.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "detail_view": { - "page_title_html": "{title} — Mana", - "untitled_fallback": "Articolo", - "loading": "Caricamento…", - "not_found": "Articolo non trovato.", - "back_to_list": "Torna alla lista", - "meta_word_count": "{n} parole", - "meta_reading_minutes": "{n} min", - "tag_add_label": "Tag", - "tag_placeholder": "Cerca o crea un tag…", - "toolbar_aria": "Strumenti di lettura", - "back_aria": "Torna alla lista", - "back_tip": "Torna alla lista di lettura", - "font_smaller_aria": "Font più piccolo", - "font_smaller_tip": "Font più piccolo", - "font_larger_aria": "Font più grande", - "font_larger_tip": "Font più grande", - "font_serif_tip": "Font serif", - "font_serif_label": "Serif", - "font_sans_tip": "Font sans-serif", - "font_sans_label": "Sans", - "theme_light_aria": "Modalità chiara", - "theme_light_tip": "Modalità chiara", - "theme_sepia_aria": "Modalità seppia", - "theme_sepia_tip": "Modalità seppia", - "theme_dark_aria": "Modalità scura", - "theme_dark_tip": "Modalità scura", - "mark_unread_label": "Segna come non letto", - "mark_read_label": "Segna come letto", - "fav_remove": "Rimuovi dai preferiti", - "fav_mark": "Segna come preferito", - "archive_label": "Archivia articolo", - "open_original": "Apri pagina originale", - "delete_label": "Elimina articolo", - "confirm_delete": "Eliminare davvero l'articolo?" - }, - "import": { - "bulk_link": "Più URL contemporaneamente? → Import multiplo", - "form_title": "Importa più articoli", - "form_subtitle": "Una URL per riga (o separate da spazi / virgole). Mana le estrae una dopo l'altra in background.", - "form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…", - "count_valid": "{n} valide", - "count_overlimit_suffix": " / max {max}", - "count_dup": "{n} duplicate (ignorate)", - "count_invalid": "{n} non valide", - "invalid_details_summary": "Mostra righe non valide ({n})", - "error_no_urls": "Aggiungi almeno una URL valida.", - "error_overlimit": "Troppe URL ({n}). Massimo {max} per job — dividi l'import.", - "error_failed": "Impossibile creare il job.", - "submit_label": "Importa {n} URL", - "submit_busy": "Creazione del job…", - "hint": "Funziona in background — puoi chiudere la scheda e tornare più tardi. 50 URL richiedono circa 5–10 minuti. Lo stato di avanzamento è nella pagina di dettaglio.", - "jobs_heading": "Import precedenti", - "filter_all": "Tutti ({n})", - "filter_active": "Attivi ({n})", - "filter_done": "Completati ({n})", - "filter_errors": "Con errori ({n})", - "empty_filter": "Nessun job in questa vista.", - "status_queued": "In attesa", - "status_running": "In esecuzione", - "status_paused": "In pausa", - "status_done": "Completato", - "status_cancelled": "Annullato", - "jobs_meta_errors": "{n} errori", - "jobs_meta_dups": "{n} duplicati", - "jobs_meta_warnings": "{n} avvisi", - "detail_title": "Job di import", - "detail_not_found": "Job non trovato.", - "detail_progress_aria": "Avanzamento", - "detail_counter_total": "{done} / {total} elaborati", - "detail_counter_saved": "{n} salvati", - "detail_counter_dups": "{n} duplicati", - "detail_counter_warns": "{n} con cookie wall", - "detail_counter_errors": "{n} errori", - "action_pause": "Pausa", - "action_resume": "Riprendi", - "action_cancel": "Annulla", - "action_retry": "Riprova errori", - "action_delete": "Elimina", - "confirm_cancel": "Annullare davvero il job? Gli articoli già salvati rimangono.", - "confirm_delete": "Eliminare la cronologia del job? Gli articoli rimangono.", - "consent_hint_strong": "Cookie wall rilevato", - "consent_hint_body": "{n, plural, one {# articolo ha} other {# articoli hanno}} salvato solo la finestra di consenso ai cookie (il server non vede cookie). Soluzione:", - "consent_hint_link": "bookmarklet HTML del browser", - "consent_hint_after_link": "dalla scheda dove hai già accettato — sovrascrive il teaser con l'articolo reale.", - "item_pending": "In attesa", - "item_extracting": "Estrazione…", - "item_extracted": "Server pronto", - "item_saved": "✓ Salvato", - "item_duplicate": "· Duplicato", - "item_consent_wall": "⚠ Cookie wall", - "item_error": "✗ Errore", - "item_cancelled": "Annullato", - "item_action_view_teaser": "Vedi teaser", - "item_action_rescue": "Salva di nuovo", - "item_action_rescue_tip": "Salva di nuovo con il bookmarklet — sovrascrive il teaser con l'articolo reale", - "item_action_open": "Apri" - } -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/ArticlesTabShell.svelte b/apps/mana/apps/web/src/lib/modules/articles/ArticlesTabShell.svelte deleted file mode 100644 index a9f04016d..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/ArticlesTabShell.svelte +++ /dev/null @@ -1,150 +0,0 @@ - - - -
-
- - -
- - - -
- {#if activeTab === 'list'} - - {:else if activeTab === 'highlights'} - - {:else if activeTab === 'favorites'} - - {:else if activeTab === 'stats'} - - {/if} -
-
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte b/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte deleted file mode 100644 index 48b9449ba..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/ListView.svelte +++ /dev/null @@ -1,329 +0,0 @@ - - - -
- {#if showContinueReading} - - {/if} - -
-
- {#each FILTERS as f (f.id)} - - {/each} -
- - {#if siteFilter || tagFilter} -
- {#if siteFilter} - - {/if} - {#if tagFilter} - - {/if} -
- {/if} -
- - {#if articles$.loading} -

Lädt…

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

Noch nichts gespeichert.

-

- Geh auf die Übersicht und füge oben eine URL ein — der Server extrahiert den Artikel mit - Readability, alles bleibt verschlüsselt offline verfügbar. -

-
- {:else if filtered.length === 0} -
-

Nichts in diesem Filter.

-

Probier einen anderen Filter oder speichere weitere Artikel.

-
- {:else} -
    - {#each filtered as article (article.id)} -
  • - -
  • - {/each} -
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/api.ts b/apps/mana/apps/web/src/lib/modules/articles/api.ts deleted file mode 100644 index 1a3fd41a2..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/api.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Articles API client — talks to apps/api `/api/v1/articles/*`. - * - * One endpoint (`POST /extract`) with the Readability result. Both the - * preview (AddUrlForm) and the direct save paths share the same call; - * the client chooses whether to show the result or immediately persist. - * - * Auth + base-URL handling mirrors news/api.ts — see that file for the - * full rationale on why we read `getManaApiUrl()` and `authStore. - * getValidToken()` instead of the cookie/env shortcuts. - */ - -import { authStore } from '$lib/stores/auth.svelte'; -import { getManaApiUrl } from '$lib/api/config'; - -async function authHeader(): Promise> { - const token = await authStore.getValidToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - -export interface ExtractedArticle { - originalUrl: string; - title: string; - excerpt: string | null; - content: string; - htmlContent: string; - author: string | null; - siteName: string | null; - wordCount: number; - readingTimeMinutes: number; - /** - * Server-side quality flag. Today only `'probable_consent_wall'` is - * emitted: the extracted text was suspiciously short AND contained - * consent-dialog vocabulary, which typically means the server's - * anonymous fetch hit a GDPR interstitial instead of the article. - * The client uses this to offer the bookmarklet-v2 (browser-HTML) - * path without silently persisting garbage. - */ - warning?: 'probable_consent_wall'; -} - -/** - * Hard client-side timeout for the extract roundtrip. The server's - * own Readability fetch has a 15s timeout + a few seconds of JSDOM - * parse overhead; anything past 25s on the wire is almost certainly a - * dead server or a stuck network path, not a slow article. Without - * this, AddUrlForm's loader just sat there forever when the API was - * unreachable — hence the bookmarklet-lands-on-loader bug. - */ -const EXTRACT_TIMEOUT_MS = 25_000; - -export async function extractArticle( - url: string, - fetchImpl: typeof fetch = fetch -): Promise { - let response: Response; - try { - response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(await authHeader()), - }, - body: JSON.stringify({ url }), - signal: AbortSignal.timeout(EXTRACT_TIMEOUT_MS), - }); - } catch (err) { - if (err instanceof DOMException && err.name === 'TimeoutError') { - throw new Error( - `Server antwortet nicht (nach ${EXTRACT_TIMEOUT_MS / 1000}s). Läuft apps/api?` - ); - } - if (err instanceof TypeError) { - // Network-layer failure (connection refused, DNS, offline). - throw new Error( - `Server nicht erreichbar. Prüf dass apps/api läuft — pnpm run mana:dev startet beides.` - ); - } - throw err; - } - if (!response.ok) { - const text = await response.text(); - throw new Error(`extractArticle failed: ${response.status} ${text}`); - } - return (await response.json()) as ExtractedArticle; -} - -/** - * Extract from a HTML payload the browser already has. Used by the - * bookmarklet-v2 flow — the user's browser already dealt with the - * cookie-consent wall, so we skip the server-side fetch entirely. - * - * The HTML cap is 10 MiB on the server; the browser sends - * `document.documentElement.outerHTML` which for typical article - * pages is 200-800 KB, well under the limit. - */ -export async function extractFromHtml( - url: string, - html: string, - fetchImpl: typeof fetch = fetch -): Promise { - let response: Response; - try { - response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract/html`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(await authHeader()), - }, - body: JSON.stringify({ url, html }), - signal: AbortSignal.timeout(EXTRACT_TIMEOUT_MS), - }); - } catch (err) { - if (err instanceof DOMException && err.name === 'TimeoutError') { - throw new Error( - `Server antwortet nicht (nach ${EXTRACT_TIMEOUT_MS / 1000}s). Läuft apps/api?` - ); - } - if (err instanceof TypeError) { - throw new Error( - `Server nicht erreichbar. Prüf dass apps/api läuft — pnpm run mana:dev startet beides.` - ); - } - throw err; - } - if (!response.ok) { - const text = await response.text(); - throw new Error(`extractFromHtml failed: ${response.status} ${text}`); - } - return (await response.json()) as ExtractedArticle; -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/collections.ts b/apps/mana/apps/web/src/lib/modules/articles/collections.ts deleted file mode 100644 index 267a72946..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/collections.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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, - LocalArticleExtractPickup, - LocalArticleImportItem, - LocalArticleImportJob, - LocalArticleTag, - LocalHighlight, -} from './types'; - -export const articleTable = db.table('articles'); -export const articleHighlightTable = db.table('articleHighlights'); -export const articleTagTable = db.table('articleTags'); -export const articleImportJobTable = db.table('articleImportJobs'); -export const articleImportItemTable = db.table('articleImportItems'); -export const articleExtractPickupTable = - db.table('articleExtractPickup'); diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte deleted file mode 100644 index eb62790bb..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/AddUrlForm.svelte +++ /dev/null @@ -1,529 +0,0 @@ - - - -
-
-

Artikel speichern

-

URL einfügen — Mana extrahiert + speichert direkt.

-
- -
- { - if (e.key === 'Enter') handleSubmit(); - }} - use:focusOnMount - /> - -
- - - - {#if (loading || saving) && !error && !preview && !duplicate} -
- -
-

- {saving ? 'Speichere in deine Leseliste…' : 'Server extrahiert den Artikel…'} -

-

- {saving - ? 'Gleich weiter zum Reader.' - : 'Dauert normalerweise 2–5 Sekunden. Nach 25 Sekunden geben wir auf.'} -

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

{error}

- {/if} - - {#if duplicate} -
-

Den hast du schon gespeichert.

-

{duplicate.title}

-
- - -
-
- {/if} - - {#if preview} - - -
-

{preview.title}

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

{preview.excerpt}

- {/if} -
- - -
-
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/ArticleCard.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/ArticleCard.svelte deleted file mode 100644 index 5a4918d28..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/ArticleCard.svelte +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/BulkImportForm.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/BulkImportForm.svelte deleted file mode 100644 index 43c3dc0a6..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/BulkImportForm.svelte +++ /dev/null @@ -1,234 +0,0 @@ - - - -
-
-

{$_('articles.import.form_title')}

-

{$_('articles.import.form_subtitle')}

-
- - - -
- - {$_('articles.import.count_valid', { values: { n: parsed.valid.length } })}{overLimit - ? $_('articles.import.count_overlimit_suffix', { values: { max: MAX_URLS_PER_JOB } }) - : ''} - - {#if parsed.duplicates.length > 0} - - {$_('articles.import.count_dup', { values: { n: parsed.duplicates.length } })} - - {/if} - {#if parsed.invalid.length > 0} - - {$_('articles.import.count_invalid', { values: { n: parsed.invalid.length } })} - - {/if} -
- - {#if overLimit} - - {/if} - - {#if parsed.invalid.length > 0} -
- - {$_('articles.import.invalid_details_summary', { values: { n: parsed.invalid.length } })} - -
    - {#each parsed.invalid as bad (bad)} -
  • {bad}
  • - {/each} -
-
- {/if} - - {#if error} - - {/if} - -
- -
- -

{$_('articles.import.hint')}

-
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte deleted file mode 100644 index dd8cd2f36..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/HighlightLayer.svelte +++ /dev/null @@ -1,294 +0,0 @@ - - - -
- {#if menu?.kind === 'create'} - (menu = null)} - /> - {:else if menu?.kind === 'edit'} - (menu = null)} - /> - {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte deleted file mode 100644 index 115e0a52e..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/HighlightMenu.svelte +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionSources.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionSources.svelte deleted file mode 100644 index bf8ac6612..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionSources.svelte +++ /dev/null @@ -1,97 +0,0 @@ - - - -{#if sources.length > 0} -
-
-

Deine Quellen

-
-
    - {#each sources as src (src.siteName)} -
  • - -
  • - {/each} -
-
-{/if} - - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionStats.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionStats.svelte deleted file mode 100644 index 5156cd915..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionStats.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - -
-
- {savedThisWeek} - diese Woche gespeichert -
-
- {finishedThisWeek} - diese Woche gelesen -
- {#if avgReadMin !== null} -
- ø {avgReadMin} min - pro Artikel in der Leseliste -
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionWeiterlesen.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionWeiterlesen.svelte deleted file mode 100644 index 21da65206..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/HomeSectionWeiterlesen.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - -{#if articles.length > 0} -
-
-

Weiterlesen

- {articles.length} -
- -
-{/if} - - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/JobDetailView.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/JobDetailView.svelte deleted file mode 100644 index e3cd0c015..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/JobDetailView.svelte +++ /dev/null @@ -1,435 +0,0 @@ - - - -
- {#if !job} -

{$_('articles.import.detail_not_found')}

- {:else} - {@const j = job} -
-
-

{$_('articles.import.detail_title')}

- {j.status} -
-
-
-
-
- - {$_('articles.import.detail_counter_total', { - values: { done: totalDone, total: j.totalUrls }, - })} - - {#if j.savedCount > 0} - - {$_('articles.import.detail_counter_saved', { values: { n: j.savedCount } })} - - {/if} - {#if j.duplicateCount > 0} - - {$_('articles.import.detail_counter_dups', { values: { n: j.duplicateCount } })} - - {/if} - {#if j.warningCount > 0} - - {$_('articles.import.detail_counter_warns', { values: { n: j.warningCount } })} - - {/if} - {#if j.errorCount > 0} - - {$_('articles.import.detail_counter_errors', { values: { n: j.errorCount } })} - - {/if} -
- -
- {#if j.status === 'running' || j.status === 'queued'} - - {/if} - {#if j.status === 'paused'} - - {/if} - {#if j.status === 'running' || j.status === 'queued' || j.status === 'paused'} - - {/if} - {#if j.errorCount > 0} - - {/if} - {#if j.status === 'done' || j.status === 'cancelled'} - - {/if} -
-
- - {#if j.warningCount > 0} - - {/if} - - - {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/JobsList.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/JobsList.svelte deleted file mode 100644 index 230dac078..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/JobsList.svelte +++ /dev/null @@ -1,260 +0,0 @@ - - - -{#if allJobs.length > 0} -
-
-

{$_('articles.import.jobs_heading')}

- -
- {#if visibleJobs.length === 0} -

{$_('articles.import.empty_filter')}

- {/if} -
    - {#each visibleJobs as job (job.id)} - - {/each} -
-
-{/if} - - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/QuickAddInput.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/QuickAddInput.svelte deleted file mode 100644 index 5a713ee0f..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/QuickAddInput.svelte +++ /dev/null @@ -1,133 +0,0 @@ - - - -
- - {#if error} - - {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte b/apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte deleted file mode 100644 index 4dac64edd..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/components/ReaderView.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - - -
- {#if html} - - {@html html} - {:else} -
{plainFallback}
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts b/apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts deleted file mode 100644 index 4ec64e4d4..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Articles Bulk-Import — client-side Pickup Consumer. - * - * The server-side import-worker drops `articleExtractPickup` rows for - * each successful URL extraction. This consumer: - * - * 1. Watches the pickup table via `liveQuery`. - * 2. For each new row: calls `articlesStore.saveFromExtracted()` so - * the existing Single-URL save-path runs unchanged (encrypt → - * `articleTable.add()` → emit ArticleSaved domain event). - * 3. Updates the matching `articleImportItems` row to state='saved' - * (or 'duplicate' / 'consent-wall') with the resulting articleId. - * 4. Deletes the pickup row so the inbox stays empty in steady state. - * - * Multi-tab coordination via `navigator.locks.request('mana:articles:pickup')`: - * any number of tabs can subscribe, but only the lock-holder consumes. - * Falls back to per-row in-memory dedupe when locks aren't available - * (older Safari) — the field-LWW merge on the server forgives the rare - * double-process. - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -import { liveQuery, type Subscription } from 'dexie'; -import { emitDomainEvent } from '$lib/data/events'; -import { - articleExtractPickupTable, - articleImportItemTable, - articleImportJobTable, -} from './collections'; -import { articlesStore } from './stores/articles.svelte'; -import type { - ArticleImportItemState, - LocalArticleExtractPickup, - LocalArticleImportJob, -} from './types'; - -const LOCK_NAME = 'mana:articles:pickup'; - -/** In-memory guard so a quick liveQuery double-tick doesn't race the - * same pickup row through `consumeOne` twice. Reset on tab close. */ -const inFlight = new Set(); - -let subscription: Subscription | null = null; -let jobWatchSubscription: Subscription | null = null; - -/** Track which jobs we've already emitted ArticleImportFinished for so a - * liveQuery re-tick doesn't double-fire when other rows change. */ -const finishedEmitted = new Set(); - -/** - * Start watching the pickup inbox. Idempotent — second call returns - * the existing dispose function. - * - * Disable via `localStorage('mana:articles:pickup:disabled')` (string - * 'true') — escape hatch for users who want to debug without the - * consumer running. - */ -export function startArticlePickupConsumer(): () => void { - if (typeof window === 'undefined') return () => {}; - if (subscription) return stopArticlePickupConsumer; - if (window.localStorage?.getItem('mana:articles:pickup:disabled') === 'true') { - console.log('[articles-import] pickup consumer disabled via localStorage'); - return () => {}; - } - - const query = liveQuery(async () => - articleExtractPickupTable.filter((r) => !r.deletedAt).toArray() - ); - subscription = query.subscribe({ - next: (rows: LocalArticleExtractPickup[]) => { - void runConsume(rows); - }, - error: (err) => { - console.error('[articles-import] pickup liveQuery error:', err); - }, - }); - - // Independently watch the jobs table for status='done' flips so we - // can emit `ArticleImportFinished` once per job. Server-worker - // flips the status; this is the only client-side observer for the - // terminal transition. - const jobsQuery = liveQuery(async () => - articleImportJobTable.filter((j) => j.status === 'done' && !j.deletedAt).toArray() - ); - jobWatchSubscription = jobsQuery.subscribe({ - next: (jobs: LocalArticleImportJob[]) => { - for (const j of jobs) { - if (finishedEmitted.has(j.id)) continue; - finishedEmitted.add(j.id); - emitDomainEvent('ArticleImportFinished', 'articles', 'articleImportJobs', j.id, { - jobId: j.id, - totalUrls: j.totalUrls, - savedCount: j.savedCount ?? 0, - duplicateCount: j.duplicateCount ?? 0, - errorCount: j.errorCount ?? 0, - warningCount: j.warningCount ?? 0, - }); - } - }, - error: (err) => { - console.error('[articles-import] job-watch liveQuery error:', err); - }, - }); - return stopArticlePickupConsumer; -} - -export function stopArticlePickupConsumer(): void { - subscription?.unsubscribe(); - subscription = null; - jobWatchSubscription?.unsubscribe(); - jobWatchSubscription = null; - inFlight.clear(); - finishedEmitted.clear(); -} - -/** - * Drain the current set of pickup rows under the multi-tab Web-Lock. - * If the lock is held by another tab, this returns immediately and the - * other tab's run handles the rows. - */ -async function runConsume(rows: readonly LocalArticleExtractPickup[]): Promise { - if (rows.length === 0) return; - - const locks = (navigator as Navigator & { locks?: LockManager }).locks; - if (!locks) { - await drain(rows); - return; - } - - await locks.request(LOCK_NAME, { ifAvailable: true }, async (lock) => { - if (!lock) { - // Another tab is the consumer — leave the rows alone. - return; - } - await drain(rows); - }); -} - -async function drain(rows: readonly LocalArticleExtractPickup[]): Promise { - for (const row of rows) { - if (inFlight.has(row.id)) continue; - inFlight.add(row.id); - try { - await consumeOne(row); - } catch (err) { - console.error('[articles-import] consumeOne failed:', row.id, err); - } finally { - inFlight.delete(row.id); - } - } -} - -async function consumeOne(row: LocalArticleExtractPickup): Promise { - const item = await articleImportItemTable.get(row.itemId); - - // Stale pickup row — item was deleted, cancelled, or already - // consumed by a previous tab. Just clean up the inbox. - if (!item || item.state !== 'extracted' || item.deletedAt) { - await articleExtractPickupTable.delete(row.id); - return; - } - - // Dedupe race: user may have single-saved this URL via QuickAddInput - // while the bulk job was running. Don't write a duplicate Article - // row; just point the import item at the existing one. - const existing = await articlesStore.findByUrl(row.payload.originalUrl); - if (existing) { - await articleImportItemTable.update(item.id, { - state: 'duplicate', - articleId: existing.id, - }); - await articleExtractPickupTable.delete(row.id); - return; - } - - // Happy path: persist via the existing single-URL pipeline. This - // runs encryptRecord + articleTable.add and emits the ArticleSaved - // domain event, exactly like a manual `Save URL` would. - const article = await articlesStore.saveFromExtracted({ - originalUrl: row.payload.originalUrl, - title: row.payload.title, - excerpt: row.payload.excerpt, - content: row.payload.content, - htmlContent: row.payload.htmlContent, - author: row.payload.author, - siteName: row.payload.siteName, - wordCount: row.payload.wordCount, - readingTimeMinutes: row.payload.readingTimeMinutes, - warning: row.payload.warning, - }); - - const nextState: ArticleImportItemState = - row.payload.warning === 'probable_consent_wall' ? 'consent-wall' : 'saved'; - - await articleImportItemTable.update(item.id, { - state: nextState, - articleId: article.id, - warning: row.payload.warning ?? null, - }); - await articleExtractPickupTable.delete(row.id); -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/index.ts b/apps/mana/apps/web/src/lib/modules/articles/index.ts deleted file mode 100644 index d8de9d25d..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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, - useAllHighlights, - useArticleTagIds, - useArticleTagMap, - useStats, - toArticle, - toHighlight, - filterByStatus, - searchArticles, -} from './queries'; - -export type { ArticlesStats, SiteCount, HighlightWithArticle } from './queries'; - -export { articleTable, articleHighlightTable, articleTagTable } from './collections'; - -export type { - LocalArticle, - LocalHighlight, - LocalArticleTag, - Article, - Highlight, - ArticleStatus, - HighlightColor, -} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/articles/lib/markdown-export.ts b/apps/mana/apps/web/src/lib/modules/articles/lib/markdown-export.ts deleted file mode 100644 index ace18b178..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/lib/markdown-export.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Markdown export for the highlights collection view. - * - * Groups highlights by article, dumps them in the order - * `useAllHighlights` returned them (chronological), and wraps the whole - * thing in a small header with the export date so the user can paste - * the result into Obsidian, Notion, a Markdown note — whatever. - * - * Kept in a standalone file so the export logic can be unit-tested - * without needing the Svelte render tree. - */ - -import type { HighlightWithArticle } from '../queries'; - -/** Escape the minimum set of Markdown specials that show up in article - * titles and highlight text so pasted output doesn't accidentally - * format parts of the quote. We don't escape inside the quoted block - * itself — the reader's expectation is "see what I highlighted". */ -function escapeTitle(text: string): string { - return text.replace(/([\\*_`[\]<>])/g, '\\$1'); -} - -function formatDate(iso: string): string { - try { - return new Date(iso).toISOString().slice(0, 10); - } catch { - return iso.slice(0, 10); - } -} - -export function renderHighlightsMarkdown( - rows: HighlightWithArticle[], - now: Date = new Date() -): string { - const header = `# Mana Highlights — ${now.toISOString().slice(0, 10)}\n`; - if (rows.length === 0) { - return `${header}\n_Keine Highlights._\n`; - } - - // Preserve the incoming chronological order but group consecutive - // rows for the same article together. Using a manual walk instead of - // Map-groupBy keeps the per-section header below the most-recent row - // for that article, matching what the UI shows. - const blocks: string[] = [header]; - let currentArticleId: string | null = null; - - for (const row of rows) { - if (row.article.id !== currentArticleId) { - currentArticleId = row.article.id; - blocks.push(''); - blocks.push(`## ${escapeTitle(row.article.title)}`); - const subtitle = [row.article.siteName, row.article.originalUrl] - .filter((s): s is string => !!s) - .join(' · '); - if (subtitle) blocks.push(`_${subtitle}_`); - blocks.push(''); - } - - const savedAt = row.highlight.createdAt ? ` _(${formatDate(row.highlight.createdAt)})_` : ''; - blocks.push(`- > ${row.highlight.text.replace(/\n+/g, ' ')}${savedAt}`); - if (row.highlight.note) { - blocks.push(` — ${row.highlight.note.replace(/\n+/g, ' ')}`); - } - } - - blocks.push(''); - return blocks.join('\n'); -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts b/apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts deleted file mode 100644 index 7836cefab..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Highlight offset resolution. - * - * We persist each highlight as a `{ startOffset, endOffset }` pair of - * plain-text character offsets into the Reader's root. "Plain text" here - * is the concatenation of all text nodes in document order — i.e. the - * value of `root.textContent` — so `

Hello world

` - * has the offsets `H=0, e=1, …, w=6, o=7, …`.
, , and block - * boundaries contribute zero characters, which matches `textContent`'s - * behaviour and the user's mental model of "what did I actually select?" - * - * Storing offsets into the rendered DOM (as opposed to the article's raw - * `content` field) means we don't have to reconcile Readability's - * whitespace normalisation with the browser's. On re-open we walk the - * same DOM and find the node for each offset. - * - * The context-snippet fields on `LocalHighlight` (`contextBefore`, - * `contextAfter`) are populated here for re-anchor purposes in later - * milestones (when the article is re-extracted and the offsets drift). - */ - -const CONTEXT_CHARS = 40; - -export interface TextOffsetPair { - start: number; - end: number; -} - -/** - * A single contiguous text-node slice between `start` and `end` offsets. - * Used when wrapping a multi-node range into highlight spans. - */ -export interface TextSlice { - node: Text; - /** inclusive offset inside this text node */ - start: number; - /** exclusive offset inside this text node */ - end: number; -} - -/** - * Resolve a DOM Range to plain-text offsets relative to `root`. - * - * Returns null if the range is collapsed, not inside the root, or - * crosses element boundaries we can't map (shouldn't happen for normal - * user selections inside the reader body). - */ -export function rangeToTextOffsets(range: Range, root: Element): TextOffsetPair | null { - if (range.collapsed) return null; - if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) { - return null; - } - - let start = -1; - let end = -1; - let offset = 0; - - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let node: Node | null = walker.nextNode(); - while (node) { - const text = node as Text; - const length = text.data.length; - - if (text === range.startContainer) { - start = offset + Math.min(range.startOffset, length); - } - if (text === range.endContainer) { - end = offset + Math.min(range.endOffset, length); - break; - } - offset += length; - node = walker.nextNode(); - } - - // Edge case: range boundaries are element nodes (e.g. the user - // triple-clicked a paragraph). Fall back to the first/last text - // descendant so we still get something saveable. - if (start === -1) start = descendantOffset(root, range.startContainer, range.startOffset); - if (end === -1) end = descendantOffset(root, range.endContainer, range.endOffset); - - if (start < 0 || end < 0 || end <= start) return null; - return { start, end }; -} - -function descendantOffset(root: Element, container: Node, offset: number): number { - // Element-container ranges report offset in terms of child *nodes*, not - // characters. Translate by summing textContent of siblings before the - // child index. - if (container.nodeType === Node.TEXT_NODE) { - // Already a text node but wasn't hit in the main walk — find its absolute offset. - let total = 0; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let n: Node | null = walker.nextNode(); - while (n) { - if (n === container) return total + Math.min(offset, (n as Text).data.length); - total += (n as Text).data.length; - n = walker.nextNode(); - } - return total; - } - const childIndex = Math.min(offset, container.childNodes.length); - let total = textLengthBefore(root, container, childIndex); - return total; -} - -function textLengthBefore(root: Element, container: Node, childIndex: number): number { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let total = 0; - let n: Node | null = walker.nextNode(); - while (n) { - // Stop once we're past the target child. - if (isDescendantAtIndexOrLater(container, childIndex, n)) return total; - total += (n as Text).data.length; - n = walker.nextNode(); - } - return total; -} - -function isDescendantAtIndexOrLater(container: Node, index: number, candidate: Node): boolean { - // Walk up from candidate until we hit a direct child of container. - let node: Node | null = candidate; - while (node && node.parentNode !== container) { - node = node.parentNode; - } - if (!node) return false; - const idx = Array.prototype.indexOf.call(container.childNodes, node); - return idx >= index; -} - -/** - * Resolve `{ start, end }` plain-text offsets back into a list of - * contiguous text-node slices, suitable for wrapping in highlight - * spans. Multi-paragraph selections yield multiple slices, one per - * text node touched. - */ -export function textOffsetsToSlices(root: Element, start: number, end: number): TextSlice[] { - const slices: TextSlice[] = []; - if (end <= start) return slices; - - let offset = 0; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let node: Node | null = walker.nextNode(); - while (node) { - const text = node as Text; - const length = text.data.length; - const nodeStart = offset; - const nodeEnd = offset + length; - - if (nodeEnd > start && nodeStart < end) { - slices.push({ - node: text, - start: Math.max(0, start - nodeStart), - end: Math.min(length, end - nodeStart), - }); - } - - if (nodeEnd >= end) break; - offset = nodeEnd; - node = walker.nextNode(); - } - return slices; -} - -export interface SelectionSnapshot { - start: number; - end: number; - text: string; - contextBefore: string | null; - contextAfter: string | null; -} - -/** - * Package a user Range into everything we need to persist a highlight: - * offsets, selected text, and ~40 chars of surrounding context for - * later re-anchor attempts. - */ -export function extractSelectionSnapshot(range: Range, root: Element): SelectionSnapshot | null { - const offsets = rangeToTextOffsets(range, root); - if (!offsets) return null; - - const whole = root.textContent ?? ''; - const text = whole.slice(offsets.start, offsets.end).trim(); - if (!text) return null; - - const before = whole.slice(Math.max(0, offsets.start - CONTEXT_CHARS), offsets.start) || null; - const after = whole.slice(offsets.end, offsets.end + CONTEXT_CHARS) || null; - - return { - start: offsets.start, - end: offsets.end, - text, - contextBefore: before, - contextAfter: after, - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/module.config.ts b/apps/mana/apps/web/src/lib/modules/articles/module.config.ts deleted file mode 100644 index 11e5808f5..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/module.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -/** - * Articles module — saved web articles + highlights + tag links + bulk- - * import jobs. - * - * `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, …). - * - * `articleImportJobs` + `articleImportItems` + `articleExtractPickup` - * implement the durable bulk-import pipeline (docs/plans/articles-bulk- - * import.md). All three sync under the articles appId so multi-device - * progress and server-worker state-transitions ride the standard - * sync_changes channel. - */ -export const articlesModuleConfig: ModuleConfig = { - appId: 'articles', - tables: [ - { name: 'articles' }, - { name: 'articleHighlights', syncName: 'highlights' }, - { name: 'articleTags' }, - { name: 'articleImportJobs', syncName: 'importJobs' }, - { name: 'articleImportItems', syncName: 'importItems' }, - { name: 'articleExtractPickup', syncName: 'extractPickup' }, - ], -}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/parse-urls.ts b/apps/mana/apps/web/src/lib/modules/articles/parse-urls.ts deleted file mode 100644 index 3e1a4e132..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/parse-urls.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Pure URL-list parser for the bulk-import flow. Extracted into its - * own module so tests can import + exercise it without booting Dexie - * (collections.ts and stores/imports.svelte.ts have a transitive - * dependency on the database, which won't open under fake-indexeddb - * if any registered table is currently in a half-migrated state). - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -export interface ParsedUrls { - valid: string[]; - invalid: string[]; - duplicates: string[]; -} - -/** - * Splits the raw textarea blob on any whitespace + comma, drops empty - * tokens, validates with `new URL` + http(s) scheme check, and - * deduplicates while preserving first-occurrence order. - * - * parseUrls('https://a.com\nhttps://a.com\nbroken') - * → { valid: ['https://a.com/'], - * invalid: ['broken'], - * duplicates: ['https://a.com/'] } - */ -export function parseUrls(raw: string): ParsedUrls { - const tokens = raw - .split(/[\s,]+/) - .map((t) => t.trim()) - .filter(Boolean); - const valid: string[] = []; - const invalid: string[] = []; - const duplicates: string[] = []; - const seen = new Set(); - for (const token of tokens) { - // Reject anything without an http(s) scheme — `new URL('foo.com')` - // would happily accept it as an opaque URI and the server-side - // fetch would then 400 on us. - let parsed: URL; - try { - parsed = new URL(token); - } catch { - invalid.push(token); - continue; - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - invalid.push(token); - continue; - } - const canonical = parsed.toString(); - if (seen.has(canonical)) { - duplicates.push(canonical); - continue; - } - seen.add(canonical); - valid.push(canonical); - } - return { valid, invalid, duplicates }; -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/queries.ts b/apps/mana/apps/web/src/lib/modules/articles/queries.ts deleted file mode 100644 index d7560fd08..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/queries.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * 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 { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; -import { deriveUpdatedAt } from '$lib/data/sync'; -import { decryptRecords } from '$lib/data/crypto'; -import { scopedForModule, scopedGet } from '$lib/data/scope'; -import { articleTagOps } from './stores/tags.svelte'; -import type { - Article, - ArticleImportItem, - ArticleImportJob, - ArticleStatus, - Highlight, - LocalArticle, - LocalArticleImportItem, - LocalArticleImportJob, - LocalHighlight, -} 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: deriveUpdatedAt(local), - }; -} - -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: deriveUpdatedAt(local), - }; -} - -// ─── Live Queries ───────────────────────────────────────── - -export function useAllArticles() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('articles', 'articles').toArray(); - const visible = locals.filter((a) => !a.deletedAt); - const decrypted = await decryptRecords('articles', visible); - return decrypted - .map(toArticle) - .sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? '')); - }, [] as Article[]); -} - -export function useArticle(id: string) { - return useScopedLiveQuery( - async () => { - // scopedGet returns undefined if the article belongs to another - // space — protects against URL-manipulated deep links. - const local = await scopedGet('articles', id); - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('articles', [local]); - return decrypted ? toArticle(decrypted) : null; - }, - null as Article | null - ); -} - -/** - * 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 useScopedLiveQuery(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 useScopedLiveQuery( - async () => articleTagOps.getTagIdsForMany(articleIds), - new Map() - ); -} - -export interface SiteCount { - siteName: string; - count: number; -} - -export interface ArticlesStats { - total: number; - unread: number; - reading: number; - finished: number; - archived: number; - favorites: number; - savedThisWeek: number; - finishedThisWeek: number; - topSites: SiteCount[]; - totalHighlights: number; -} - -const WEEK_MS = 7 * 24 * 60 * 60 * 1000; - -/** - * Aggregate stats for the dashboard widget + stats section. One live - * query over scope-filtered articles + highlights; decrypts only the - * articles (needed for title-based top-sites grouping). - */ -export function useStats() { - return useScopedLiveQuery( - async () => { - const [articleRows, highlightRows] = await Promise.all([ - scopedForModule('articles', 'articles').toArray(), - scopedForModule('articles', 'articleHighlights').toArray(), - ]); - const visible = articleRows.filter((a) => !a.deletedAt); - const decrypted = await decryptRecords('articles', visible); - - const now = Date.now(); - const weekAgo = now - WEEK_MS; - - const byStatus: Record = { - unread: 0, - reading: 0, - finished: 0, - archived: 0, - }; - let favorites = 0; - let savedThisWeek = 0; - let finishedThisWeek = 0; - const siteCounts = new Map(); - - for (const a of decrypted) { - byStatus[a.status] = (byStatus[a.status] ?? 0) + 1; - if (a.isFavorite) favorites++; - const savedTs = a.savedAt ? Date.parse(a.savedAt) : NaN; - if (Number.isFinite(savedTs) && savedTs >= weekAgo) savedThisWeek++; - const readTs = a.readAt ? Date.parse(a.readAt) : NaN; - if (Number.isFinite(readTs) && readTs >= weekAgo) finishedThisWeek++; - if (a.siteName) { - siteCounts.set(a.siteName, (siteCounts.get(a.siteName) ?? 0) + 1); - } - } - - const topSites: SiteCount[] = [...siteCounts.entries()] - .map(([siteName, count]) => ({ siteName, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 5); - - const totalHighlights = highlightRows.filter((h) => !h.deletedAt).length; - - return { - total: decrypted.length, - unread: byStatus.unread, - reading: byStatus.reading, - finished: byStatus.finished, - archived: byStatus.archived, - favorites, - savedThisWeek, - finishedThisWeek, - topSites, - totalHighlights, - } satisfies ArticlesStats; - }, - { - total: 0, - unread: 0, - reading: 0, - finished: 0, - archived: 0, - favorites: 0, - savedThisWeek: 0, - finishedThisWeek: 0, - topSites: [], - totalHighlights: 0, - } as ArticlesStats - ); -} - -/** - * Cross-article highlights query for `/articles/highlights`. Fetches - * all articles + all highlights in the active scope, pairs them up, - * and returns rows shaped for rendering as a chronological collection. - * Articles without highlights are excluded. - */ -export interface HighlightWithArticle { - highlight: Highlight; - article: Pick; -} - -export function useAllHighlights() { - return useScopedLiveQuery(async () => { - const [articleRows, highlightRows] = await Promise.all([ - scopedForModule('articles', 'articles').toArray(), - scopedForModule('articles', 'articleHighlights').toArray(), - ]); - const liveArticles = articleRows.filter((a) => !a.deletedAt); - const liveHighlights = highlightRows.filter((h) => !h.deletedAt); - if (liveHighlights.length === 0) return [] as HighlightWithArticle[]; - - const [decArticles, decHighlights] = await Promise.all([ - decryptRecords('articles', liveArticles), - decryptRecords('articleHighlights', liveHighlights), - ]); - - const byId = new Map(decArticles.map((a) => [a.id, toArticle(a)])); - - return decHighlights - .map((h) => { - const art = byId.get(h.articleId); - if (!art) return null; - return { - highlight: toHighlight(h), - article: { - id: art.id, - title: art.title, - siteName: art.siteName, - originalUrl: art.originalUrl, - }, - } satisfies HighlightWithArticle; - }) - .filter((r): r is HighlightWithArticle => r !== null) - .sort((a, b) => (b.highlight.createdAt ?? '').localeCompare(a.highlight.createdAt ?? '')); - }, [] as HighlightWithArticle[]); -} - -export function useArticleHighlights(articleId: string) { - return useScopedLiveQuery(async () => { - // scopedForModule returns the scope-filtered Collection; we narrow - // to this article in a post-filter (O(highlights per space), tiny). - // Using scopedForModule instead of a direct indexed where() keeps the - // scope check centralised — same pattern other modules use for - // per-parent lookups (e.g. notes tag subsets). - const locals = await scopedForModule( - 'articles', - 'articleHighlights' - ).toArray(); - const forArticle = locals.filter((h) => h.articleId === articleId && !h.deletedAt); - const decrypted = await decryptRecords('articleHighlights', forArticle); - return decrypted.map(toHighlight).sort((a, b) => a.startOffset - b.startOffset); - }, [] as Highlight[]); -} - -// ─── Bulk-Import (docs/plans/articles-bulk-import.md) ──── - -export function toImportJob(local: LocalArticleImportJob): ArticleImportJob { - const now = new Date().toISOString(); - return { - id: local.id, - totalUrls: local.totalUrls, - status: local.status, - startedAt: local.startedAt ?? null, - finishedAt: local.finishedAt ?? null, - savedCount: local.savedCount ?? 0, - duplicateCount: local.duplicateCount ?? 0, - errorCount: local.errorCount ?? 0, - warningCount: local.warningCount ?? 0, - createdAt: local.createdAt ?? now, - updatedAt: deriveUpdatedAt(local) ?? local.createdAt ?? now, - }; -} - -export function toImportItem(local: LocalArticleImportItem): ArticleImportItem { - const now = new Date().toISOString(); - return { - id: local.id, - jobId: local.jobId, - idx: local.idx, - url: local.url, - state: local.state, - articleId: local.articleId ?? null, - warning: local.warning ?? null, - error: local.error ?? null, - attempts: local.attempts ?? 0, - lastAttemptAt: local.lastAttemptAt ?? null, - createdAt: local.createdAt ?? now, - updatedAt: deriveUpdatedAt(local) ?? local.createdAt ?? now, - }; -} - -/** All bulk-import jobs in the active space, newest first. Drives the - * `/articles/import` index. */ -export function useImportJobs() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'articles', - 'articleImportJobs' - ).toArray(); - const visible = locals.filter((j) => !j.deletedAt); - visible.sort((a, b) => (deriveUpdatedAt(b) ?? '').localeCompare(deriveUpdatedAt(a) ?? '')); - return visible.map(toImportJob); - }, [] as ArticleImportJob[]); -} - -/** Single job — drives the `/articles/import/[jobId]` detail header. */ -export function useImportJob(jobId: string) { - return useScopedLiveQuery( - async () => { - const local = await scopedGet('articleImportJobs', jobId); - if (!local || local.deletedAt) return null; - return toImportJob(local); - }, - null as ArticleImportJob | null - ); -} - -/** Items for one job, in the original input order. Drives the per-row - * list on the detail view. */ -export function useImportItems(jobId: string) { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'articles', - 'articleImportItems' - ).toArray(); - const forJob = locals.filter((i) => i.jobId === jobId && !i.deletedAt); - forJob.sort((a, b) => a.idx - b.idx); - return forJob.map(toImportItem); - }, [] as ArticleImportItem[]); -} - -// ─── Pure Helpers ───────────────────────────────────────── - -export function filterByStatus(articles: Article[], status: ArticleStatus): Article[] { - return articles.filter((a) => a.status === status); -} - -export function searchArticles(articles: Article[], query: string): Article[] { - const lower = query.toLowerCase(); - return articles.filter( - (a) => - a.title.toLowerCase().includes(lower) || - (a.author?.toLowerCase().includes(lower) ?? false) || - (a.siteName?.toLowerCase().includes(lower) ?? false) - ); -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts deleted file mode 100644 index 348faa0e2..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/stores/articles.svelte.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Articles store — mutation-only service. - * - * M1 scope is intentionally thin: delete + status/favourite/progress toggles - * that exercise the encryption + event pipeline. `saveFromUrl` (the real - * ingestion path) lands in M2 together with the server extract route and - * AddUrlForm. The pipeline is wired now so the Reader view and CRUD plumbing - * in M2/M3 can slot in without reshaping calls. - */ - -import { encryptRecord, decryptRecords } from '$lib/data/crypto'; -import { emitDomainEvent } from '$lib/data/events'; -import { scopedForModule } from '$lib/data/scope'; -import { articleTable } from '../collections'; -import { extractArticle, type ExtractedArticle } from '../api'; -import { toArticle } from '../queries'; -import type { Article, ArticleStatus, LocalArticle } from '../types'; - -export const articlesStore = { - async setStatus(id: string, status: ArticleStatus): Promise { - const diff: Partial = { - status, - }; - if (status === 'finished') { - const existing = await articleTable.get(id); - if (existing && !existing.readAt) diff.readAt = diff.updatedAt; - } - await articleTable.update(id, diff); - }, - - async toggleFavorite(id: string): Promise { - const existing = await articleTable.get(id); - if (!existing) return; - await articleTable.update(id, { - isFavorite: !existing.isFavorite, - }); - }, - - async setProgress(id: string, progress: number): Promise { - const clamped = Math.max(0, Math.min(1, progress)); - await articleTable.update(id, { - readingProgress: clamped, - }); - }, - - async updateNote(id: string, note: string | null): Promise { - const diff: Partial = { - userNote: note, - }; - await encryptRecord('articles', diff as LocalArticle); - await articleTable.update(id, diff); - }, - - async deleteArticle(id: string): Promise { - await articleTable.update(id, { - deletedAt: new Date().toISOString(), - }); - }, - - /** - * Look up an already-saved article by URL in the current space. Used - * by the dedupe path in saveFromUrl and by AddUrlForm to offer - * "already saved — open it" instead of duplicating the row. - * Returns a decrypted snapshot, or null. - */ - async findByUrl(url: string): Promise
{ - const match = await scopedForModule('articles', 'articles') - .filter((r) => r.originalUrl === url && !r.deletedAt) - .first(); - if (!match) return null; - const [decrypted] = await decryptRecords('articles', [match]); - return decrypted ? toArticle(decrypted) : null; - }, - - /** - * Persist an extracted payload as a saved article. Returns the snapshot - * directly so callers can navigate to `/articles/` without waiting - * for the liveQuery to tick. - * - * AddUrlForm passes a pre-extracted payload (after it already called - * extractArticle once to render the preview); the direct path in - * saveFromUrl lets the store do the extract itself. - */ - async saveFromExtracted(extracted: ExtractedArticle): Promise
{ - const now = new Date().toISOString(); - const newLocal: LocalArticle = { - id: crypto.randomUUID(), - originalUrl: extracted.originalUrl, - title: extracted.title, - excerpt: extracted.excerpt ?? null, - content: extracted.content, - htmlContent: extracted.htmlContent ?? null, - author: extracted.author ?? null, - siteName: extracted.siteName ?? null, - imageUrl: null, - wordCount: extracted.wordCount, - readingTimeMinutes: extracted.readingTimeMinutes, - publishedAt: null, - status: 'unread', - readingProgress: 0, - isFavorite: false, - savedAt: now, - readAt: null, - userNote: null, - extractedVersion: 1, - }; - const snapshot = toArticle(newLocal); - await encryptRecord('articles', newLocal); - await articleTable.add(newLocal); - emitDomainEvent('ArticleSaved', 'articles', 'articles', newLocal.id, { - articleId: newLocal.id, - title: newLocal.title, - }); - return snapshot; - }, - - /** - * Full save path: dedupe → extract → persist. Returns the existing - * article when the URL is already saved in the current space (the - * caller can then navigate to it instead of creating a duplicate). - */ - async saveFromUrl(url: string): Promise<{ article: Article; duplicate: boolean }> { - const existing = await this.findByUrl(url); - if (existing) return { article: existing, duplicate: true }; - const extracted = await extractArticle(url); - const article = await this.saveFromExtracted(extracted); - return { article, duplicate: false }; - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts deleted file mode 100644 index a72b1cfc7..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/stores/highlights.svelte.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Highlights store — mutation-only service for `articleHighlights`. - * - * Every write routes through encryptRecord so text + note + context - * snippets ship encrypted. Structural fields (articleId, startOffset, - * endOffset, color) stay plaintext for the reader's range-scan query. - */ - -import { encryptRecord } from '$lib/data/crypto'; -import { articleHighlightTable } from '../collections'; -import { toHighlight } from '../queries'; -import type { Highlight, HighlightColor, LocalHighlight } from '../types'; - -export interface AddHighlightInput { - articleId: string; - text: string; - color?: HighlightColor; - note?: string | null; - startOffset: number; - endOffset: number; - contextBefore?: string | null; - contextAfter?: string | null; -} - -export const highlightsStore = { - async addHighlight(input: AddHighlightInput): Promise { - const newLocal: LocalHighlight = { - id: crypto.randomUUID(), - articleId: input.articleId, - text: input.text, - note: input.note ?? null, - color: input.color ?? 'yellow', - startOffset: input.startOffset, - endOffset: input.endOffset, - contextBefore: input.contextBefore ?? null, - contextAfter: input.contextAfter ?? null, - }; - const snapshot = toHighlight(newLocal); - await encryptRecord('articleHighlights', newLocal); - await articleHighlightTable.add(newLocal); - return snapshot; - }, - - async setColor(id: string, color: HighlightColor): Promise { - await articleHighlightTable.update(id, { - color, - }); - }, - - async setNote(id: string, note: string | null): Promise { - const diff: Partial = { - note, - }; - await encryptRecord('articleHighlights', diff as LocalHighlight); - await articleHighlightTable.update(id, diff); - }, - - async deleteHighlight(id: string): Promise { - await articleHighlightTable.update(id, { - deletedAt: new Date().toISOString(), - }); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/imports.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/imports.svelte.ts deleted file mode 100644 index 7f9bb11f5..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/stores/imports.svelte.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Articles Bulk-Import — store (mutations only). - * - * Creates and steers `articleImportJobs` + `articleImportItems`. The - * server-side worker (apps/api/src/modules/articles/import-worker.ts) - * picks up `state='pending'` items, extracts them, drops Pickup rows - * the client-side `consume-pickup.ts` consumer translates into encrypted - * `articles` rows. - * - * Read-side queries live in `queries.ts` (a `useImportJob(id)` / - * `useImportItems(jobId)` pair will land alongside the UI in Phase 5). - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -import { emitDomainEvent } from '$lib/data/events'; -import { articleImportJobTable, articleImportItemTable } from '../collections'; -import { parseUrls, type ParsedUrls } from '../parse-urls'; -import type { - ArticleImportItemState, - LocalArticleImportItem, - LocalArticleImportJob, -} from '../types'; - -// Re-export so call sites that already imported from `stores/imports` -// (BulkImportForm, tools.ts) keep working unchanged. -export { parseUrls, type ParsedUrls }; - -/** - * Hard cap on the URL count per job. The worker can chew through any - * number of items, but at very high counts the UI becomes unwieldy - * (JobDetailView is a flat list, no virtualisation yet) and the - * worst-case wall-clock duration climbs into the multi-hour range - * (50 URLs ≈ 5–10 min at concurrency 3, scales linearly). 200 is a - * pragmatic ceiling — real reading-list dumps from Pocket exports - * average 50–150 items. - */ -export const MAX_URLS_PER_JOB = 200; - -export const articleImportsStore = { - /** - * Create a job with N items, all in state='pending'. Returns the - * job id so the caller can navigate to `/articles/import/[jobId]`. - * - * No URL validation here — `parseUrls` is the canonical entry, and - * the UI calls it for live feedback before submit. We accept a - * pre-cleaned string array so this method stays trivially testable. - */ - async createJob(urls: readonly string[]): Promise { - if (urls.length === 0) { - throw new Error('createJob: empty url list'); - } - if (urls.length > MAX_URLS_PER_JOB) { - throw new Error( - `createJob: too many URLs (${urls.length}). Max ${MAX_URLS_PER_JOB} pro Job — splitte den Import in mehrere Jobs.` - ); - } - const jobId = crypto.randomUUID(); - - const job: LocalArticleImportJob = { - id: jobId, - totalUrls: urls.length, - status: 'queued', - startedAt: null, - finishedAt: null, - savedCount: 0, - duplicateCount: 0, - errorCount: 0, - warningCount: 0, - }; - - const items: LocalArticleImportItem[] = urls.map((url, idx) => ({ - id: crypto.randomUUID(), - jobId, - idx, - url, - state: 'pending' as ArticleImportItemState, - articleId: null, - warning: null, - error: null, - attempts: 0, - lastAttemptAt: null, - })); - - // Items first so a server-worker tick that races the job-write - // won't see a job with totalUrls=N but only N-1 items reachable. - // (Conservative ordering — the worker filters jobs to running/ - // queued before scanning items, but the bulkAdd is cheap.) - await articleImportItemTable.bulkAdd(items); - await articleImportJobTable.add(job); - - emitDomainEvent('ArticleImportStarted', 'articles', 'articleImportJobs', jobId, { - jobId, - totalUrls: urls.length, - }); - - return jobId; - }, - - /** Pause a running job. Server-worker observes `status='paused'` and - * stops claiming new items. Already-extracting items finish their - * roundtrip; pickup/encrypt cycle for them runs normally. */ - async pauseJob(jobId: string): Promise { - await articleImportJobTable.update(jobId, { status: 'paused' }); - }, - - /** Resume a paused job. */ - async resumeJob(jobId: string): Promise { - await articleImportJobTable.update(jobId, { status: 'running' }); - }, - - /** Cancel a job. Server-worker flips every still-pending item to - * state='cancelled' on the next tick. */ - async cancelJob(jobId: string): Promise { - await articleImportJobTable.update(jobId, { status: 'cancelled' }); - }, - - /** - * Retry the failed items of a job — flip them back to 'pending' so - * the worker picks them up again. Resets attempts so the per-item - * 3-attempt budget restarts cleanly. Counter delta is left to the - * worker (it derives counters from current item states each tick). - */ - async retryFailed(jobId: string): Promise { - const failed = await articleImportItemTable - .where('[jobId+state]') - .equals([jobId, 'error']) - .toArray(); - for (const it of failed) { - await articleImportItemTable.update(it.id, { - state: 'pending' as ArticleImportItemState, - error: null, - attempts: 0, - }); - } - // If the job was 'done' because everything was terminal, re-arm it. - if (failed.length > 0) { - const job = await articleImportJobTable.get(jobId); - if (job?.status === 'done') { - await articleImportJobTable.update(jobId, { - status: 'running', - finishedAt: null, - }); - } - } - return failed.length; - }, - - /** Soft-delete the job + all its items. Article rows that already - * landed are NOT touched — the user's reading list is independent - * from the import job's history. */ - async deleteJob(jobId: string): Promise { - const now = new Date().toISOString(); - const items = await articleImportItemTable.where('jobId').equals(jobId).toArray(); - for (const it of items) { - await articleImportItemTable.update(it.id, { deletedAt: now }); - } - await articleImportJobTable.update(jobId, { deletedAt: now }); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/imports.test.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/imports.test.ts deleted file mode 100644 index 1e4f0ba54..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/stores/imports.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Tests for the pure `parseUrls` URL-list parser. The store's mutation - * methods (createJob, pauseJob, …) are integration-shaped (need Dexie - * + the scope hook) and live under the integration suite; this file - * only covers the parser, which is the deterministic part. - * - * Plan: docs/plans/articles-bulk-import.md. - */ - -import { describe, it, expect } from 'vitest'; -import { parseUrls } from '../parse-urls'; - -describe('parseUrls', () => { - it('returns empty arrays for an empty input', () => { - expect(parseUrls('')).toEqual({ valid: [], invalid: [], duplicates: [] }); - expect(parseUrls(' \n\t ')).toEqual({ valid: [], invalid: [], duplicates: [] }); - }); - - it('parses a single newline-separated list', () => { - const r = parseUrls('https://example.com/a\nhttps://example.com/b\nhttps://example.com/c'); - expect(r.valid).toEqual([ - 'https://example.com/a', - 'https://example.com/b', - 'https://example.com/c', - ]); - expect(r.invalid).toEqual([]); - expect(r.duplicates).toEqual([]); - }); - - it('accepts whitespace + comma + tabs as separators', () => { - const r = parseUrls('https://a.com https://b.com,\thttps://c.com\nhttps://d.com'); - expect(r.valid).toEqual([ - 'https://a.com/', - 'https://b.com/', - 'https://c.com/', - 'https://d.com/', - ]); - }); - - it('accepts http and https, rejects everything else', () => { - const r = parseUrls( - [ - 'http://insecure.example', - 'https://secure.example', - 'ftp://files.example', - 'javascript:alert(1)', - 'mailto:foo@bar.com', - 'file:///etc/passwd', - ].join('\n') - ); - expect(r.valid).toEqual(['http://insecure.example/', 'https://secure.example/']); - expect(r.invalid).toHaveLength(4); - expect(r.invalid).toContain('javascript:alert(1)'); - expect(r.invalid).toContain('mailto:foo@bar.com'); - }); - - it('rejects scheme-less domains (URL accepts them as opaque)', () => { - const r = parseUrls('example.com\ngoogle.com\nhttps://valid.com'); - expect(r.valid).toEqual(['https://valid.com/']); - expect(r.invalid).toEqual(['example.com', 'google.com']); - }); - - it('flags duplicate URLs as duplicates, keeps the first occurrence', () => { - const r = parseUrls( - 'https://example.com/a\nhttps://example.com/b\nhttps://example.com/a\nhttps://example.com/b' - ); - expect(r.valid).toEqual(['https://example.com/a', 'https://example.com/b']); - expect(r.duplicates).toEqual(['https://example.com/a', 'https://example.com/b']); - }); - - it('canonicalises URLs (trailing slash on root, identical query order) so dupes are caught', () => { - const r = parseUrls('https://example.com\nhttps://example.com/'); - expect(r.valid).toEqual(['https://example.com/']); - expect(r.duplicates).toEqual(['https://example.com/']); - }); - - it('preserves first-occurrence order across mixed valid/invalid/dup tokens', () => { - const r = parseUrls( - [ - 'https://first.com', - 'not-a-url', - 'https://second.com', - 'https://first.com', // duplicate of first - 'https://third.com', - ].join('\n') - ); - expect(r.valid).toEqual(['https://first.com/', 'https://second.com/', 'https://third.com/']); - expect(r.invalid).toEqual(['not-a-url']); - expect(r.duplicates).toEqual(['https://first.com/']); - }); - - it('handles realistic paste with title prefixes (extracts URL-shaped tokens only)', () => { - // User pasted from a chat where each line had a title before the URL - // — our parser splits on whitespace, so this leaves bare URL tokens - // + title-noise as "invalid". That's the correct behaviour for a - // strict parser; the UI surfaces both counters so the user sees it. - const r = parseUrls( - 'Awesome article: https://nytimes.com/article-1\nAnother one: https://wsj.com/x' - ); - expect(r.valid).toEqual(['https://nytimes.com/article-1', 'https://wsj.com/x']); - expect(r.invalid).toContain('Awesome'); - expect(r.invalid).toContain('article:'); - }); - - it('keeps query strings + fragments in canonical form', () => { - const r = parseUrls( - 'https://example.com/a?x=1&y=2#section\nhttps://example.com/a?x=1&y=2#section' - ); - expect(r.valid).toEqual(['https://example.com/a?x=1&y=2#section']); - expect(r.duplicates).toEqual(['https://example.com/a?x=1&y=2#section']); - }); - - it('handles a 50-URL input without choking', () => { - const urls = Array.from({ length: 50 }, (_, i) => `https://example.com/article-${i}`); - const r = parseUrls(urls.join('\n')); - expect(r.valid).toHaveLength(50); - expect(r.invalid).toEqual([]); - expect(r.duplicates).toEqual([]); - }); -}); diff --git a/apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts b/apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts deleted file mode 100644 index b6f168070..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/stores/tags.svelte.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Articles Tags — junction ops into the global tag system. - * - * Mirrors notes/stores/tags.svelte.ts, calendar/stores/tags.svelte.ts, - * contacts/stores/tags.svelte.ts — tag names/colors live in globalTags - * (appId: 'tags'), articles just holds the junction rows. - */ - -import { db } from '$lib/data/database'; -import { createTagLinkOps } from '@mana/shared-stores'; - -export { - tagMutations, - useAllTags, - getTagById, - getTagsByIds, - getTagColor, -} from '@mana/shared-stores'; - -export const articleTagOps = createTagLinkOps({ - table: () => db.table('articleTags'), - entityIdField: 'articleId', -}); diff --git a/apps/mana/apps/web/src/lib/modules/articles/tab-context.ts b/apps/mana/apps/web/src/lib/modules/articles/tab-context.ts deleted file mode 100644 index 1df9efa12..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/tab-context.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Cross-tab context for the articles module. - * - * ArticlesTabShell provides this to let deeply-nested section components - * (HomeSectionFrisch's "Alle ungelesenen →" button etc.) switch the - * active tab without navigating away from the current URL — critical - * when the articles module is rendered inside a Workbench card where a - * `goto(...)` would kick the user out of the card entirely. - * - * Consumers: call `getArticlesTabContext()` and, if non-null, use - * `.switchTo(tab)` in place of a `goto(/articles/...)`. Falling through - * to goto when no context exists is the explicit escape hatch for when - * the component is rendered standalone (e.g. old tests). - */ - -import { getContext } from 'svelte'; - -export type ArticlesTabId = 'list' | 'highlights' | 'favorites' | 'stats'; - -export interface ArticlesTabContext { - switchTo(tab: ArticlesTabId): void; -} - -export const ARTICLES_TAB_CONTEXT = Symbol('articles-tab-context'); - -export function getArticlesTabContext(): ArticlesTabContext | null { - return getContext(ARTICLES_TAB_CONTEXT) ?? null; -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/tools.ts b/apps/mana/apps/web/src/lib/modules/articles/tools.ts deleted file mode 100644 index 6ff321e8c..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/tools.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Articles AI Tools — LLM-accessible operations for the articles module. - * - * Catalog entries live in `@mana/shared-ai/src/tools/schemas.ts` and drive - * the policy layer + server planner automatically; this file contributes - * the execute-side glue. - * - * list_articles auto Read-only listing for agent context. - * save_article propose URL → Readability → encrypted save. - * Legacy `save_news_article` kept as - * alias in `modules/news/tools.ts`. - * archive_article propose Flips status → 'archived'. - * tag_article propose Creates (or reuses) a global tag by - * name and links it to the article. - * add_article_highlight propose Persists a highlight anchored to the - * first verbatim occurrence of `text` - * in the article's plain content. Fails - * gracefully if the snippet isn't found. - */ - -import { decryptRecords } from '$lib/data/crypto'; -import { scopedForModule, scopedGet } from '$lib/data/scope'; -import { tagMutations, useAllTags } from '@mana/shared-stores'; -import type { ModuleTool } from '$lib/data/tools/types'; -import { articlesStore } from './stores/articles.svelte'; -import { articleImportsStore, parseUrls } from './stores/imports.svelte'; -import { highlightsStore } from './stores/highlights.svelte'; -import { articleTagOps } from './stores/tags.svelte'; -import { toArticle } from './queries'; -import type { HighlightColor, LocalArticle, ArticleStatus } from './types'; - -const DEFAULT_LIST_LIMIT = 30; -const MAX_LIST_LIMIT = 100; -const MIN_HIGHLIGHT_TEXT = 10; -const MAX_HIGHLIGHT_TEXT = 500; - -export const articlesTools: ModuleTool[] = [ - { - name: 'list_articles', - module: 'articles', - description: - 'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.', - parameters: [ - { - name: 'status', - type: 'string', - description: - 'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).', - required: false, - enum: ['unread', 'reading', 'finished', 'archived', 'all'], - }, - { - name: 'limit', - type: 'number', - description: `Maximale Anzahl (Standard ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT})`, - required: false, - }, - { - name: 'query', - type: 'string', - description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle', - required: false, - }, - ], - async execute(params) { - const limit = Math.min( - Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1), - MAX_LIST_LIMIT - ); - const statusFilter = typeof params.status === 'string' ? params.status : ''; - const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : ''; - - const locals = await scopedForModule('articles', 'articles').toArray(); - const visible = locals.filter((a) => { - if (a.deletedAt) return false; - if (statusFilter === 'all') return true; - if (statusFilter === '' || !statusFilter) return a.status !== 'archived'; - return a.status === statusFilter; - }); - const decrypted = await decryptRecords('articles', visible); - - const matches = query - ? decrypted.filter( - (a) => - a.title.toLowerCase().includes(query) || - (a.author?.toLowerCase().includes(query) ?? false) || - (a.siteName?.toLowerCase().includes(query) ?? false) - ) - : decrypted; - - const rows = matches - .sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? '')) - .slice(0, limit) - .map((a) => ({ - id: a.id, - title: a.title, - siteName: a.siteName, - status: a.status, - readingTimeMinutes: a.readingTimeMinutes, - url: a.originalUrl, - savedAt: a.savedAt, - })); - - return { - success: true, - message: `${rows.length} Artikel gefunden`, - data: { articles: rows, total: matches.length }, - }; - }, - }, - - { - name: 'save_article', - module: 'articles', - description: - 'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.', - parameters: [ - { name: 'url', type: 'string', description: 'Die Artikel-URL', required: true }, - { - name: 'title', - type: 'string', - description: 'Anzeigetitel für den Approval-Dialog (informativ)', - required: false, - }, - { - name: 'reason', - type: 'string', - description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist', - required: false, - }, - ], - async execute(params) { - const url = String(params.url ?? '').trim(); - if (!url) return { success: false, message: 'URL fehlt' }; - const { article, duplicate } = await articlesStore.saveFromUrl(url); - return { - success: true, - message: duplicate - ? `Artikel bereits gespeichert: ${article.title}` - : `Artikel gespeichert: ${article.title}`, - data: { articleId: article.id, title: article.title, duplicate }, - }; - }, - }, - - { - name: 'archive_article', - module: 'articles', - description: 'Verschiebt einen Artikel ins Archiv.', - parameters: [ - { - name: 'articleId', - type: 'string', - description: 'ID des Artikels (aus list_articles)', - required: true, - }, - ], - async execute(params) { - const id = String(params.articleId ?? '').trim(); - if (!id) return { success: false, message: 'articleId fehlt' }; - const existing = await scopedGet('articles', id); - if (!existing || existing.deletedAt) { - return { success: false, message: `Kein Artikel mit id ${id}` }; - } - await articlesStore.setStatus(id, 'archived' satisfies ArticleStatus); - return { success: true, message: 'Artikel archiviert', data: { articleId: id } }; - }, - }, - - { - name: 'tag_article', - module: 'articles', - description: - 'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.', - parameters: [ - { - name: 'articleId', - type: 'string', - description: 'ID des Artikels (aus list_articles)', - required: true, - }, - { - name: 'tagName', - type: 'string', - description: 'Tag-Name (z.B. "KI", "lesen bald")', - required: true, - }, - ], - async execute(params) { - const id = String(params.articleId ?? '').trim(); - const rawName = String(params.tagName ?? '').trim(); - if (!id) return { success: false, message: 'articleId fehlt' }; - if (!rawName) return { success: false, message: 'tagName fehlt' }; - const name = rawName.slice(0, 60); - - const existing = await scopedGet('articles', id); - if (!existing || existing.deletedAt) { - return { success: false, message: `Kein Artikel mit id ${id}` }; - } - - // useAllTags().value works even outside a Svelte reactive scope — - // it returns the current in-memory snapshot. Match by lower-case - // name so "KI" and "ki" dedupe. - const pool = useAllTags().value; - const needle = name.toLowerCase(); - let tag = pool.find((t) => t.name.toLowerCase() === needle); - if (!tag) { - tag = await tagMutations.createTag({ name }); - } - - await articleTagOps.addTag(id, tag.id); - return { - success: true, - message: `Tag „${tag.name}" gesetzt`, - data: { - articleId: id, - tagId: tag.id, - tagName: tag.name, - created: !pool.some((t) => t.id === tag!.id), - }, - }; - }, - }, - - { - name: 'add_article_highlight', - module: 'articles', - description: - 'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen.', - parameters: [ - { - name: 'articleId', - type: 'string', - description: 'ID des Artikels (aus list_articles)', - required: true, - }, - { - name: 'text', - type: 'string', - description: 'Wörtliche Textstelle die markiert werden soll (10–500 Zeichen)', - required: true, - }, - { - name: 'color', - type: 'string', - description: 'Highlight-Farbe', - required: false, - enum: ['yellow', 'green', 'blue', 'pink'], - }, - { - name: 'note', - type: 'string', - description: 'Optionale Notiz zum Highlight', - required: false, - }, - ], - async execute(params) { - const id = String(params.articleId ?? '').trim(); - const text = String(params.text ?? '').trim(); - const color = (params.color as HighlightColor | undefined) ?? 'yellow'; - const note = typeof params.note === 'string' ? params.note.trim() || null : null; - - if (!id) return { success: false, message: 'articleId fehlt' }; - if (text.length < MIN_HIGHLIGHT_TEXT) { - return { success: false, message: `Text zu kurz (min ${MIN_HIGHLIGHT_TEXT} Zeichen)` }; - } - if (text.length > MAX_HIGHLIGHT_TEXT) { - return { success: false, message: `Text zu lang (max ${MAX_HIGHLIGHT_TEXT} Zeichen)` }; - } - - const existing = await scopedGet('articles', id); - if (!existing || existing.deletedAt) { - return { success: false, message: `Kein Artikel mit id ${id}` }; - } - const [decrypted] = await decryptRecords('articles', [existing]); - if (!decrypted) return { success: false, message: 'Entschlüsselung fehlgeschlagen' }; - const article = toArticle(decrypted); - - // Snap to the first verbatim occurrence of the snippet in the - // Readability-extracted plain content. If the AI is hallucinating - // (or the article was re-extracted and the text shifted) we bail - // instead of persisting an orphan highlight. - const startOffset = article.content.indexOf(text); - if (startOffset < 0) { - return { success: false, message: 'Textstelle nicht im Artikel gefunden' }; - } - const endOffset = startOffset + text.length; - const contextBefore = - article.content.slice(Math.max(0, startOffset - 40), startOffset) || null; - const contextAfter = article.content.slice(endOffset, endOffset + 40) || null; - - const highlight = await highlightsStore.addHighlight({ - articleId: id, - text, - color, - note, - startOffset, - endOffset, - contextBefore, - contextAfter, - }); - return { - success: true, - message: 'Highlight gesetzt', - data: { highlightId: highlight.id, articleId: id }, - }; - }, - }, - - // ─── Bulk-Import (docs/plans/articles-bulk-import.md) ─── - { - name: 'import_articles_from_urls', - module: 'articles', - description: - 'Erstellt einen Bulk-Import-Job für mehrere URLs. Server extrahiert sie nacheinander im Hintergrund. Auto-policy: kein Approval pro Artikel, der Job ist ein einziger Task.', - parameters: [ - { - name: 'urls', - type: 'array', - description: 'Liste der Artikel-URLs (max 50)', - required: true, - }, - ], - execute: async (params: Record) => { - const rawUrls = params.urls; - if (!Array.isArray(rawUrls) || rawUrls.length === 0) { - return { success: false, message: 'urls muss ein nicht-leeres Array sein' }; - } - if (rawUrls.length > 50) { - return { - success: false, - message: 'Maximal 50 URLs pro Job. Splitte in mehrere Aufrufe.', - }; - } - const blob = rawUrls.filter((u): u is string => typeof u === 'string').join('\n'); - const parsed = parseUrls(blob); - if (parsed.valid.length === 0) { - return { - success: false, - message: `Keine gültigen URLs (alle ${rawUrls.length} verworfen)`, - }; - } - const jobId = await articleImportsStore.createJob(parsed.valid); - return { - success: true, - message: `Bulk-Import gestartet (${parsed.valid.length} URLs${parsed.duplicates.length ? `, ${parsed.duplicates.length} Duplikate übersprungen` : ''}${parsed.invalid.length ? `, ${parsed.invalid.length} ungültig` : ''})`, - data: { - jobId, - accepted: parsed.valid.length, - duplicates: parsed.duplicates.length, - invalid: parsed.invalid.length, - }, - }; - }, - }, -]; diff --git a/apps/mana/apps/web/src/lib/modules/articles/types.ts b/apps/mana/apps/web/src/lib/modules/articles/types.ts deleted file mode 100644 index 2dc0b5010..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/types.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Articles module — Pocket-style read-it-later. - * - * Six 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'). - * - * articleImportJobs — Bulk-Import job header. Plaintext: counters, - * status, lease metadata. See - * docs/plans/articles-bulk-import.md. - * articleImportItems — One row per URL in a bulk job. URL stays - * plaintext (server-worker reads it without - * master-key access — same rationale as - * articles.originalUrl). State machine: - * pending → extracting → extracted → - * (saved | duplicate | consent-wall | error | - * cancelled). - * articleExtractPickup — Server-write inbox: the worker drops the - * extracted payload here, the client picks it - * up, runs encryptRecord + articleTable.add, - * then deletes the row. Plaintext by necessity - * (server has no master key); empty in steady - * state. - */ - -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; -} - -// ─── Bulk Import (docs/plans/articles-bulk-import.md) ───── - -/** - * Job status — drives the index list filter and the JobDetailView's - * action bar. `running` is the only state where the worker actively - * pulls items; `paused` lets the user stop progress without losing the - * remaining queue, `cancelled` is a hard stop with all pending items - * flipped to terminal `cancelled`. - */ -export type ArticleImportJobStatus = 'queued' | 'running' | 'paused' | 'done' | 'cancelled'; - -/** - * Item state machine. Server-side transitions: pending → extracting → - * extracted (worker has dropped a pickup row). Client-side transitions: - * extracted → saved | duplicate | consent-wall (pickup-consumer - * applied the result). Both sides may transition to error (worker after - * 3 retries, client if encryptRecord/add fails). cancelled is terminal - * and only set when the parent job is cancelled before the item ran. - */ -export type ArticleImportItemState = - | 'pending' - | 'extracting' - | 'extracted' - | 'saved' - | 'duplicate' - | 'consent-wall' - | 'error' - | 'cancelled'; - -export interface LocalArticleImportJob extends BaseRecord { - totalUrls: number; - status: ArticleImportJobStatus; - startedAt: string | null; - finishedAt: string | null; - /** Counters mirror the per-item terminal states. Cache for fast list - * rendering — truth lives in the item rows. Worker stamps these on - * each transition. */ - savedCount: number; - duplicateCount: number; - errorCount: number; - warningCount: number; - // NOTE: `leasedBy` + `leasedUntil` were defined on the original - // schema as a soft-lease handshake but the worker uses - // pg_try_advisory_xact_lock instead, so they were never written. - // Removed in Dexie v58 — see database.ts. -} - -export interface LocalArticleImportItem extends BaseRecord { - jobId: string; - /** Original position in the user-provided URL list. Drives display order. */ - idx: number; - /** Plaintext — server worker reads it without master-key access. Same - * rationale as articles.originalUrl / newsArticles.originalUrl. */ - url: string; - state: ArticleImportItemState; - /** Pointer into `articles` table once the article is persisted. */ - articleId: string | null; - warning: 'probable_consent_wall' | null; - /** Plaintext technical error message ("502 Bad Gateway", "timeout"). */ - error: string | null; - attempts: number; - lastAttemptAt: string | null; -} - -/** - * Server → client handoff. Lives only between worker-write and - * pickup-consumer-read. Empty in steady state. - */ -export interface LocalArticleExtractPickup extends BaseRecord { - itemId: string; - /** The server's ExtractedArticle JSON, plaintext. Mirrors the shape - * in articles/api.ts but lives here as a structural type so the - * database layer doesn't import the API client. */ - payload: { - originalUrl: string; - title: string; - excerpt: string | null; - content: string; - htmlContent: string; - author: string | null; - siteName: string | null; - wordCount: number; - readingTimeMinutes: number; - warning?: 'probable_consent_wall'; - }; -} - -// Public DTOs used by views (livequery converters strip the BaseRecord -// internals + map state to display-friendly shapes). - -export interface ArticleImportJob { - id: string; - totalUrls: number; - status: ArticleImportJobStatus; - startedAt: string | null; - finishedAt: string | null; - savedCount: number; - duplicateCount: number; - errorCount: number; - warningCount: number; - createdAt: string; - updatedAt: string; -} - -export interface ArticleImportItem { - id: string; - jobId: string; - idx: number; - url: string; - state: ArticleImportItemState; - articleId: string | null; - warning: 'probable_consent_wall' | null; - error: string | null; - attempts: number; - lastAttemptAt: string | null; - createdAt: string; - updatedAt: string; -} diff --git a/apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte deleted file mode 100644 index 4b54d9786..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/views/DetailView.svelte +++ /dev/null @@ -1,547 +0,0 @@ - - - - - {$_('articles.detail_view.page_title_html', { - values: { title: article?.title ?? $_('articles.detail_view.untitled_fallback') }, - })} - - -
- {#if article$.loading} -

{$_('articles.detail_view.loading')}

- {:else if !article} -
-

{$_('articles.detail_view.not_found')}

- -
- {:else} -
-

{article.title}

-
- {#if article.siteName}{article.siteName}{/if} - {#if article.author}· {article.author}{/if} - {#if article.readingTimeMinutes}· - {$_('articles.detail_view.meta_reading_minutes', { - values: { n: article.readingTimeMinutes }, - })}{/if} - {#if article.wordCount}· - {$_('articles.detail_view.meta_word_count', { values: { n: article.wordCount } })}{/if} -
-
- -
-
- - (readerScroller = el)} - /> - - - -
- - - - -
- - - - - - - -
- - - -
- - - - - ↗ - - -
-
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/views/HighlightsView.svelte b/apps/mana/apps/web/src/lib/modules/articles/views/HighlightsView.svelte deleted file mode 100644 index b5a890287..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/views/HighlightsView.svelte +++ /dev/null @@ -1,257 +0,0 @@ - - - - - Highlights — Artikel — Mana - - -
- {#if rows.length > 0} -
- - -
- {/if} - - {#if rows$.loading} -

Lädt…

- {:else if groups.length === 0} -
-

Noch keine Highlights.

-

- Markier eine Textstelle in einem gespeicherten Artikel — sie erscheint hier automatisch. -

-
- {:else} -
- {#each groups as group (group.articleId)} -
-
- -
-
    - {#each group.highlights as h (h.id)} -
  • - - {#if h.note} -

    {h.note}

    - {/if} -
  • - {/each} -
-
- {/each} -
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/views/StatsView.svelte b/apps/mana/apps/web/src/lib/modules/articles/views/StatsView.svelte deleted file mode 100644 index b9e09ff22..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/views/StatsView.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - - -
- {#if articles$.loading} -

Lädt…

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

Noch keine Artikel gespeichert — Statistiken erscheinen sobald du anfängst.

- {:else} - -
- {stats.totalHighlights} - markierte Textstellen insgesamt -
- - {#if stats.archived > 0} - - {/if} - {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/articles/widgets/ArticlesUnreadWidget.svelte b/apps/mana/apps/web/src/lib/modules/articles/widgets/ArticlesUnreadWidget.svelte deleted file mode 100644 index 9ca1459c6..000000000 --- a/apps/mana/apps/web/src/lib/modules/articles/widgets/ArticlesUnreadWidget.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -
-
-

- - Artikel -

- Alle → -
- - {#if articles$.loading} -
- {#each Array(3) as _} -
- {/each} -
- {:else if articles.length === 0} -
-

Noch keine Artikel gespeichert.

- - Erste URL speichern - -
- {:else if topUnread.length === 0} -
-

Alles gelesen — stark.

- - Leseliste öffnen - -
- {:else} - -
- {stats.unread} ungelesen · {stats.savedThisWeek} diese Woche gespeichert -
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte b/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte index bf9f146d4..c95f215da 100644 --- a/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte @@ -10,10 +10,8 @@ workbench card and full page keeps results. -->