chore(mana): news aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run

Reader-Surface ist nach Pageta (pageta.mana.how + pageta-api.mana.how)
umgezogen, das seit 2026-05-16 live ist und mehr Features bietet als
das alte managarten-news-Modul:
- Highlights (4 Farben, plain-text-offsets, Kontext)
- Reading-Progress + User-Note pro Artikel
- Bulk-Import (200 URLs/Job mit Worker)
- 5 MCP-Tools (save/list/archive/tag/highlight)
- Reading-Status-Enum (unread/reading/finished/archived) statt Boolean

Was Pageta NICHT hat: Categories mit Color+Icon — Pageta verwendet
freie String-Tags statt visuelle Folders. Bewusste Design-Entscheidung
in Pageta.

Daten-Migration: KEIN automatisches Skript. User mit gespeicherten
Artikeln im managarten-newsArticles müssen ihre Liste in Pageta neu
aufbauen (oder Bulk-Import via /api/v1/imports verwenden).

Gelöscht / abgebaut:
- Module: apps/mana/.../modules/news + Routen + Locales
- apps/articles/migrations/from-news.ts (one-off-Migration nach
  articles-Modul, Sentinel-gated, abgeschlossen) + Call in
  (app)/+layout.svelte
- apps/api/src/modules/news + MCP-Executor save_news_article
- shared-branding: APP_ICONS.news + MANA_APPS news-Entry
- shared-ai/tools/schemas save_news_article
- shared-types/spaces: 3 'news'-Einträge in Space-Modul-Listen
- Cross-Module: news-research/ListView + (app)/news-research/+page.svelte
  hatten den preferencesStore + usePreferences vom news-Modul für
  Custom-Feed-Pinning — Pin-UI entfernt (Custom-Feeds sind jetzt
  Pageta-Verantwortung)
- Dashboard: 'news-unread' Widget + NewsUnreadWidget-Import
- Registries: app-registry/apps.ts (News registerApp + Newspaper icon +
  Header), categories, help-content, module-registry, data/tools/init
- i18n: news in apps/{de,en,es,fr,it}.json

Was BLEIBT:
- `news-research` Modul + `apps/api/src/modules/news-research/` —
  RSS-Discovery + Search-Funktion bleibt im managarten als
  Recherche-Tool für andere Module
- `mana-news-pool` Plattform-Service (Code/mana/services/) — wird von
  news-research + Pageta-Standalone konsumiert
- shared-ai `research_news` Tool

Dexie v65 Migration:
- droppt newsArticles, newsCategories, newsPreferences, newsReactions,
  newsCachedFeed

mana-web svelte-check 0/0, snapshot test 10/10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-18 15:03:56 +02:00
parent fc616688e3
commit 609f662538
54 changed files with 19 additions and 4766 deletions

View file

@ -34,7 +34,6 @@ import { profileRoutes } from './modules/profile/routes';
import { storageRoutes } from './modules/storage/routes';
import { todoRoutes } from './modules/todo/routes';
import { guidesRoutes } from './modules/guides/routes';
import { newsRoutes } from './modules/news/routes';
import { newsResearchRoutes } from './modules/news-research/routes';
import { articlesRoutes } from './modules/articles/routes';
import { startArticleImportWorker } from './modules/articles/import-worker';
@ -130,7 +129,6 @@ app.route('/api/v1/profile', profileRoutes);
app.route('/api/v1/storage', storageRoutes);
app.route('/api/v1/todo', todoRoutes);
app.route('/api/v1/guides', guidesRoutes);
app.route('/api/v1/news', newsRoutes);
app.route('/api/v1/news-research', newsResearchRoutes);
app.route('/api/v1/articles', articlesRoutes);
app.route('/api/v1/traces', tracesRoutes);

View file

@ -616,33 +616,6 @@ register('log_habit', async (args, userId) => {
return ok(`Habit geloggt.`);
});
// ── News tools ────────────────────────────────────────────────
register('save_news_article', async (args, userId) => {
const articleId = crypto.randomUUID();
const now = nowIso();
const data = {
id: articleId,
userId,
url: args.url as string,
title: (args.title as string) ?? '',
summary: (args.summary as string) ?? '',
savedAt: now,
createdAt: now,
updatedAt: now,
};
await writeRecord(
userId,
'news',
'savedArticles',
articleId,
'insert',
data,
fieldTs(Object.keys(data))
);
return ok(`Artikel gespeichert: "${args.title || args.url}"`, { id: articleId });
});
// ── Entry point ────────────────────────────────────────────────
/**

View file

@ -1,94 +0,0 @@
/**
* News module Reads the curated article pool + extracts ad-hoc URLs.
*
* Pool population: handled by the Plattform-Service `mana-news-pool`
* (Port 3079, eigene DB `mana_news_pool`, Schema `pool.curated_articles`).
* Cutover am 2026-05-17: ehemals direkter Raw-SQL-Read auf
* `mana_platform.news.curated_articles` aus dem `news-ingester:3066`-
* Container. Hier nur noch HTTP-Proxy auf den Plattform-Pool.
*
* Saved articles (die persönliche Reading-List eines Users) leben
* weiterhin client-side in der IndexedDB der unified Mana-App und
* syncen via mana-sync; dieses Modul sieht sie nicht.
*/
import { Hono } from 'hono';
import { extractFromUrl } from '@mana/shared-rss';
const POOL_URL = process.env.MANA_NEWS_POOL_URL ?? 'http://mana-news-pool:3079';
const POOL_KEY = process.env.MANA_SERVICE_KEY ?? '';
// ─── Routes ─────────────────────────────────────────────────
const routes = new Hono();
// ─── Feed (proxy on mana-news-pool) ────────────────────────
//
// Query params:
// topics — comma-separated topic slugs (tech,wissenschaft,…)
// lang — 'de' | 'en' | 'all' (default 'all')
// since — ISO timestamp
// limit — default 50, max 200
// offset — default 0
routes.get('/feed', async (c) => {
const passthrough = ['topics', 'lang', 'since', 'limit', 'offset'] as const;
const url = new URL(`${POOL_URL}/feed`);
for (const k of passthrough) {
const v = c.req.query(k);
if (v) url.searchParams.set(k, v);
}
try {
const res = await fetch(url.toString(), {
headers: { 'X-Service-Key': POOL_KEY },
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) {
console.warn(`[news] pool ${url}${res.status}`);
return c.json([] as Record<string, unknown>[]);
}
const data = (await res.json()) as Record<string, unknown>[];
return c.json(data);
} catch (err) {
console.warn('[news] pool fetch failed', err);
return c.json([] as Record<string, unknown>[]);
}
});
// ─── Extract (content extraction for user-pasted URLs) ─────
routes.post('/extract/preview', async (c) => {
const { url } = await c.req.json<{ url: string }>();
if (!url) return c.json({ error: 'URL is required' }, 400);
const article = await extractFromUrl(url);
if (!article) return c.json({ error: 'Extraction failed' }, 502);
return c.json(article);
});
routes.post('/extract/save', async (c) => {
const { url } = await c.req.json<{ url: string }>();
if (!url) return c.json({ error: 'URL is required' }, 400);
const extracted = await extractFromUrl(url);
if (!extracted) return c.json({ error: 'Extraction failed' }, 502);
return c.json({
id: crypto.randomUUID(),
type: 'saved',
sourceOrigin: 'user_saved',
originalUrl: url,
title: extracted.title,
content: extracted.content,
htmlContent: extracted.htmlContent,
excerpt: extracted.excerpt,
author: extracted.byline,
siteName: extracted.siteName,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
isArchived: false,
});
});
export { routes as newsRoutes };

View file

@ -36,7 +36,6 @@ import {
Calculator,
Lightning,
PencilRuler,
Newspaper,
Person,
GenderFemale,
CalendarStar,
@ -96,7 +95,7 @@ import {
// Daily-use: habits · notes · journal · myday · drink ·
// mood · sleep · activity · times · finance
// Knowledge: chat · kontext · cards · quiz · guides ·
// news · news-research · research-lab · articles ·
// news-research · research-lab · articles ·
// library · writing · comic · presi
// Body & life: body · meditate · stretch · period ·
// dreams · firsts · lasts · habits · recipes
@ -755,27 +754,6 @@ registerApp({
paramKey: 'eventId',
});
registerApp({
id: 'news',
name: 'News',
color: '#10B981',
icon: Newspaper,
views: {
list: { load: () => import('$lib/modules/news/ListView.svelte') },
},
contextMenuActions: [
{
id: 'open-feed',
label: 'Feed öffnen',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'news', action: 'open' } })
),
},
],
});
registerApp({
id: 'news-research',
name: 'News Research',

View file

@ -816,21 +816,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
],
tips: ['Jedes Modul hat zusätzlich sein eigenes ?-Icon mit kontextueller Hilfe'],
},
news: {
description:
'Kuratierter News-Feed aus vertrauenswürdigen Quellen. 7 Themen (Tech, Wissenschaft, Weltgeschehen, Wirtschaft, Kultur, Gesundheit, Politik). Alle 15 Minuten aktualisiert.',
features: [
'Thema-Filter + Sprachauswahl (DE/EN)',
'Eigene Abos über Custom-Feeds',
'Artikel in Leseliste speichern (verschlüsselt)',
'AI-Tool: `save_news_article`',
'Schwester-Modul: /research-lab für tiefere Recherche',
],
tips: [
'Ziehe einen Artikel auf Notizen um eine Zusammenfassung zu erstellen',
'Für Nicht-RSS-Quellen: Research Lab mit Deep-Research nutzen',
],
},
profile: {
description:
'Dein persönliches Profil — der Kontext-Doc, den alle AI-Agents als Basis nutzen. Wer bist du, was willst du, was sollte Mana über dich wissen.',

View file

@ -27,7 +27,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 NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.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';
@ -56,7 +55,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'day-timeline': DayTimelineWidget,
'activity-feed': ActivityFeedWidget,
period: PeriodWidget,
'news-unread': NewsUnreadWidget,
'articles-unread': ArticlesUnreadWidget,
'body-stats': BodyStatsWidget,
'invoices-open': InvoicesOpenWidget,

View file

@ -1575,6 +1575,22 @@ db.version(64).stores({
memoSpaces: null,
});
// v65 — News module retirement (2026-05-18).
// News-Reader-Surface ist nach Pageta (pageta.mana.how) umgezogen,
// das mit eigener Postgres-DB + eigenem Article-Store läuft. Der
// kuratierte Pool selbst lebt im Plattform-Service `mana-news-pool`
// und wird via news-research (in managarten) + Pageta-Reader (extern)
// weiterhin genutzt.
// dropped: newsArticles, newsCategories, newsPreferences, newsReactions,
// newsCachedFeed (war NICHT synced, nur local-mirror).
db.version(65).stores({
newsArticles: null,
newsCategories: null,
newsPreferences: null,
newsReactions: null,
newsCachedFeed: null,
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -57,8 +57,6 @@ const INTERNAL_TABLES = new Set([
// which writes via its module's sync path — proposals themselves never
// leave the device.
'pendingProposals',
// Local-only news feed cache.
'newsCachedFeed',
]);
// ─── Dexie tables that survive in the schema for backwards-compat with
@ -232,7 +230,6 @@ describe('module-registry — snapshot', () => {
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],
playground: ['playgroundSnippets', 'playgroundConversations', 'playgroundMessages'],
news: ['newsArticles', 'newsCategories', 'newsPreferences', 'newsReactions'],
body: [
'bodyExercises',
'bodyRoutines',
@ -315,10 +312,6 @@ describe('module-registry — snapshot', () => {
playgroundSnippets: 'snippets',
playgroundConversations: 'conversations',
playgroundMessages: 'messages',
newsArticles: 'articles',
newsCategories: 'categories',
newsPreferences: 'preferences',
newsReactions: 'reactions',
quizQuestions: 'questions',
quizAttempts: 'attempts',
articleHighlights: 'highlights',

View file

@ -77,7 +77,6 @@ import { eventsModuleConfig } from '$lib/modules/events/module.config';
import { financeModuleConfig } from '$lib/modules/finance/module.config';
import { placesModuleConfig } from '$lib/modules/places/module.config';
import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
import { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
import { lastsModuleConfig } from '$lib/modules/lasts/module.config';
@ -133,7 +132,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
financeModuleConfig,
placesModuleConfig,
playgroundModuleConfig,
newsModuleConfig,
bodyModuleConfig,
firstsModuleConfig,
lastsModuleConfig,

View file

@ -26,7 +26,6 @@ import { firstsTools } from '$lib/modules/firsts/tools';
import { lastsTools } from '$lib/modules/lasts/tools';
import { guidesTools } from '$lib/modules/guides/tools';
import { inventoryTools } from '$lib/modules/inventory/tools';
import { newsTools } from '$lib/modules/news/tools';
import { newsResearchTools } from '$lib/modules/news-research/tools';
import { articlesTools } from '$lib/modules/articles/tools';
import { recipesTools } from '$lib/modules/recipes/tools';
@ -74,7 +73,6 @@ export function initTools(): void {
registerTools(lastsTools);
registerTools(guidesTools);
registerTools(inventoryTools);
registerTools(newsTools);
registerTools(newsResearchTools);
registerTools(articlesTools);
registerTools(recipesTools);

View file

@ -30,7 +30,6 @@
"automations": "Automationen",
"playground": "Playground",
"kontext": "Web-Kontext",
"news": "News",
"news-research": "News-Recherche",
"articles": "Artikel",
"research-lab": "Recherche-Labor",

View file

@ -30,7 +30,6 @@
"automations": "Automations",
"playground": "Playground",
"kontext": "Web Context",
"news": "News",
"news-research": "News Research",
"articles": "Articles",
"research-lab": "Research Lab",

View file

@ -30,7 +30,6 @@
"automations": "Automatizaciones",
"playground": "Playground",
"kontext": "Contexto web",
"news": "Noticias",
"news-research": "Investigación de noticias",
"articles": "Artículos",
"research-lab": "Laboratorio de investigación",

View file

@ -30,7 +30,6 @@
"automations": "Automations",
"playground": "Playground",
"kontext": "Contexte web",
"news": "Actualités",
"news-research": "Recherche d'actus",
"articles": "Articles",
"research-lab": "Laboratoire de recherche",

View file

@ -30,7 +30,6 @@
"automations": "Automazioni",
"playground": "Playground",
"kontext": "Contesto Web",
"news": "News",
"news-research": "Ricerca News",
"articles": "Articoli",
"research-lab": "Laboratorio di ricerca",

View file

@ -1,149 +0,0 @@
{
"app": {
"name": "News",
"tagline": "Dein kuratierter Newsfeed"
},
"feed": {
"title": "News",
"articles": "{count} Artikel",
"refresh": "Neu laden",
"loading": "Lade Artikel…",
"empty": "Keine neuen Artikel zu deinen Themen.",
"emptyHint": "Probiere ↻ oder erweitere deine Themen.",
"loadError": "Fehler beim Laden",
"savedLink": "Gespeichert",
"settingsLink": "Einstellungen",
"openArticleAria": "Artikel öffnen",
"savedBadgeTitle": "In deiner Leseliste",
"savedBadgeText": "❤️ gespeichert",
"readingTimeMin": "{n} min"
},
"reactions": {
"interested": "Interessiert",
"interestedSaved": "Gespeichert",
"interestedTitle": "Speichern + mehr davon",
"interestedSavedTitle": "Schon gespeichert — nochmal klicken bestätigt nur",
"notInterested": "Nicht für mich",
"notInterestedTitle": "Weniger davon",
"blockSource": "Quelle ausblenden",
"blockSourceLabel": "{source} ausblenden"
},
"onboarding": {
"welcome": "Willkommen beim News Hub",
"intro": "In drei Schritten baust du dir deinen persönlichen Newsfeed.",
"stepTopics": "1. Themen",
"stepLanguage": "2. Sprache",
"stepSources": "3. Quellen",
"topicsTitle": "Was interessiert dich?",
"topicsHint": "Wähle mindestens zwei Themen.",
"languageTitle": "In welchen Sprachen liest du?",
"sourcesTitle": "Quellen aus deinen Themen",
"sourcesHint": "Tippe eine Quelle an um sie auszublenden. Du kannst das jederzeit ändern.",
"back": "Zurück",
"next": "Weiter",
"finish": "Fertig",
"finishLoading": "Speichere…"
},
"reader": {
"back": "Zurück",
"smaller": "Kleiner",
"larger": "Größer",
"save": "Speichern",
"loading": "Lade…",
"notFound": "Artikel nicht gefunden.",
"backToFeed": "Zurück zum Feed",
"openOriginal": "Original öffnen"
},
"saved": {
"title": "Gespeichert",
"backToFeed": "Feed",
"addUrl": "URL hinzufügen",
"tabUnread": "Ungelesen",
"tabFavorites": "Favoriten",
"tabArchive": "Archiv",
"emptyUnread": "Keine ungelesenen Artikel.",
"emptyUnreadHint": "Reagiere im Feed mit „Interessiert\" um Artikel hier zu sammeln.",
"emptyFavorites": "Noch keine Favoriten.",
"emptyArchive": "Archiv ist leer.",
"badgeOwn": "eigen",
"actionFavorite": "Favorit",
"actionArchive": "Archivieren",
"actionUnarchive": "Wiederherstellen",
"actionDelete": "Löschen",
"actionCategory": "Kategorie",
"categoryNone": "— Keine —"
},
"categories": {
"all": "Alle",
"manage": "Kategorien verwalten",
"placeholder": "Neue Kategorie…",
"add": "Hinzufügen",
"empty": "Noch keine Kategorien. Erstelle eine oben.",
"rename": "umbenennen",
"delete": "löschen",
"deleteConfirm": "Kategorie löschen? Artikel bleiben erhalten."
},
"add": {
"title": "Artikel speichern",
"hint": "Füge eine URL ein. Wir extrahieren den Volltext (Mozilla Readability) und legen ihn in deine verschlüsselte Leseliste.",
"backLink": "Gespeichert",
"placeholder": "https://…",
"submit": "Speichern",
"loading": "Lade…",
"errorGeneric": "Speichern fehlgeschlagen"
},
"preferences": {
"title": "News-Einstellungen",
"page_title_html": "News-Einstellungen — Mana",
"subtitle": "Themen · Sprachen · Gewichtungen",
"backToFeed": "Feed",
"topicsHeading": "Themen",
"topicsHint": "Welche Themen sollen im Feed auftauchen?",
"languagesHeading": "Sprachen",
"sourcesHeading": "Quellen",
"sourcesHint": "Du blockst aktuell {count} Quellen.",
"sourcesHintHtml": "Du blockst aktuell <strong>{count}</strong> Quellen.",
"sourcesLink": "Quellen verwalten",
"sourcesLinkArrow": "Quellen verwalten →",
"weightsHeading": "Gelernte Gewichtungen",
"weightsHint": "Über Reaktionen lernt der Feed deine Vorlieben: {topics} Themen-Gewichte, {sources} Quellen-Gewichte.",
"weightsReset": "Zurücksetzen",
"weightsResetConfirm": "Alle gelernten Gewichtungen zurücksetzen?",
"onboardingHeading": "Onboarding",
"onboardingHint": "Themen, Sprachen und Quellen neu wählen.",
"onboardingRerun": "Onboarding neu starten"
},
"sources": {
"title": "Quellen",
"backToPreferences": "Einstellungen",
"hint": "{count} blockiert. Tippe auf eine Quelle um sie ein- oder auszublenden.",
"blocked": "blockiert",
"weightTooltip": "Gewicht: {weight}"
},
"topics": {
"tech": "Tech",
"wissenschaft": "Wissenschaft",
"weltgeschehen": "Weltgeschehen",
"wirtschaft": "Wirtschaft",
"kultur": "Kultur",
"gesundheit": "Gesundheit",
"politik": "Politik"
},
"languages": {
"de": "Deutsch",
"en": "English"
},
"widget": {
"title": "News",
"empty": "Keine ungelesenen News.",
"viewAll": "Alle ansehen"
},
"workbench": {
"cta_title": "News Hub einrichten",
"cta_hint": "Wähle Themen, Sprachen und Quellen — danach erscheinen hier deine Artikel.",
"cta_action": "Jetzt einrichten",
"err_short": "Fehler",
"empty_short": "Keine neuen Artikel.",
"open_aria": "Öffnen"
}
}

View file

@ -1,149 +0,0 @@
{
"app": {
"name": "News",
"tagline": "Your curated news feed"
},
"feed": {
"title": "News",
"articles": "{count} articles",
"refresh": "Refresh",
"loading": "Loading articles…",
"empty": "No new articles for your topics.",
"emptyHint": "Try ↻ or pick more topics.",
"loadError": "Loading failed",
"savedLink": "Saved",
"settingsLink": "Settings",
"openArticleAria": "Open article",
"savedBadgeTitle": "In your reading list",
"savedBadgeText": "❤️ saved",
"readingTimeMin": "{n} min"
},
"reactions": {
"interested": "Interested",
"interestedSaved": "Saved",
"interestedTitle": "Save and see more like this",
"interestedSavedTitle": "Already saved — clicking again only confirms",
"notInterested": "Not for me",
"notInterestedTitle": "Show less of this",
"blockSource": "Hide source",
"blockSourceLabel": "Hide {source}"
},
"onboarding": {
"welcome": "Welcome to the News Hub",
"intro": "Three steps and you'll have your personal news feed.",
"stepTopics": "1. Topics",
"stepLanguage": "2. Language",
"stepSources": "3. Sources",
"topicsTitle": "What are you interested in?",
"topicsHint": "Pick at least two topics.",
"languageTitle": "Which languages do you read?",
"sourcesTitle": "Sources for your topics",
"sourcesHint": "Tap a source to hide it. You can change this any time.",
"back": "Back",
"next": "Next",
"finish": "Done",
"finishLoading": "Saving…"
},
"reader": {
"back": "Back",
"smaller": "Smaller",
"larger": "Larger",
"save": "Save",
"loading": "Loading…",
"notFound": "Article not found.",
"backToFeed": "Back to feed",
"openOriginal": "Open original"
},
"saved": {
"title": "Saved",
"backToFeed": "Feed",
"addUrl": "Add URL",
"tabUnread": "Unread",
"tabFavorites": "Favorites",
"tabArchive": "Archive",
"emptyUnread": "No unread articles.",
"emptyUnreadHint": "Tap \"Interested\" in the feed to collect articles here.",
"emptyFavorites": "No favorites yet.",
"emptyArchive": "Archive is empty.",
"badgeOwn": "own",
"actionFavorite": "Favorite",
"actionArchive": "Archive",
"actionUnarchive": "Restore",
"actionDelete": "Delete",
"actionCategory": "Category",
"categoryNone": "— None —"
},
"categories": {
"all": "All",
"manage": "Manage categories",
"placeholder": "New category…",
"add": "Add",
"empty": "No categories yet. Create one above.",
"rename": "rename",
"delete": "delete",
"deleteConfirm": "Delete category? Articles are kept."
},
"add": {
"title": "Save article",
"hint": "Paste a URL. We'll extract the full text (Mozilla Readability) and store it in your encrypted reading list.",
"backLink": "Saved",
"placeholder": "https://…",
"submit": "Save",
"loading": "Loading…",
"errorGeneric": "Save failed"
},
"preferences": {
"title": "News settings",
"page_title_html": "News settings — Mana",
"subtitle": "Topics · Languages · Weights",
"backToFeed": "Feed",
"topicsHeading": "Topics",
"topicsHint": "Which topics should appear in the feed?",
"languagesHeading": "Languages",
"sourcesHeading": "Sources",
"sourcesHint": "You're currently blocking {count} sources.",
"sourcesHintHtml": "You're currently blocking <strong>{count}</strong> sources.",
"sourcesLink": "Manage sources",
"sourcesLinkArrow": "Manage sources →",
"weightsHeading": "Learned weights",
"weightsHint": "From your reactions the feed learns your preferences: {topics} topic weights, {sources} source weights.",
"weightsReset": "Reset",
"weightsResetConfirm": "Reset all learned weights?",
"onboardingHeading": "Onboarding",
"onboardingHint": "Pick topics, languages and sources from scratch.",
"onboardingRerun": "Restart onboarding"
},
"sources": {
"title": "Sources",
"backToPreferences": "Settings",
"hint": "{count} blocked. Tap a source to toggle.",
"blocked": "blocked",
"weightTooltip": "Weight: {weight}"
},
"topics": {
"tech": "Tech",
"wissenschaft": "Science",
"weltgeschehen": "World",
"wirtschaft": "Business",
"kultur": "Culture",
"gesundheit": "Health",
"politik": "Politics"
},
"languages": {
"de": "German",
"en": "English"
},
"widget": {
"title": "News",
"empty": "No unread news.",
"viewAll": "View all"
},
"workbench": {
"cta_title": "Set up the News Hub",
"cta_hint": "Pick topics, languages and sources — after that your articles appear here.",
"cta_action": "Set up now",
"err_short": "Error",
"empty_short": "No new articles.",
"open_aria": "Open"
}
}

View file

@ -1,149 +0,0 @@
{
"app": {
"name": "News",
"tagline": "Tu feed de noticias curado"
},
"feed": {
"title": "Noticias",
"articles": "{count} artículos",
"refresh": "Recargar",
"loading": "Cargando artículos…",
"empty": "No hay artículos nuevos para tus temas.",
"emptyHint": "Prueba ↻ o añade más temas.",
"loadError": "Error al cargar",
"savedLink": "Guardados",
"settingsLink": "Ajustes",
"openArticleAria": "Abrir artículo",
"savedBadgeTitle": "En tu lista de lectura",
"savedBadgeText": "❤️ guardado",
"readingTimeMin": "{n} min"
},
"reactions": {
"interested": "Me interesa",
"interestedSaved": "Guardado",
"interestedTitle": "Guardar y ver más como esto",
"interestedSavedTitle": "Ya guardado — volver a hacer clic solo confirma",
"notInterested": "No es para mí",
"notInterestedTitle": "Mostrar menos de esto",
"blockSource": "Ocultar fuente",
"blockSourceLabel": "Ocultar {source}"
},
"onboarding": {
"welcome": "Bienvenido al News Hub",
"intro": "En tres pasos crearás tu feed personal.",
"stepTopics": "1. Temas",
"stepLanguage": "2. Idioma",
"stepSources": "3. Fuentes",
"topicsTitle": "¿Qué te interesa?",
"topicsHint": "Elige al menos dos temas.",
"languageTitle": "¿En qué idiomas lees?",
"sourcesTitle": "Fuentes de tus temas",
"sourcesHint": "Toca una fuente para ocultarla. Puedes cambiarlo cuando quieras.",
"back": "Atrás",
"next": "Siguiente",
"finish": "Listo",
"finishLoading": "Guardando…"
},
"reader": {
"back": "Atrás",
"smaller": "Menor",
"larger": "Mayor",
"save": "Guardar",
"loading": "Cargando…",
"notFound": "Artículo no encontrado.",
"backToFeed": "Volver al feed",
"openOriginal": "Abrir original"
},
"saved": {
"title": "Guardados",
"backToFeed": "Feed",
"addUrl": "Añadir URL",
"tabUnread": "No leídos",
"tabFavorites": "Favoritos",
"tabArchive": "Archivo",
"emptyUnread": "No hay artículos sin leer.",
"emptyUnreadHint": "Toca \"Me interesa\" en el feed para coleccionarlos aquí.",
"emptyFavorites": "Aún no hay favoritos.",
"emptyArchive": "El archivo está vacío.",
"badgeOwn": "propio",
"actionFavorite": "Favorito",
"actionArchive": "Archivar",
"actionUnarchive": "Restaurar",
"actionDelete": "Eliminar",
"actionCategory": "Categoría",
"categoryNone": "— Ninguna —"
},
"categories": {
"all": "Todos",
"manage": "Gestionar categorías",
"placeholder": "Nueva categoría…",
"add": "Añadir",
"empty": "Aún no hay categorías. Crea una arriba.",
"rename": "renombrar",
"delete": "eliminar",
"deleteConfirm": "¿Eliminar la categoría? Los artículos se mantienen."
},
"add": {
"title": "Guardar artículo",
"hint": "Pega una URL. Extraemos el texto completo (Mozilla Readability) y lo guardamos en tu lista de lectura cifrada.",
"backLink": "Guardados",
"placeholder": "https://…",
"submit": "Guardar",
"loading": "Cargando…",
"errorGeneric": "Error al guardar"
},
"preferences": {
"title": "Ajustes de noticias",
"page_title_html": "Ajustes de noticias — Mana",
"subtitle": "Temas · Idiomas · Pesos",
"backToFeed": "Feed",
"topicsHeading": "Temas",
"topicsHint": "¿Qué temas deben aparecer en el feed?",
"languagesHeading": "Idiomas",
"sourcesHeading": "Fuentes",
"sourcesHint": "Estás bloqueando {count} fuentes.",
"sourcesHintHtml": "Estás bloqueando <strong>{count}</strong> fuentes.",
"sourcesLink": "Gestionar fuentes",
"sourcesLinkArrow": "Gestionar fuentes →",
"weightsHeading": "Pesos aprendidos",
"weightsHint": "El feed aprende tus preferencias a partir de tus reacciones: {topics} pesos de temas, {sources} pesos de fuentes.",
"weightsReset": "Restablecer",
"weightsResetConfirm": "¿Restablecer todos los pesos aprendidos?",
"onboardingHeading": "Onboarding",
"onboardingHint": "Vuelve a elegir temas, idiomas y fuentes.",
"onboardingRerun": "Reiniciar onboarding"
},
"sources": {
"title": "Fuentes",
"backToPreferences": "Ajustes",
"hint": "{count} bloqueadas. Toca una fuente para alternar.",
"blocked": "bloqueada",
"weightTooltip": "Peso: {weight}"
},
"topics": {
"tech": "Tecnología",
"wissenschaft": "Ciencia",
"weltgeschehen": "Mundo",
"wirtschaft": "Economía",
"kultur": "Cultura",
"gesundheit": "Salud",
"politik": "Política"
},
"languages": {
"de": "Alemán",
"en": "Inglés"
},
"widget": {
"title": "Noticias",
"empty": "Sin noticias por leer.",
"viewAll": "Ver todo"
},
"workbench": {
"cta_title": "Configurar el News Hub",
"cta_hint": "Elige temas, idiomas y fuentes — luego tus artículos aparecerán aquí.",
"cta_action": "Configurar ahora",
"err_short": "Error",
"empty_short": "No hay artículos nuevos.",
"open_aria": "Abrir"
}
}

View file

@ -1,149 +0,0 @@
{
"app": {
"name": "News",
"tagline": "Ton fil d'actualité personnalisé"
},
"feed": {
"title": "Actualités",
"articles": "{count} articles",
"refresh": "Recharger",
"loading": "Chargement des articles…",
"empty": "Aucun nouvel article pour tes thèmes.",
"emptyHint": "Essaie ↻ ou ajoute des thèmes.",
"loadError": "Échec du chargement",
"savedLink": "Enregistrés",
"settingsLink": "Réglages",
"openArticleAria": "Ouvrir l'article",
"savedBadgeTitle": "Dans ta liste de lecture",
"savedBadgeText": "❤️ enregistré",
"readingTimeMin": "{n} min"
},
"reactions": {
"interested": "Intéressant",
"interestedSaved": "Enregistré",
"interestedTitle": "Enregistrer et en voir plus",
"interestedSavedTitle": "Déjà enregistré — recliquer ne fait que confirmer",
"notInterested": "Pas pour moi",
"notInterestedTitle": "Moins de ce genre",
"blockSource": "Masquer la source",
"blockSourceLabel": "Masquer {source}"
},
"onboarding": {
"welcome": "Bienvenue dans le News Hub",
"intro": "Trois étapes pour bâtir ton fil personnel.",
"stepTopics": "1. Thèmes",
"stepLanguage": "2. Langue",
"stepSources": "3. Sources",
"topicsTitle": "Qu'est-ce qui t'intéresse ?",
"topicsHint": "Choisis au moins deux thèmes.",
"languageTitle": "Dans quelles langues lis-tu ?",
"sourcesTitle": "Sources de tes thèmes",
"sourcesHint": "Touche une source pour la masquer. Tu peux changer à tout moment.",
"back": "Retour",
"next": "Suivant",
"finish": "Terminé",
"finishLoading": "Enregistrement…"
},
"reader": {
"back": "Retour",
"smaller": "Plus petit",
"larger": "Plus grand",
"save": "Enregistrer",
"loading": "Chargement…",
"notFound": "Article introuvable.",
"backToFeed": "Retour au fil",
"openOriginal": "Ouvrir l'original"
},
"saved": {
"title": "Enregistrés",
"backToFeed": "Fil",
"addUrl": "Ajouter une URL",
"tabUnread": "Non lus",
"tabFavorites": "Favoris",
"tabArchive": "Archives",
"emptyUnread": "Aucun article non lu.",
"emptyUnreadHint": "Touche « Intéressant » dans le fil pour collecter les articles ici.",
"emptyFavorites": "Aucun favori pour l'instant.",
"emptyArchive": "Les archives sont vides.",
"badgeOwn": "perso",
"actionFavorite": "Favori",
"actionArchive": "Archiver",
"actionUnarchive": "Restaurer",
"actionDelete": "Supprimer",
"actionCategory": "Catégorie",
"categoryNone": "— Aucune —"
},
"categories": {
"all": "Tous",
"manage": "Gérer les catégories",
"placeholder": "Nouvelle catégorie…",
"add": "Ajouter",
"empty": "Aucune catégorie. Crées-en une au-dessus.",
"rename": "renommer",
"delete": "supprimer",
"deleteConfirm": "Supprimer la catégorie ? Les articles sont conservés."
},
"add": {
"title": "Enregistrer un article",
"hint": "Colle une URL. Nous extrayons le texte complet (Mozilla Readability) et l'ajoutons à ta liste de lecture chiffrée.",
"backLink": "Enregistrés",
"placeholder": "https://…",
"submit": "Enregistrer",
"loading": "Chargement…",
"errorGeneric": "Échec de l'enregistrement"
},
"preferences": {
"title": "Réglages des actualités",
"page_title_html": "Réglages des actualités — Mana",
"subtitle": "Thèmes · Langues · Pondérations",
"backToFeed": "Fil",
"topicsHeading": "Thèmes",
"topicsHint": "Quels thèmes doivent apparaître dans le fil ?",
"languagesHeading": "Langues",
"sourcesHeading": "Sources",
"sourcesHint": "Tu bloques actuellement {count} sources.",
"sourcesHintHtml": "Tu bloques actuellement <strong>{count}</strong> sources.",
"sourcesLink": "Gérer les sources",
"sourcesLinkArrow": "Gérer les sources →",
"weightsHeading": "Pondérations apprises",
"weightsHint": "Le fil apprend tes préférences via tes réactions : {topics} pondérations de thèmes, {sources} pondérations de sources.",
"weightsReset": "Réinitialiser",
"weightsResetConfirm": "Réinitialiser toutes les pondérations apprises ?",
"onboardingHeading": "Onboarding",
"onboardingHint": "Re-choisis thèmes, langues et sources.",
"onboardingRerun": "Recommencer l'onboarding"
},
"sources": {
"title": "Sources",
"backToPreferences": "Réglages",
"hint": "{count} bloquées. Touche une source pour basculer.",
"blocked": "bloquée",
"weightTooltip": "Poids : {weight}"
},
"topics": {
"tech": "Tech",
"wissenschaft": "Sciences",
"weltgeschehen": "Monde",
"wirtschaft": "Économie",
"kultur": "Culture",
"gesundheit": "Santé",
"politik": "Politique"
},
"languages": {
"de": "Allemand",
"en": "Anglais"
},
"widget": {
"title": "Actualités",
"empty": "Aucune actualité non lue.",
"viewAll": "Tout voir"
},
"workbench": {
"cta_title": "Configurer le News Hub",
"cta_hint": "Choisis thèmes, langues et sources — ensuite tes articles apparaîtront ici.",
"cta_action": "Configurer maintenant",
"err_short": "Erreur",
"empty_short": "Aucun nouvel article.",
"open_aria": "Ouvrir"
}
}

View file

@ -1,149 +0,0 @@
{
"app": {
"name": "News",
"tagline": "Il tuo feed di notizie curato"
},
"feed": {
"title": "Notizie",
"articles": "{count} articoli",
"refresh": "Ricarica",
"loading": "Caricamento articoli…",
"empty": "Nessun nuovo articolo per i tuoi temi.",
"emptyHint": "Prova ↻ o aggiungi temi.",
"loadError": "Errore di caricamento",
"savedLink": "Salvati",
"settingsLink": "Impostazioni",
"openArticleAria": "Apri articolo",
"savedBadgeTitle": "Nella tua lista di lettura",
"savedBadgeText": "❤️ salvato",
"readingTimeMin": "{n} min"
},
"reactions": {
"interested": "Mi interessa",
"interestedSaved": "Salvato",
"interestedTitle": "Salva e mostra di più di questo",
"interestedSavedTitle": "Già salvato — un altro click conferma soltanto",
"notInterested": "Non per me",
"notInterestedTitle": "Mostra di meno",
"blockSource": "Nascondi fonte",
"blockSourceLabel": "Nascondi {source}"
},
"onboarding": {
"welcome": "Benvenuto nel News Hub",
"intro": "In tre passi crei il tuo feed personale.",
"stepTopics": "1. Temi",
"stepLanguage": "2. Lingua",
"stepSources": "3. Fonti",
"topicsTitle": "Cosa ti interessa?",
"topicsHint": "Scegli almeno due temi.",
"languageTitle": "In quali lingue leggi?",
"sourcesTitle": "Fonti dei tuoi temi",
"sourcesHint": "Tocca una fonte per nasconderla. Puoi cambiare in qualsiasi momento.",
"back": "Indietro",
"next": "Avanti",
"finish": "Fatto",
"finishLoading": "Salvataggio…"
},
"reader": {
"back": "Indietro",
"smaller": "Più piccolo",
"larger": "Più grande",
"save": "Salva",
"loading": "Caricamento…",
"notFound": "Articolo non trovato.",
"backToFeed": "Torna al feed",
"openOriginal": "Apri originale"
},
"saved": {
"title": "Salvati",
"backToFeed": "Feed",
"addUrl": "Aggiungi URL",
"tabUnread": "Da leggere",
"tabFavorites": "Preferiti",
"tabArchive": "Archivio",
"emptyUnread": "Nessun articolo da leggere.",
"emptyUnreadHint": "Tocca \"Mi interessa\" nel feed per raccogliere articoli qui.",
"emptyFavorites": "Ancora nessun preferito.",
"emptyArchive": "L'archivio è vuoto.",
"badgeOwn": "personale",
"actionFavorite": "Preferito",
"actionArchive": "Archivia",
"actionUnarchive": "Ripristina",
"actionDelete": "Elimina",
"actionCategory": "Categoria",
"categoryNone": "— Nessuna —"
},
"categories": {
"all": "Tutti",
"manage": "Gestisci categorie",
"placeholder": "Nuova categoria…",
"add": "Aggiungi",
"empty": "Nessuna categoria. Creane una sopra.",
"rename": "rinomina",
"delete": "elimina",
"deleteConfirm": "Eliminare la categoria? Gli articoli vengono mantenuti."
},
"add": {
"title": "Salva articolo",
"hint": "Incolla un URL. Estraiamo il testo completo (Mozilla Readability) e lo salviamo nella tua lista di lettura cifrata.",
"backLink": "Salvati",
"placeholder": "https://…",
"submit": "Salva",
"loading": "Caricamento…",
"errorGeneric": "Salvataggio fallito"
},
"preferences": {
"title": "Impostazioni notizie",
"page_title_html": "Impostazioni notizie — Mana",
"subtitle": "Temi · Lingue · Pesi",
"backToFeed": "Feed",
"topicsHeading": "Temi",
"topicsHint": "Quali temi devono apparire nel feed?",
"languagesHeading": "Lingue",
"sourcesHeading": "Fonti",
"sourcesHint": "Stai bloccando {count} fonti.",
"sourcesHintHtml": "Stai bloccando <strong>{count}</strong> fonti.",
"sourcesLink": "Gestisci fonti",
"sourcesLinkArrow": "Gestisci fonti →",
"weightsHeading": "Pesi appresi",
"weightsHint": "Dalle tue reazioni il feed impara le tue preferenze: {topics} pesi tema, {sources} pesi fonte.",
"weightsReset": "Reimposta",
"weightsResetConfirm": "Reimpostare tutti i pesi appresi?",
"onboardingHeading": "Onboarding",
"onboardingHint": "Riscegli temi, lingue e fonti.",
"onboardingRerun": "Riavvia onboarding"
},
"sources": {
"title": "Fonti",
"backToPreferences": "Impostazioni",
"hint": "{count} bloccate. Tocca una fonte per cambiare stato.",
"blocked": "bloccata",
"weightTooltip": "Peso: {weight}"
},
"topics": {
"tech": "Tech",
"wissenschaft": "Scienza",
"weltgeschehen": "Mondo",
"wirtschaft": "Economia",
"kultur": "Cultura",
"gesundheit": "Salute",
"politik": "Politica"
},
"languages": {
"de": "Tedesco",
"en": "Inglese"
},
"widget": {
"title": "Notizie",
"empty": "Nessuna notizia da leggere.",
"viewAll": "Vedi tutte"
},
"workbench": {
"cta_title": "Configura il News Hub",
"cta_hint": "Scegli temi, lingue e fonti — poi i tuoi articoli appariranno qui.",
"cta_action": "Configura ora",
"err_short": "Errore",
"empty_short": "Nessun nuovo articolo.",
"open_aria": "Apri"
}
}

View file

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

View file

@ -14,14 +14,10 @@
import type { ViewProps } from '$lib/app-registry';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { usePreferences } from '$lib/modules/news/queries';
const {}: ViewProps = $props();
const store = researchSessionStore;
const prefs$ = usePreferences();
const pinnedUrls = $derived(new Set((prefs$.value?.customFeeds ?? []).map((f) => f.url)));
let mode = $state<'query' | 'site'>('query');
let query = $state('');
@ -58,15 +54,6 @@
feedsOpen = false;
}
async function togglePin(feed: { url: string; title: string | null }) {
if (pinnedUrls.has(feed.url)) {
const existing = (prefs$.value?.customFeeds ?? []).find((f) => f.url === feed.url);
if (existing) await preferencesStore.unpinCustomFeed(existing.id);
} else {
await preferencesStore.pinCustomFeed({ url: feed.url, title: feed.title ?? feed.url });
}
}
async function onSave(articleUrl: string) {
savingUrl = articleUrl;
saveError = null;
@ -157,15 +144,6 @@
/>
<span class="ft">{feed.title ?? feed.url}</span>
</label>
<button
type="button"
class="pin"
class:pinned={pinnedUrls.has(feed.url)}
onclick={() => togglePin(feed)}
title={pinnedUrls.has(feed.url) ? 'Abo entfernen' : 'Abonnieren'}
>
{pinnedUrls.has(feed.url) ? '★' : '☆'}
</button>
</li>
{/each}
</ul>
@ -356,17 +334,6 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.pin {
background: transparent;
border: none;
cursor: pointer;
font-size: 0.95rem;
color: hsl(var(--color-muted-foreground));
padding: 0 0.25rem;
}
.pin.pinned {
color: hsl(var(--color-primary));
}
.results-head {
display: flex;
justify-content: space-between;

View file

@ -1,369 +0,0 @@
<!--
News — Workbench ListView.
This is the version that renders inside an AppPage carousel slot, not
the dedicated /news route. Two important differences from the route:
1. We boot the feed-cache poll loop here too — a user might add the
News card to a workbench scene without ever opening the /news route,
and we don't want them to stare at an empty card.
2. The onboarding wizard lives only on the /news route. Inside the
compact workbench frame there's no room for a 3-step picker, so
un-onboarded users get a CTA card pointing them at /news.
Header is intentionally bare — the workbench AppPage already supplies
the title bar and close/move/minimize controls.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import type { ViewProps } from '$lib/app-registry';
import {
usePreferences,
useCachedFeed,
useReactions,
formatRelativeTime,
} from '$lib/modules/news/queries';
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
import type { LocalCachedArticle } from '$lib/modules/news/types';
import { _ } from 'svelte-i18n';
// We accept ViewProps for protocol compatibility but the workbench
// view doesn't navigate within itself — every "open" jumps to the
// dedicated /news routes. Empty destructure satisfies the $props()
// declaration without referencing the props object (which would
// trigger Svelte's "captured initial value" warning).
const {}: ViewProps = $props();
const prefs$ = usePreferences();
const pool$ = useCachedFeed();
const reactions$ = useReactions();
const prefs = $derived(prefs$.value);
const pool = $derived(pool$.value);
const reactions = $derived(reactions$.value);
const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions));
const ranked = $derived(
prefs.onboardingCompleted ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : []
);
onMount(() => {
feedCacheStore.start();
});
onDestroy(() => {
// Don't stop the poll — the /news layout uses it too and the
// store dedupes via inFlight. Stopping here would race with a
// concurrently-mounted /news route.
});
$effect(() => {
if (!prefs.onboardingCompleted) return;
void feedCacheStore.refresh({
topics: prefs.selectedTopics,
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
});
});
async function react(
article: LocalCachedArticle,
kind: 'interested' | 'not_interested' | 'source_blocked'
) {
await reactionsStore.react({
articleId: article.id,
reaction: kind,
topic: article.topic,
sourceSlug: article.sourceSlug,
});
if (kind === 'interested') {
await articlesStore.saveFromCurated(article);
}
}
function open(article: LocalCachedArticle) {
goto(`/news/${article.id}`);
}
async function refresh() {
await feedCacheStore.refresh({
topics: prefs.selectedTopics,
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
});
}
</script>
<div class="wb-news">
{#if !prefs.onboardingCompleted}
<div class="cta">
<p class="cta-title">{$_('news.workbench.cta_title')}</p>
<p class="cta-hint">
{$_('news.workbench.cta_hint')}
</p>
<a class="cta-btn" href="/news">{$_('news.workbench.cta_action')}</a>
</div>
{:else}
<div class="toolbar">
<div class="counts">
{$_('news.feed.articles', { values: { count: ranked.length } })}
{#if feedCacheStore.lastError}
· <span class="err">{$_('news.workbench.err_short')}</span>
{/if}
</div>
<div class="tools">
<button
type="button"
class="tool"
onclick={refresh}
disabled={feedCacheStore.inFlight}
title={$_('news.feed.refresh')}
>
{feedCacheStore.inFlight ? '…' : '↻'}
</button>
<a class="tool" href="/news/saved" title={$_('news.feed.savedLink')}>📑</a>
<a class="tool" href="/news/preferences" title={$_('news.feed.settingsLink')}>⚙</a>
</div>
</div>
{#if ranked.length === 0}
<div class="empty">
{#if pool.length === 0}
<p>{$_('news.feed.loading')}</p>
{:else}
<p>{$_('news.workbench.empty_short')}</p>
<button type="button" class="link" onclick={refresh}>{$_('news.feed.refresh')}</button>
{/if}
</div>
{:else}
<ul class="list">
{#each ranked.slice(0, 30) as { article } (article.id)}
<li class="item">
{#if article.imageUrl}
<button
type="button"
class="thumb"
onclick={() => open(article)}
aria-label={$_('news.workbench.open_aria')}
>
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="body">
<div class="meta">
<span class="site">{article.siteName}</span>
<span>·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
</div>
<button type="button" class="title" onclick={() => open(article)}>
{article.title}
</button>
<div class="actions">
<button
type="button"
class="rxn"
onclick={() => react(article, 'interested')}
title={$_('news.reactions.interested')}
>
❤️
</button>
<button
type="button"
class="rxn"
onclick={() => react(article, 'not_interested')}
title={$_('news.reactions.notInterested')}
>
👎
</button>
<button
type="button"
class="rxn"
onclick={() => react(article, 'source_blocked')}
title={$_('news.reactions.blockSource')}
>
🚫
</button>
</div>
</div>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
<style>
.wb-news {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.5rem 0.25rem;
height: 100%;
overflow: hidden;
}
.cta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
text-align: center;
}
.cta-title {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.cta-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.cta-btn {
margin-top: 0.5rem;
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
text-decoration: none;
font-size: 0.8125rem;
font-weight: 500;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.25rem;
}
.counts {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.counts .err {
color: hsl(var(--color-destructive));
}
.tools {
display: flex;
gap: 0.25rem;
}
.tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
text-decoration: none;
}
.tool:disabled {
opacity: 0.5;
}
.empty {
text-align: center;
padding: 1.5rem 0;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.link {
background: none;
border: none;
color: hsl(var(--color-primary));
cursor: pointer;
text-decoration: underline;
font-size: 0.8125rem;
margin-top: 0.25rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
flex: 1;
}
.item {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.625rem;
padding: 0.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.thumb {
width: 64px;
height: 48px;
border: none;
padding: 0;
background: hsl(var(--color-background));
border-radius: 0.375rem;
overflow: hidden;
cursor: pointer;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.body {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.meta {
display: flex;
gap: 0.3rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.meta .site {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.title {
text-align: left;
background: none;
border: none;
padding: 0;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.3;
color: hsl(var(--color-foreground));
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title:hover {
color: hsl(var(--color-primary));
}
.actions {
display: flex;
gap: 0.25rem;
margin-top: 0.125rem;
}
.rxn {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
font-size: 0.75rem;
}
</style>

View file

@ -1,89 +0,0 @@
/**
* News API client talks to apps/api `/api/v1/news/*`.
*
* Two flavors of endpoints:
* - GET /feed pulls the curated pool, with topic/lang filters
* - POST /extract/* Mozilla Readability for ad-hoc URL saves
*
* The base URL comes from `getManaApiUrl()`, which on the client reads the
* browser-injected `__PUBLIC_MANA_API_URL__` (set from
* `PUBLIC_MANA_API_URL_CLIENT` in hooks.server.ts e.g.
* `https://mana-api.mana.how`) and on the server reads `process.env`
* directly. Reading `$env/dynamic/public.PUBLIC_MANA_API_URL` here would
* leak the SSR-side internal Docker hostname (`http://mana-api:3060`) to
* the browser and trip CSP / DNS.
*
* Auth is the unified Mana JWT pulled from `authStore.getAccessToken()`
* and attached as a `Authorization: Bearer …` header. The credentials/
* cookie path does NOT work the apps/api authMiddleware only reads
* the Authorization header. Initially we passed `credentials: 'include'`
* thinking the cookie alone was enough, which made every browser-side
* fetch return 401 because mana-api never sees a token.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
async function authHeader(): Promise<Record<string, string>> {
// getValidToken (not getAccessToken) — runs the token through the
// tokenManager so it refreshes if expired. getAccessToken just reads
// localStorage and returns null/stale, which is what made the first
// pass at this fix still 401. sync.ts uses the same getValidToken.
const token = await authStore.getValidToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export interface FeedArticleDto {
id: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string;
sourceSlug: string;
imageUrl: string | null;
topic: string;
language: string;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
ingestedAt: string;
}
export interface FeedQuery {
topics?: string[];
lang?: 'de' | 'en' | 'all';
since?: string;
limit?: number;
offset?: number;
}
export async function fetchFeed(
query: FeedQuery = {},
fetchImpl: typeof fetch = fetch
): Promise<FeedArticleDto[]> {
const params = new URLSearchParams();
if (query.topics && query.topics.length > 0) {
params.set('topics', query.topics.join(','));
}
if (query.lang && query.lang !== 'all') params.set('lang', query.lang);
if (query.since) params.set('since', query.since);
if (query.limit != null) params.set('limit', String(query.limit));
if (query.offset != null) params.set('offset', String(query.offset));
const url = `${getManaApiUrl()}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`;
const response = await fetchImpl(url, {
headers: await authHeader(),
});
if (!response.ok) {
throw new Error(`fetchFeed failed: ${response.status}`);
}
return (await response.json()) as FeedArticleDto[];
}
// Ad-hoc URL extraction moved to the `articles` module in M5 — see
// `modules/articles/api.ts` and `modules/articles/stores/articles.svelte.ts`.
// The `/api/v1/news/extract/*` routes in apps/api are kept for now as
// a legacy surface; the `news-research` module still relies on them.

View file

@ -1,35 +0,0 @@
/**
* News module Dexie table accessors and seed data.
*/
import { db } from '$lib/data/database';
import type {
LocalArticle,
LocalCachedArticle,
LocalCategory,
LocalPreferences,
LocalReaction,
} from './types';
import { PREFERENCES_ID } from './types';
export const articleTable = db.table<LocalArticle>('newsArticles');
export const categoryTable = db.table<LocalCategory>('newsCategories');
export const preferencesTable = db.table<LocalPreferences>('newsPreferences');
export const reactionTable = db.table<LocalReaction>('newsReactions');
export const cachedFeedTable = db.table<LocalCachedArticle>('newsCachedFeed');
/**
* Default preferences row written on first launch (before the user runs
* the onboarding flow). `onboardingCompleted: false` is what triggers
* the onboarding view to render instead of the feed.
*/
export const DEFAULT_PREFERENCES: LocalPreferences = {
id: PREFERENCES_ID,
selectedTopics: [],
blockedSources: [],
preferredLanguages: ['de', 'en'],
topicWeights: {},
sourceWeights: {},
onboardingCompleted: false,
customFeeds: [],
};

View file

@ -1,204 +0,0 @@
/**
* Pure feed-engine: takes the raw cached pool + the user's preferences
* and reactions, returns a sorted, filtered list of articles to show.
*
* No state, no I/O every input is passed in. The store layer wires
* this up against live Dexie data via $derived.
*
* Scoring formula (deterministic, no ML):
* score = recency × topicWeight × sourceWeight
*
* recency 1.0 for <1h old, decays linearly to 0 over 7 days
* topicWeight default 1.0, +0.1 per "interested" reaction in that
* topic, 0.05 per "not_interested" (clamped 0.1..3.0)
* sourceWeight same dynamics keyed on source slug
*
* Hard filters (applied before scoring):
* - article topic must be in preferences.selectedTopics
* - article source must NOT be in preferences.blockedSources
* - language must be in preferences.preferredLanguages
* - article must not have a prior reaction
* (interested moved to reading list, not_interested/hidden
* explicitly suppressed)
*/
import type { LocalCachedArticle, Preferences, Reaction, ReactionKind } from './types';
export const TOPIC_WEIGHT_DEFAULT = 1.0;
export const TOPIC_WEIGHT_MIN = 0.1;
export const TOPIC_WEIGHT_MAX = 3.0;
export const INTERESTED_DELTA = 0.1;
export const NOT_INTERESTED_DELTA = -0.05;
const RECENCY_WINDOW_HOURS = 168; // 7 days
function recencyScore(publishedAt: string | null): number {
if (!publishedAt) return 0.1;
const ageH = (Date.now() - new Date(publishedAt).getTime()) / 3.6e6;
if (ageH < 0) return 1.0;
return Math.max(0, 1 - ageH / RECENCY_WINDOW_HOURS);
}
export interface ScoreContext {
prefs: Preferences;
/**
* Set of curatedArticleIds the user has actively dismissed
* (`not_interested`, `hidden`, or via `source_blocked`). Used as a
* hard hide-from-feed filter.
*
* `interested` reactions are NOT in this set on purpose those
* articles stay visible in the feed (with a saved-badge) so the
* user can keep reading and clicking around without articles
* disappearing the moment they tap "❤️". The reading list remains
* the source of truth for "what did I save".
*/
dismissedIds: ReadonlySet<string>;
/** Set of curatedArticleIds the user marked as interested. */
interestedIds: ReadonlySet<string>;
}
/**
* Split the user's reactions into two sets: dismissed (hard-hide
* from feed) and interested (keep visible, badge in UI). Built once
* per render and reused across all scoreArticle calls.
*
* `source_blocked` reactions are NOT added to dismissedIds even
* though they hide articles the source-level filter in
* `scoreArticle` handles those via `prefs.blockedSources` instead,
* so adding them here would be a no-op duplicate.
*/
export function buildReactionSets(reactions: readonly Reaction[]): {
dismissedIds: Set<string>;
interestedIds: Set<string>;
} {
const dismissedIds = new Set<string>();
const interestedIds = new Set<string>();
for (const r of reactions) {
if (r.reaction === 'interested') {
interestedIds.add(r.articleId);
} else if (r.reaction === 'not_interested' || r.reaction === 'hidden') {
dismissedIds.add(r.articleId);
}
}
return { dismissedIds, interestedIds };
}
/**
* @deprecated Kept for backwards compat with the dashboard widget.
* New call sites should use `buildReactionSets` and the
* dismissedIds/interestedIds shape instead. This helper now returns
* only the dismissed ids same effect on the feed filter, but it
* means widgets that haven't migrated still hide
* `not_interested`/`hidden` articles correctly while leaving
* `interested` articles visible (matching the new feed behavior).
*/
export function buildReactedIds(reactions: readonly Reaction[]): Set<string> {
return buildReactionSets(reactions).dismissedIds;
}
/**
* Returns a number 0 if the article passes filters, or `null` if it
* should be hidden entirely. Callers sort by descending score.
*/
export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): number | null {
const { prefs, dismissedIds } = ctx;
if (prefs.selectedTopics.length > 0 && !prefs.selectedTopics.includes(article.topic as never)) {
return null;
}
if (prefs.blockedSources.includes(article.sourceSlug)) return null;
if (
prefs.preferredLanguages.length > 0 &&
!prefs.preferredLanguages.includes(article.language as never)
) {
return null;
}
// Only ACTIVELY DISMISSED articles are filtered out. `interested`
// reactions stay visible in the feed (the user's read state is
// communicated via the badge in the UI, not by hiding the card).
if (dismissedIds.has(article.id)) return null;
const topicW = prefs.topicWeights[article.topic] ?? TOPIC_WEIGHT_DEFAULT;
const sourceW = prefs.sourceWeights[article.sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
const recency = recencyScore(article.publishedAt);
// Floor recency at 0.05 so very old but highly-weighted sources still
// surface above brand-new but unweighted ones — keeps the feed from
// devolving into a pure recency stream.
const floored = Math.max(recency, 0.05);
return floored * topicW * sourceW;
}
export interface ScoredArticle {
article: LocalCachedArticle;
score: number;
}
/**
* Score the whole pool, drop the rejected ones, and return descending
* by score. Stable: ties broken by `publishedAt` desc.
*/
export function rankFeed(pool: readonly LocalCachedArticle[], ctx: ScoreContext): ScoredArticle[] {
const out: ScoredArticle[] = [];
for (const article of pool) {
const score = scoreArticle(article, ctx);
if (score == null) continue;
out.push({ article, score });
}
out.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ap = a.article.publishedAt ?? '';
const bp = b.article.publishedAt ?? '';
return bp.localeCompare(ap);
});
return out;
}
// ─── Weight updates (returned as a partial Preferences diff) ───
export interface WeightDiff {
topicWeights?: Record<string, number>;
sourceWeights?: Record<string, number>;
blockedSources?: string[];
}
function clamp(n: number): number {
return Math.max(TOPIC_WEIGHT_MIN, Math.min(TOPIC_WEIGHT_MAX, n));
}
/**
* Compute the preferences delta for a new reaction. The store layer
* merges this back onto the existing preferences row in a single
* `update()` call.
*/
export function applyReaction(
prefs: Preferences,
reaction: ReactionKind,
topic: string,
sourceSlug: string
): WeightDiff {
if (reaction === 'source_blocked') {
if (prefs.blockedSources.includes(sourceSlug)) return {};
return { blockedSources: [...prefs.blockedSources, sourceSlug] };
}
if (reaction === 'hidden') {
// "Hidden" is a per-article suppression — no weight change. The
// reaction row alone is enough for `reactedIds` to filter it.
return {};
}
const delta = reaction === 'interested' ? INTERESTED_DELTA : NOT_INTERESTED_DELTA;
const currentTopic = prefs.topicWeights[topic] ?? TOPIC_WEIGHT_DEFAULT;
const currentSource = prefs.sourceWeights[sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
return {
topicWeights: { ...prefs.topicWeights, [topic]: clamp(currentTopic + delta) },
sourceWeights: {
...prefs.sourceWeights,
[sourceSlug]: clamp(currentSource + delta),
},
};
}

View file

@ -1,65 +0,0 @@
/**
* News module barrel exports.
*/
export {
articleTable,
categoryTable,
preferencesTable,
reactionTable,
cachedFeedTable,
DEFAULT_PREFERENCES,
} from './collections';
export {
useSavedArticles,
useArticle,
useCategories,
usePreferences,
useReactions,
useCachedFeed,
toArticle,
toCategory,
toPreferences,
toReaction,
formatRelativeTime,
} from './queries';
export {
rankFeed,
scoreArticle,
buildReactedIds,
buildReactionSets,
applyReaction,
TOPIC_WEIGHT_DEFAULT,
} from './feed-engine';
export type { ScoredArticle, ScoreContext, WeightDiff } from './feed-engine';
export { articlesStore } from './stores/articles.svelte';
export { categoriesStore } from './stores/categories.svelte';
export { preferencesStore } from './stores/preferences.svelte';
export { reactionsStore } from './stores/reactions.svelte';
export { feedCacheStore } from './stores/feed-cache.svelte';
export { fetchFeed } from './api';
export type { FeedArticleDto, FeedQuery } from './api';
export { SOURCES_META, SOURCE_META_BY_SLUG, sourcesForTopic, TOPIC_LABELS } from './sources-meta';
export type { SourceMeta } from './sources-meta';
export { ALL_TOPICS, PREFERENCES_ID } from './types';
export type {
Article,
Category,
LocalArticle,
LocalCachedArticle,
LocalCategory,
LocalPreferences,
LocalReaction,
Language,
Preferences,
Reaction,
ReactionKind,
Topic,
} from './types';

View file

@ -1,19 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
/**
* News module five Dexie tables, four of them synced.
*
* `newsCachedFeed` is intentionally absent: it mirrors the public
* server pool, refreshes on a 10-minute poll, and would chew through
* sync bandwidth + storage quota for zero benefit (the same data is
* just an HTTP fetch away).
*/
export const newsModuleConfig: ModuleConfig = {
appId: 'news',
tables: [
{ name: 'newsArticles', syncName: 'articles' },
{ name: 'newsCategories', syncName: 'categories' },
{ name: 'newsPreferences', syncName: 'preferences' },
{ name: 'newsReactions', syncName: 'reactions' },
],
};

View file

@ -1,182 +0,0 @@
import { formatDate } from '$lib/i18n/format';
import { deriveUpdatedAt } from '$lib/data/sync';
/**
* Reactive queries + type converters for News.
*
* Read-side only. Anything that mutates lives in stores/*.svelte.ts.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import {
articleTable,
cachedFeedTable,
categoryTable,
preferencesTable,
reactionTable,
DEFAULT_PREFERENCES,
} from './collections';
import type {
Article,
Category,
LocalArticle,
LocalCachedArticle,
LocalCategory,
LocalPreferences,
LocalReaction,
Preferences,
Reaction,
} from './types';
import { PREFERENCES_ID } from './types';
// ─── Type converters ───────────────────────────────────────
export function toArticle(local: LocalArticle): Article {
return {
id: local.id,
type: local.type,
sourceCuratedId: local.sourceCuratedId ?? undefined,
originalUrl: local.originalUrl,
title: local.title,
excerpt: local.excerpt,
content: local.content,
htmlContent: local.htmlContent,
author: local.author,
siteName: local.siteName,
sourceSlug: local.sourceSlug,
imageUrl: local.imageUrl,
categoryId: local.categoryId,
wordCount: local.wordCount,
readingTimeMinutes: local.readingTimeMinutes,
publishedAt: local.publishedAt,
isArchived: local.isArchived,
isRead: local.isRead,
isFavorite: local.isFavorite,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toCategory(local: LocalCategory): Category {
return {
id: local.id,
name: local.name,
color: local.color,
icon: local.icon,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toPreferences(local: LocalPreferences): Preferences {
// Force the array fields back to arrays even if decryption left an
// encrypted blob string in place (vault locked at boot). Without this
// guard `{#each prefs.selectedTopics}` iterates the encrypted string
// char-by-char and crashes `TOPIC_LABELS[topic].emoji` on render.
return {
id: local.id,
selectedTopics: Array.isArray(local.selectedTopics) ? local.selectedTopics : [],
blockedSources: Array.isArray(local.blockedSources) ? local.blockedSources : [],
preferredLanguages: Array.isArray(local.preferredLanguages)
? local.preferredLanguages
: ['de', 'en'],
topicWeights:
local.topicWeights && typeof local.topicWeights === 'object' ? local.topicWeights : {},
sourceWeights:
local.sourceWeights && typeof local.sourceWeights === 'object' ? local.sourceWeights : {},
onboardingCompleted: local.onboardingCompleted ?? false,
customFeeds: Array.isArray(local.customFeeds) ? local.customFeeds : [],
};
}
export function toReaction(local: LocalReaction): Reaction {
return {
id: local.id,
articleId: local.articleId,
reaction: local.reaction,
sourceSlug: local.sourceSlug,
topic: local.topic,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live queries ──────────────────────────────────────────
/** Saved articles (the personal reading list). Encrypted on disk. */
export function useSavedArticles() {
return useLiveQueryWithDefault(async () => {
const visible = (await articleTable.toArray()).filter((a) => !a.deletedAt && !a.isArchived);
const decrypted = await decryptRecords('newsArticles', visible);
return decrypted.map(toArticle).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [] as Article[]);
}
export function useArticle(id: string) {
return useLiveQueryWithDefault(
async () => {
const local = await articleTable.get(id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('newsArticles', [local]);
return decrypted ? toArticle(decrypted) : null;
},
null as Article | null
);
}
export function useCategories() {
return useLiveQueryWithDefault(async () => {
const visible = (await categoryTable.toArray()).filter((c) => !c.deletedAt);
const decrypted = await decryptRecords('newsCategories', visible);
return decrypted.map(toCategory).sort((a, b) => a.sortOrder - b.sortOrder);
}, [] as Category[]);
}
/**
* Singleton preferences row. Returns the default-shape preferences if
* the user has never opened the module before onboardingCompleted
* starts as `false`, which the route layer uses to redirect into the
* onboarding view on first launch.
*/
export function usePreferences() {
return useLiveQueryWithDefault(async () => {
const local = await preferencesTable.get(PREFERENCES_ID);
if (!local) return toPreferences(DEFAULT_PREFERENCES);
const [decrypted] = await decryptRecords('newsPreferences', [local]);
return toPreferences(decrypted ?? DEFAULT_PREFERENCES);
}, toPreferences(DEFAULT_PREFERENCES));
}
export function useReactions() {
return useLiveQueryWithDefault(async () => {
const visible = (await reactionTable.toArray()).filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('newsReactions', visible);
return decrypted.map(toReaction);
}, [] as Reaction[]);
}
/** The local mirror of the server's curated pool — plaintext, not synced. */
export function useCachedFeed() {
return useLiveQueryWithDefault(async () => {
const all = await cachedFeedTable.toArray();
// Newest first, but the feed engine re-sorts by score so this is
// only a stable input order.
return all.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''));
}, [] as LocalCachedArticle[]);
}
// ─── Pure helpers ──────────────────────────────────────────
export function formatRelativeTime(iso: string | null): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'jetzt';
if (mins < 60) return `vor ${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `vor ${hours}h`;
const days = Math.floor(hours / 24);
if (days < 7) return `vor ${days}d`;
return formatDate(new Date(iso), { day: 'numeric', month: 'short' });
}

View file

@ -1,83 +0,0 @@
/**
* Source metadata for the News onboarding picker and per-article badges.
*
* MUST stay in sync with `services/news-ingester/src/sources.ts`
* the `slug` is the cross-reference key (user blocklists store it
* verbatim, articles in the curated pool reference it). Adding or
* removing a source means editing both files.
*
* `language` and `topic` are duplicated from the ingester so the client
* doesn't need to fetch source metadata at runtime.
*/
import type { Topic, Language } from './types';
export interface SourceMeta {
slug: string;
name: string;
topic: Topic;
language: Language;
}
export const SOURCES_META: readonly SourceMeta[] = [
// tech
{ slug: 'hacker-news', name: 'Hacker News', topic: 'tech', language: 'en' },
{ slug: 'arstechnica', name: 'Ars Technica', topic: 'tech', language: 'en' },
{ slug: 'theverge', name: 'The Verge', topic: 'tech', language: 'en' },
{ slug: 'heise', name: 'heise online', topic: 'tech', language: 'de' },
// wissenschaft
{ slug: 'quanta-magazine', name: 'Quanta Magazine', topic: 'wissenschaft', language: 'en' },
{ slug: 'spektrum', name: 'Spektrum', topic: 'wissenschaft', language: 'de' },
{ slug: 'nature-news', name: 'Nature News', topic: 'wissenschaft', language: 'en' },
{ slug: 'phys-org', name: 'Phys.org', topic: 'wissenschaft', language: 'en' },
// weltgeschehen
{ slug: 'tagesschau', name: 'Tagesschau', topic: 'weltgeschehen', language: 'de' },
{ slug: 'bbc-world', name: 'BBC World', topic: 'weltgeschehen', language: 'en' },
{ slug: 'aljazeera', name: 'Al Jazeera', topic: 'weltgeschehen', language: 'en' },
{ slug: 'dw-top', name: 'Deutsche Welle', topic: 'weltgeschehen', language: 'en' },
// wirtschaft
{ slug: 'handelsblatt', name: 'Handelsblatt', topic: 'wirtschaft', language: 'de' },
{ slug: 'ft-world', name: 'Financial Times', topic: 'wirtschaft', language: 'en' },
{ slug: 'bloomberg-markets', name: 'Bloomberg Markets', topic: 'wirtschaft', language: 'en' },
{
slug: 'economist-finance',
name: 'The Economist — Finance',
topic: 'wirtschaft',
language: 'en',
},
// kultur
{ slug: 'guardian-culture', name: 'The Guardian Culture', topic: 'kultur', language: 'en' },
{ slug: 'guardian-books', name: 'The Guardian Books', topic: 'kultur', language: 'en' },
{ slug: 'npr-arts', name: 'NPR Arts', topic: 'kultur', language: 'en' },
// gesundheit
{ slug: 'stat-news', name: 'STAT News', topic: 'gesundheit', language: 'en' },
{ slug: 'bbc-health', name: 'BBC Health', topic: 'gesundheit', language: 'en' },
{ slug: 'sciencedaily-health', name: 'ScienceDaily Health', topic: 'gesundheit', language: 'en' },
// politik
{ slug: 'spiegel-politik', name: 'Spiegel Politik', topic: 'politik', language: 'de' },
{ slug: 'politico-eu', name: 'Politico EU', topic: 'politik', language: 'en' },
{
slug: 'atlantic-politics',
name: 'The Atlantic — Politics',
topic: 'politik',
language: 'en',
},
];
export const SOURCE_META_BY_SLUG: Record<string, SourceMeta> = Object.fromEntries(
SOURCES_META.map((s) => [s.slug, s])
);
export function sourcesForTopic(topic: Topic): readonly SourceMeta[] {
return SOURCES_META.filter((s) => s.topic === topic);
}
export const TOPIC_LABELS: Record<Topic, { de: string; en: string; emoji: string }> = {
tech: { de: 'Tech', en: 'Tech', emoji: '💻' },
wissenschaft: { de: 'Wissenschaft', en: 'Science', emoji: '🔬' },
weltgeschehen: { de: 'Weltgeschehen', en: 'World', emoji: '🌍' },
wirtschaft: { de: 'Wirtschaft', en: 'Business', emoji: '📈' },
kultur: { de: 'Kultur', en: 'Culture', emoji: '🎭' },
gesundheit: { de: 'Gesundheit', en: 'Health', emoji: '🩺' },
politik: { de: 'Politik', en: 'Politics', emoji: '🏛️' },
};

View file

@ -1,92 +0,0 @@
/**
* Articles store the user's saved reading list.
*
* Now single-purpose: saveFromCurated copies a row from the local pool
* mirror into the encrypted reading list (hit when the user presses
* "speichern" on a feed card). The ad-hoc URL path (`saveFromUrl` +
* the `type: 'saved'` discriminator) moved to the Articles module in
* M5 see `modules/articles/migrations/from-news.ts` for the one-off
* data migration and `modules/articles/stores/articles.svelte.ts` for
* the replacement flow.
*
* All other operations (read/archive/favorite/delete) are plain
* updates against `newsArticles`.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { articleTable } from '../collections';
import { toArticle } from '../queries';
import type { Article, LocalArticle, LocalCachedArticle } from '../types';
export const articlesStore = {
async saveFromCurated(input: LocalCachedArticle): Promise<Article> {
// Dedupe: if the user has already saved this curated article,
// return the existing row instead of creating a duplicate. The
// `sourceCuratedId` index makes this O(1).
const existing = await articleTable.where('sourceCuratedId').equals(input.id).first();
if (existing) return toArticle(existing);
const newLocal: LocalArticle = {
id: crypto.randomUUID(),
type: 'curated',
sourceCuratedId: input.id,
originalUrl: input.originalUrl,
title: input.title,
excerpt: input.excerpt,
content: input.content,
htmlContent: input.htmlContent,
author: input.author,
siteName: input.siteName,
sourceSlug: input.sourceSlug,
imageUrl: input.imageUrl,
categoryId: null,
wordCount: input.wordCount,
readingTimeMinutes: input.readingTimeMinutes,
publishedAt: input.publishedAt,
isArchived: false,
isRead: false,
isFavorite: false,
};
const snapshot = toArticle(newLocal);
await encryptRecord('newsArticles', newLocal);
await articleTable.add(newLocal);
emitDomainEvent('ArticleSaved', 'news', 'newsArticles', newLocal.id, {
articleId: newLocal.id,
title: input.title ?? '',
});
return snapshot;
},
async markRead(id: string, isRead = true): Promise<void> {
await articleTable.update(id, {
isRead,
});
},
async toggleFavorite(id: string): Promise<void> {
const a = await articleTable.get(id);
if (!a) return;
await articleTable.update(id, {
isFavorite: !a.isFavorite,
});
},
async archive(id: string): Promise<void> {
await articleTable.update(id, {
isArchived: true,
});
},
async setCategory(id: string, categoryId: string | null): Promise<void> {
await articleTable.update(id, {
categoryId,
});
},
async delete(id: string): Promise<void> {
await articleTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -1,89 +0,0 @@
/**
* Categories store user-defined folders for the saved reading list.
*
* Categories live in `newsCategories`. The link from an article to its
* category is `LocalArticle.categoryId` (a plaintext FK index), set
* via `articlesStore.setCategory`.
*
* Default seeds (Lese später / Recherche) are NOT created here the
* user starts with zero categories and adds them on demand. Empty is
* a valid state and the saved-list view falls back to "Alle Artikel"
* when no category is selected.
*/
import { encryptRecord } from '$lib/data/crypto';
import { categoryTable } from '../collections';
import type { LocalCategory } from '../types';
const DEFAULT_COLORS = [
'#3b82f6',
'#22c55e',
'#f59e0b',
'#ec4899',
'#8b5cf6',
'#06b6d4',
'#f43f5e',
'#84cc16',
];
function pickColor(existingCount: number): string {
return DEFAULT_COLORS[existingCount % DEFAULT_COLORS.length];
}
export const categoriesStore = {
async create(input: { name: string; color?: string; icon?: string }): Promise<LocalCategory> {
const count = await categoryTable.count();
const newLocal: LocalCategory = {
id: crypto.randomUUID(),
name: input.name.trim() || 'Ohne Namen',
color: input.color ?? pickColor(count),
icon: input.icon ?? '📁',
sortOrder: count,
};
await encryptRecord('newsCategories', newLocal);
await categoryTable.add(newLocal);
return newLocal;
},
async rename(id: string, name: string): Promise<void> {
const trimmed = name.trim();
if (!trimmed) return;
const diff: Partial<LocalCategory> = {
name: trimmed,
};
await encryptRecord('newsCategories', diff);
await categoryTable.update(id, diff);
},
async setColor(id: string, color: string): Promise<void> {
await categoryTable.update(id, {
color,
});
},
async setIcon(id: string, icon: string): Promise<void> {
await categoryTable.update(id, {
icon,
});
},
async reorder(ids: string[]): Promise<void> {
// Bulk update via individual writes — Dexie has no native bulkUpdate
// for partial diffs and the per-call cost is negligible at folder
// counts (typically <20).
const now = new Date().toISOString();
for (let i = 0; i < ids.length; i++) {
await categoryTable.update(ids[i], { sortOrder: i });
}
},
async delete(id: string): Promise<void> {
// Soft-delete the category itself. Articles that referenced it
// keep `categoryId` pointing at the tombstoned row — the saved
// view treats unknown categoryIds as "uncategorized" so they
// don't disappear. A subsequent re-categorize cleans them up.
await categoryTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -1,120 +0,0 @@
/**
* Feed-cache store pulls the curated pool from /api/v1/news/feed
* into the local-only `newsCachedFeed` table.
*
* Why a local mirror at all? The feed engine (scoreArticle, rankFeed)
* runs against the cached pool every time the feed view re-renders.
* Hitting the network on every render would be silly; hitting the
* network on every preferences change would be worse. Caching also
* gives us offline reading for the cards the user already saw.
*
* The cache is bounded: we keep at most CACHE_LIMIT rows, and prune
* the oldest by ingestedAt before each refresh. The bounded size + the
* fact that the cache is plaintext + not synced is what justifies
* leaving it out of the encryption registry and the sync map.
*
* `start()` should be called once from the news +layout it kicks an
* immediate refresh and then polls on a 10-minute interval. The
* interval is held in module scope on purpose so multiple route entries
* can't accidentally double up.
*/
import { cachedFeedTable } from '../collections';
import { fetchFeed } from '../api';
import type { FeedArticleDto } from '../api';
import type { LocalCachedArticle } from '../types';
const POLL_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
const CACHE_LIMIT = 400;
let pollHandle: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let lastError: string | null = null;
let lastRefreshedAt: string | null = null;
function toLocal(dto: FeedArticleDto): LocalCachedArticle {
return {
id: dto.id,
originalUrl: dto.originalUrl,
title: dto.title,
excerpt: dto.excerpt,
content: dto.content,
htmlContent: dto.htmlContent,
author: dto.author,
siteName: dto.siteName,
sourceSlug: dto.sourceSlug,
imageUrl: dto.imageUrl,
topic: dto.topic,
language: dto.language,
wordCount: dto.wordCount,
readingTimeMinutes: dto.readingTimeMinutes,
publishedAt: dto.publishedAt,
ingestedAt: dto.ingestedAt,
cachedAt: new Date().toISOString(),
};
}
async function pruneToLimit(): Promise<void> {
const count = await cachedFeedTable.count();
if (count <= CACHE_LIMIT) return;
// Keep the newest CACHE_LIMIT rows by ingestedAt. Dexie has no
// LIMIT/OFFSET on plain table, so collect all PKs sorted and slice.
const all = await cachedFeedTable.toArray();
all.sort((a, b) => (b.ingestedAt ?? '').localeCompare(a.ingestedAt ?? ''));
const toDelete = all.slice(CACHE_LIMIT).map((a) => a.id);
if (toDelete.length > 0) await cachedFeedTable.bulkDelete(toDelete);
}
export const feedCacheStore = {
get lastError() {
return lastError;
},
get lastRefreshedAt() {
return lastRefreshedAt;
},
get inFlight() {
return inFlight;
},
async refresh(opts: { topics?: string[]; lang?: 'de' | 'en' | 'all' } = {}): Promise<void> {
if (inFlight) return;
inFlight = true;
lastError = null;
try {
const dtos = await fetchFeed({
limit: 200,
topics: opts.topics,
lang: opts.lang ?? 'all',
});
if (dtos.length > 0) {
// bulkPut keeps existing rows for the same id and updates
// them in place. New rows from the server replace the
// previous mirror, old rows that fell out of the server's
// 200-row window stay in the cache until pruneToLimit cuts
// them. That's the behavior we want — the cache should
// degrade gradually, not flush every refresh.
await cachedFeedTable.bulkPut(dtos.map(toLocal));
}
await pruneToLimit();
lastRefreshedAt = new Date().toISOString();
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
console.warn('[news] feed refresh failed:', lastError);
} finally {
inFlight = false;
}
},
start(opts: { topics?: string[]; lang?: 'de' | 'en' | 'all' } = {}): void {
if (pollHandle) return; // already started
void this.refresh(opts);
pollHandle = setInterval(() => void this.refresh(opts), POLL_INTERVAL_MS);
},
stop(): void {
if (pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
}
},
};

View file

@ -1,137 +0,0 @@
/**
* Preferences store singleton row keyed on `PREFERENCES_ID`.
*
* The first read of the preferences row is also the place that creates
* it on disk, so the rest of the codebase can assume it always exists.
* Onboarding then flips `onboardingCompleted` to true.
*/
import { encryptRecord } from '$lib/data/crypto';
import { preferencesTable, DEFAULT_PREFERENCES } from '../collections';
import { toPreferences } from '../queries';
import type { CustomFeed, LocalPreferences, Preferences, Topic, Language } from '../types';
import { PREFERENCES_ID } from '../types';
async function ensureRow(): Promise<LocalPreferences> {
const existing = await preferencesTable.get(PREFERENCES_ID);
if (existing) return existing;
const fresh: LocalPreferences = { ...DEFAULT_PREFERENCES };
await encryptRecord('newsPreferences', fresh);
await preferencesTable.add(fresh);
return fresh;
}
export const preferencesStore = {
async get(): Promise<Preferences> {
const row = await ensureRow();
return toPreferences(row);
},
async completeOnboarding(input: {
topics: Topic[];
languages: Language[];
blockedSources?: string[];
}): Promise<void> {
await ensureRow();
// Spread the input arrays — callers in onboarding pass Svelte 5
// `$state` proxy arrays, which IndexedDB cannot structured-clone
// (DataCloneError on the Dexie hook's _pendingChanges write).
const diff: Partial<LocalPreferences> = {
selectedTopics: [...input.topics],
preferredLanguages: [...input.languages],
blockedSources: [...(input.blockedSources ?? [])],
onboardingCompleted: true,
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async setTopics(topics: Topic[]): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
selectedTopics: [...topics],
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async setLanguages(languages: Language[]): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
preferredLanguages: [...languages],
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async toggleBlockedSource(slug: string): Promise<void> {
const row = await ensureRow();
const blocked = row.blockedSources ?? [];
const next = blocked.includes(slug) ? blocked.filter((s) => s !== slug) : [...blocked, slug];
const diff: Partial<LocalPreferences> = {
blockedSources: next,
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
/**
* Apply a precomputed weight diff (from feed-engine.applyReaction).
* Merges with existing weights caller already did the math.
*/
async applyWeightDiff(diff: {
topicWeights?: Record<string, number>;
sourceWeights?: Record<string, number>;
blockedSources?: string[];
}): Promise<void> {
await ensureRow();
const update: Partial<LocalPreferences> = {
...diff,
};
await encryptRecord('newsPreferences', update);
await preferencesTable.update(PREFERENCES_ID, update);
},
async pinCustomFeed(feed: { url: string; title: string; topic?: Topic }): Promise<void> {
const row = await ensureRow();
const existing = Array.isArray(row.customFeeds) ? row.customFeeds : [];
if (existing.some((f) => f.url === feed.url)) return;
const next: CustomFeed[] = [
...existing,
{
id: crypto.randomUUID(),
url: feed.url,
title: feed.title,
topic: feed.topic,
pinnedAt: Date.now(),
},
];
const diff: Partial<LocalPreferences> = {
customFeeds: next,
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async unpinCustomFeed(id: string): Promise<void> {
const row = await ensureRow();
const existing = Array.isArray(row.customFeeds) ? row.customFeeds : [];
const next = existing.filter((f) => f.id !== id);
if (next.length === existing.length) return;
const diff: Partial<LocalPreferences> = {
customFeeds: next,
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async resetWeights(): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
topicWeights: {},
sourceWeights: {},
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
};

View file

@ -1,61 +0,0 @@
/**
* Reactions store records the user's per-article feedback and pipes
* the matching weight delta into the preferences store in the same
* call. Two writes, one logical operation:
*
* 1. add a `newsReactions` row (drops the article from `reactedIds`
* so the feed engine stops surfacing it)
* 2. apply the weight diff back to `newsPreferences`
*
* The reaction row stays around so undo / "show what I dismissed"
* stays cheap. The preferences diff is what makes the suppression
* persist across cache refreshes.
*/
import { encryptRecord } from '$lib/data/crypto';
import { reactionTable } from '../collections';
import { applyReaction as computeWeightDiff } from '../feed-engine';
import { preferencesStore } from './preferences.svelte';
import type { LocalReaction, ReactionKind } from '../types';
async function loadCurrentPrefs() {
return preferencesStore.get();
}
export const reactionsStore = {
async react(input: {
articleId: string;
reaction: ReactionKind;
topic: string;
sourceSlug: string;
}): Promise<void> {
const prefs = await loadCurrentPrefs();
// 1. Persist the reaction row.
const row: LocalReaction = {
id: crypto.randomUUID(),
articleId: input.articleId,
reaction: input.reaction,
topic: input.topic,
sourceSlug: input.sourceSlug,
};
await encryptRecord('newsReactions', row);
await reactionTable.add(row);
// 2. Update preferences (weight + blocklist) in lockstep.
const diff = computeWeightDiff(prefs, input.reaction, input.topic, input.sourceSlug);
if (Object.keys(diff).length > 0) {
await preferencesStore.applyWeightDiff(diff);
}
},
async undo(reactionId: string): Promise<void> {
// Soft-delete: tombstone the reaction so the article shows up
// again in the feed. Weights stay where they were — undoing a
// thumbs-down doesn't *boost* the source, it just stops further
// suppression.
await reactionTable.update(reactionId, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -1,52 +0,0 @@
/**
* News Tools LLM-accessible operations for the news module.
*
* `save_news_article` is the agent's path into the user's reading list.
* M5 moved the saved-article storage to the `articles` module; this
* tool now routes through `articlesStore.saveFromUrl(url)` there. The
* tool name stays `save_news_article` because historic AI mission
* iterations in the DB reference it renaming would break the audit
* trail. A future `save_article` can be added as an alias in M6.
*
* `title` and `summary` are display hints for the approval dialog
* the canonical title/excerpt come from the extractor so the AI can't
* lie about content.
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
export const newsTools: ModuleTool[] = [
{
name: 'save_news_article',
module: 'news',
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: 'summary',
type: 'string',
description: 'Kurze Begründung warum dieser Artikel relevant ist',
required: false,
},
],
async execute(params) {
const url = params.url as string;
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 },
};
},
},
];

View file

@ -1,198 +0,0 @@
/**
* News module types local-first reading hub backed by the curated
* pool from `news.curated_articles` (see services/news-ingester).
*
* Local data is split across five Dexie tables:
*
* newsArticles saved reading list (encrypted)
* newsCategories user-defined folders for the reading list
* newsPreferences singleton row: topics, blocklist, weights
* newsReactions per-article feedback signals
* newsCachedFeed local mirror of the server's curated pool
* (NOT synced, NOT encrypted)
*/
import type { BaseRecord } from '@mana/local-store';
export type Topic =
| 'tech'
| 'wissenschaft'
| 'weltgeschehen'
| 'wirtschaft'
| 'kultur'
| 'gesundheit'
| 'politik';
export const ALL_TOPICS: readonly Topic[] = [
'tech',
'wissenschaft',
'weltgeschehen',
'wirtschaft',
'kultur',
'gesundheit',
'politik',
];
export type Language = 'de' | 'en';
export type ReactionKind = 'interested' | 'not_interested' | 'source_blocked' | 'hidden';
// ─── Saved reading list ────────────────────────────────────
export interface LocalArticle extends BaseRecord {
/** 'curated' = saved from the server pool, 'saved' = ad-hoc URL extract. */
type: 'curated' | 'saved';
/** Foreign key into the server's curated pool when type='curated'. */
sourceCuratedId?: string | null;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string | null;
sourceSlug: string | null;
imageUrl: string | null;
categoryId: string | null;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
isArchived: boolean;
isRead: boolean;
isFavorite: boolean;
}
export interface LocalCategory extends BaseRecord {
name: string;
color: string;
icon: string;
sortOrder: number;
}
// ─── Preferences (singleton) ───────────────────────────────
/**
* The single row id for the preferences singleton there is exactly
* one preferences row per user, so we use a stable string instead of a
* uuid to make upserts idempotent.
*/
export const PREFERENCES_ID = 'singleton';
export interface CustomFeed {
id: string;
url: string;
title: string;
/** Optional topic tag from the standard taxonomy. */
topic?: Topic;
/** Epoch ms when the user pinned this feed. */
pinnedAt: number;
}
export interface LocalPreferences extends BaseRecord {
id: string;
selectedTopics: Topic[];
blockedSources: string[];
preferredLanguages: Language[];
/** topic slug → weight (default 1.0, range ~0.1 to 3.0). */
topicWeights: Record<string, number>;
/** source slug → weight (default 1.0, range ~0.1 to 3.0). */
sourceWeights: Record<string, number>;
onboardingCompleted: boolean;
/**
* User-subscribed RSS feeds, populated from the News Research module's
* "Pin feed" action. Not ingested centrally the client fetches these
* on its own schedule (see feed-cache).
*/
customFeeds?: CustomFeed[];
}
// ─── Reactions ─────────────────────────────────────────────
export interface LocalReaction extends BaseRecord {
/** The curated article id (server-side uuid from the pool). */
articleId: string;
reaction: ReactionKind;
/** Denormalized for O(1) weight updates without a join. */
sourceSlug: string;
topic: string;
}
// ─── Cached pool mirror (local only) ───────────────────────
export interface LocalCachedArticle {
/** Server-side curated_articles.id. Used as the dedupe key. */
id: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string;
sourceSlug: string;
imageUrl: string | null;
topic: string;
language: string;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
ingestedAt: string;
/** Local timestamp when this row entered the cache. */
cachedAt: string;
}
// ─── Public DTOs (rendered by views) ───────────────────────
export interface Article {
id: string;
type: 'curated' | 'saved';
sourceCuratedId?: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string | null;
sourceSlug: string | null;
imageUrl: string | null;
categoryId: string | null;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
isArchived: boolean;
isRead: boolean;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
color: string;
icon: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface Preferences {
id: string;
selectedTopics: Topic[];
blockedSources: string[];
preferredLanguages: Language[];
topicWeights: Record<string, number>;
sourceWeights: Record<string, number>;
onboardingCompleted: boolean;
customFeeds: CustomFeed[];
}
export interface Reaction {
id: string;
articleId: string;
reaction: ReactionKind;
sourceSlug: string;
topic: string;
createdAt: string;
}

View file

@ -1,129 +0,0 @@
<script lang="ts">
/**
* NewsUnreadWidget — three top-ranked unread articles from the user's
* curated feed, surfaced on the dashboard.
*
* Reads:
* - newsCachedFeed (the local pool mirror — plaintext, no decrypt)
* - newsPreferences singleton (decrypts to apply topic/lang filters)
* - newsReactions (decrypts to skip already-rated articles)
*
* The widget intentionally does NOT trigger a feed refresh — that's
* the news layout's job. If the user has never opened /news, the
* pool is empty and the widget shows the empty state with a CTA.
*/
import { liveQuery } from 'dexie';
import {
cachedFeedTable,
preferencesTable,
reactionTable,
DEFAULT_PREFERENCES,
} from '$lib/modules/news/collections';
import { decryptRecords } from '$lib/data/crypto';
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
import { toPreferences, toReaction, formatRelativeTime } from '$lib/modules/news/queries';
import { PREFERENCES_ID, type LocalCachedArticle } from '$lib/modules/news/types';
let topThree = $state<LocalCachedArticle[]>([]);
let loading = $state(true);
let onboardingDone = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const [pool, prefsRow, reactionsRows] = await Promise.all([
cachedFeedTable.toArray(),
preferencesTable.get(PREFERENCES_ID),
reactionTable.toArray(),
]);
// Decrypt prefs + reactions (cache stays plaintext).
const prefs = prefsRow
? toPreferences(
(await decryptRecords('newsPreferences', [prefsRow]))[0] ?? DEFAULT_PREFERENCES
)
: toPreferences(DEFAULT_PREFERENCES);
const visibleReactions = reactionsRows.filter((r) => !r.deletedAt);
const reactions = (await decryptRecords('newsReactions', visibleReactions)).map(toReaction);
return {
prefs,
ranked: rankFeed(pool, { prefs, ...buildReactionSets(reactions) }),
};
}).subscribe({
next: ({ prefs, ranked }) => {
onboardingDone = prefs.onboardingCompleted;
topThree = ranked.slice(0, 3).map((s) => s.article);
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span aria-hidden="true">📰</span>
News
</h3>
<a href="/news" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
</div>
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-12 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if !onboardingDone}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Richte deinen Newsfeed ein.</p>
<a
href="/news"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Jetzt starten
</a>
</div>
{:else if topThree.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Keine neuen Artikel.</p>
<a href="/news" class="mt-3 inline-block text-xs text-primary hover:underline">
Feed öffnen
</a>
</div>
{:else}
<div class="space-y-2">
{#each topThree as article (article.id)}
<a
href="/news/{article.id}"
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div class="flex items-start gap-3">
{#if article.imageUrl}
<img
src={article.imageUrl}
alt=""
class="h-12 w-16 flex-shrink-0 rounded object-cover"
loading="lazy"
/>
{/if}
<div class="min-w-0 flex-1">
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
<div class="mt-0.5 flex gap-1 text-xs text-muted-foreground">
<span class="font-medium">{article.siteName}</span>
<span>·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -27,7 +27,6 @@ export type WidgetType =
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'period' // Period: current phase + days until next period
| 'news-unread' // News: latest unread curated articles
| 'articles-unread' // Articles: saved read-it-later articles
| 'body-stats' // Body: latest weight + active workout summary
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
@ -306,14 +305,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'period',
},
{
type: 'news-unread',
nameKey: 'dashboard.widgets.news_unread.title',
descriptionKey: 'dashboard.widgets.news_unread.description',
icon: '📰',
defaultSize: 'small',
allowMultiple: false,
},
{
type: 'articles-unread',
nameKey: 'dashboard.widgets.articles_unread.title',

View file

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

View file

@ -9,8 +9,6 @@
import { goto } from '$app/navigation';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { usePreferences } from '$lib/modules/news/queries';
import { RoutePage } from '$lib/components/shell';
let mode = $state<'query' | 'site'>('query');
@ -22,20 +20,6 @@
let saveError = $state<string | null>(null);
const store = researchSessionStore;
const prefs$ = usePreferences();
const pinnedUrls = $derived(new Set((prefs$.value?.customFeeds ?? []).map((f) => f.url)));
async function togglePin(feed: { url: string; title: string | null }) {
if (pinnedUrls.has(feed.url)) {
const existing = (prefs$.value?.customFeeds ?? []).find((f) => f.url === feed.url);
if (existing) await preferencesStore.unpinCustomFeed(existing.id);
} else {
await preferencesStore.pinCustomFeed({
url: feed.url,
title: feed.title ?? feed.url,
});
}
}
function isUrl(s: string): boolean {
try {
@ -175,18 +159,6 @@
<span class="feed-title">{feed.title ?? feed.url}</span>
<span class="feed-type">{feed.type}</span>
{#if feed.sourceHit}<span class="feed-src">{feed.sourceHit}</span>{/if}
<button
type="button"
class="pin"
class:pinned={pinnedUrls.has(feed.url)}
onclick={(e) => {
e.preventDefault();
togglePin(feed);
}}
title={pinnedUrls.has(feed.url) ? 'Abo entfernen' : 'Als Abo speichern'}
>
{pinnedUrls.has(feed.url) ? '★ Abonniert' : '☆ Abonnieren'}
</button>
</label>
</li>
{/each}
@ -372,21 +344,6 @@
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.pin {
margin-left: auto;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.35rem;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
cursor: pointer;
color: hsl(var(--color-foreground));
}
.pin.pinned {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border-color: transparent;
}
.result {
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));

View file

@ -1,39 +0,0 @@
<!--
News layout — boots the feed-cache poll loop and tears it down on
navigation away. The cached pool is shared across +page.svelte and
[id]/+page.svelte (the reader), so it lives at the layout level.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { Snippet } from 'svelte';
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
import { usePreferences } from '$lib/modules/news/queries';
let { children }: { children: Snippet } = $props();
const prefs$ = usePreferences();
const prefs = $derived(prefs$.value);
// Refresh whenever the user's topic/lang selection changes — the
// server filters server-side, so a different topic mix means a
// different cache. The store dedupes concurrent refreshes via its
// `inFlight` guard.
$effect(() => {
if (!prefs.onboardingCompleted) return;
void feedCacheStore.refresh({
topics: prefs.selectedTopics,
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
});
});
onMount(() => {
// Idempotent — start() is a no-op if the interval is already set.
feedCacheStore.start();
});
onDestroy(() => {
feedCacheStore.stop();
});
</script>
{@render children()}

View file

@ -1,777 +0,0 @@
<!--
News Feed — the main view.
Two render branches: if the user has not finished onboarding yet,
show the topic + language picker inline. Otherwise, render the
ranked feed with reaction buttons.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import {
usePreferences,
useCachedFeed,
useReactions,
formatRelativeTime,
} from '$lib/modules/news/queries';
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
import {
ALL_TOPICS,
type Topic,
type Language,
type LocalCachedArticle,
} from '$lib/modules/news/types';
import { TOPIC_LABELS, sourcesForTopic } from '$lib/modules/news/sources-meta';
import { RoutePage } from '$lib/components/shell';
import { _ } from 'svelte-i18n';
const prefs$ = usePreferences();
const pool$ = useCachedFeed();
const reactions$ = useReactions();
const prefs = $derived(prefs$.value);
const pool = $derived(pool$.value);
const reactions = $derived(reactions$.value);
// ─── Onboarding state (only used in the onboarding branch) ─
let pickedTopics = $state<Topic[]>([]);
let pickedLanguages = $state<Language[]>(['de', 'en']);
let pickedBlocked = $state<string[]>([]);
let onboardingStep = $state<1 | 2 | 3>(1);
// Local "just finished" override so the wizard hides immediately on
// click instead of waiting for Dexie's liveQuery to debounce + emit
// the new prefs.onboardingCompleted = true. Without this, the user
// clicks "Fertig", the write goes through, but the UI re-renders
// the same wizard step until the next liveQuery tick (~50-100ms),
// so people instinctively click again before noticing the change.
let onboardingJustFinished = $state(false);
let onboardingSubmitting = $state(false);
function toggleTopic(t: Topic) {
pickedTopics = pickedTopics.includes(t)
? pickedTopics.filter((x) => x !== t)
: [...pickedTopics, t];
}
function toggleLang(l: Language) {
pickedLanguages = pickedLanguages.includes(l)
? pickedLanguages.filter((x) => x !== l)
: [...pickedLanguages, l];
}
function toggleBlocked(slug: string) {
pickedBlocked = pickedBlocked.includes(slug)
? pickedBlocked.filter((x) => x !== slug)
: [...pickedBlocked, slug];
}
async function finishOnboarding() {
if (onboardingSubmitting) return;
onboardingSubmitting = true;
try {
// $state.snapshot strips the Svelte 5 reactive proxies — without it
// the arrays travel into Dexie hooks as proxies and trip
// DataCloneError on the structured-clone into _pendingChanges.
const topicsSnap = $state.snapshot(pickedTopics) as Topic[];
const langsSnap = $state.snapshot(pickedLanguages) as Language[];
const blockedSnap = $state.snapshot(pickedBlocked) as string[];
await preferencesStore.completeOnboarding({
topics: topicsSnap,
languages: langsSnap,
blockedSources: blockedSnap,
});
// Flip to the feed branch immediately. Without this we'd be at
// the mercy of Dexie's liveQuery debounce — the prefs read
// behind `prefs.onboardingCompleted` only updates a few ticks
// after the write, so the wizard would re-render the same
// step for ~50-100ms and the user would click "Fertig" twice.
onboardingJustFinished = true;
// Eagerly trigger the first feed pull instead of waiting for
// the layout's $effect to notice the prefs change. The layout
// effect WILL also fire shortly after, but its refresh is a
// no-op via the store's inFlight guard.
void feedCacheStore.refresh({
topics: topicsSnap,
lang: langsSnap.length === 1 ? langsSnap[0] : 'all',
});
} finally {
onboardingSubmitting = false;
}
}
// ─── Feed branch ──────────────────────────────────────────
const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions));
// Treat the local "just finished" override as fully onboarded so the
// feed renders immediately after the user clicks Fertig, before the
// liveQuery has had a chance to refresh prefs.
const isOnboarded = $derived(prefs.onboardingCompleted || onboardingJustFinished);
const ranked = $derived(
isOnboarded ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : []
);
async function react(
article: LocalCachedArticle,
kind: 'interested' | 'not_interested' | 'source_blocked'
) {
await reactionsStore.react({
articleId: article.id,
reaction: kind,
topic: article.topic,
sourceSlug: article.sourceSlug,
});
// "Interessiert" is the implicit save — copy into reading list.
if (kind === 'interested') {
await articlesStore.saveFromCurated(article);
}
}
function openReader(article: LocalCachedArticle) {
// We pass the curated id; the reader pulls the row from the
// cached feed table itself (no prop drilling).
goto(`/news/${article.id}`);
}
async function manualRefresh() {
await feedCacheStore.refresh({
topics: prefs.selectedTopics,
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
});
}
</script>
<svelte:head>
<title>News — Mana</title>
</svelte:head>
<RoutePage appId="news">
<div class="news-page">
{#if !isOnboarded}
<!-- ─── Onboarding ───────────────────────────────────── -->
<header class="hero">
<h1>{$_('news.onboarding.welcome')}</h1>
<p>{$_('news.onboarding.intro')}</p>
</header>
<div class="steps">
<span class="step" class:active={onboardingStep === 1}
>{$_('news.onboarding.stepTopics')}</span
>
<span class="step" class:active={onboardingStep === 2}
>{$_('news.onboarding.stepLanguage')}</span
>
<span class="step" class:active={onboardingStep === 3}
>{$_('news.onboarding.stepSources')}</span
>
</div>
{#if onboardingStep === 1}
<section class="step-panel">
<h2>{$_('news.onboarding.topicsTitle')}</h2>
<p class="hint">{$_('news.onboarding.topicsHint')}</p>
<div class="topic-grid">
{#each ALL_TOPICS as topic}
<button
type="button"
class="topic-pill"
class:selected={pickedTopics.includes(topic)}
onclick={() => toggleTopic(topic)}
>
<span class="topic-emoji">{TOPIC_LABELS[topic].emoji}</span>
<span>{TOPIC_LABELS[topic].de}</span>
</button>
{/each}
</div>
<div class="actions">
<button
type="button"
class="btn-primary"
disabled={pickedTopics.length < 2}
onclick={() => (onboardingStep = 2)}
>
{$_('news.onboarding.next')}
</button>
</div>
</section>
{:else if onboardingStep === 2}
<section class="step-panel">
<h2>{$_('news.onboarding.languageTitle')}</h2>
<div class="lang-row">
<button
type="button"
class="lang-pill"
class:selected={pickedLanguages.includes('de')}
onclick={() => toggleLang('de')}
>
🇩🇪 {$_('news.languages.de')}
</button>
<button
type="button"
class="lang-pill"
class:selected={pickedLanguages.includes('en')}
onclick={() => toggleLang('en')}
>
🇬🇧 {$_('news.languages.en')}
</button>
</div>
<div class="actions">
<button type="button" class="btn-secondary" onclick={() => (onboardingStep = 1)}>
{$_('news.onboarding.back')}
</button>
<button
type="button"
class="btn-primary"
disabled={pickedLanguages.length === 0}
onclick={() => (onboardingStep = 3)}
>
{$_('news.onboarding.next')}
</button>
</div>
</section>
{:else}
<section class="step-panel">
<h2>{$_('news.onboarding.sourcesTitle')}</h2>
<p class="hint">
{$_('news.onboarding.sourcesHint')}
</p>
<div class="sources-list">
{#each pickedTopics as topic}
<div class="topic-block">
<h3>
{TOPIC_LABELS[topic].emoji}
{TOPIC_LABELS[topic].de}
</h3>
<div class="source-row">
{#each sourcesForTopic(topic) as src}
<button
type="button"
class="source-chip"
class:blocked={pickedBlocked.includes(src.slug)}
onclick={() => toggleBlocked(src.slug)}
>
{src.name}
<span class="lang">·{src.language}</span>
</button>
{/each}
</div>
</div>
{/each}
</div>
<div class="actions">
<button type="button" class="btn-secondary" onclick={() => (onboardingStep = 2)}>
{$_('news.onboarding.back')}
</button>
<button
type="button"
class="btn-primary"
onclick={finishOnboarding}
disabled={onboardingSubmitting}
>
{onboardingSubmitting
? $_('news.onboarding.finishLoading')
: $_('news.onboarding.finish')}
</button>
</div>
</section>
{/if}
{:else}
<!-- ─── Feed ─────────────────────────────────────────── -->
<header class="feed-header">
<div>
<h1>{$_('news.feed.title')}</h1>
<div class="meta">
{$_('news.feed.articles', { values: { count: ranked.length } })}
{#if feedCacheStore.lastError}
· <span class="error">{$_('news.feed.loadError')}</span>
{/if}
</div>
</div>
<div class="header-actions">
<button
type="button"
class="icon-btn"
onclick={manualRefresh}
disabled={feedCacheStore.inFlight}
title={$_('news.feed.refresh')}
>
{feedCacheStore.inFlight ? '…' : '↻'}
</button>
<a class="icon-btn" href="/news/saved" title={$_('news.feed.savedLink')}>📑</a>
<a class="icon-btn" href="/news/preferences" title={$_('news.feed.settingsLink')}>⚙</a>
</div>
</header>
<!-- Topic filter strip -->
<div class="topic-strip">
{#each prefs.selectedTopics as topic}
<span class="topic-tag">
{TOPIC_LABELS[topic].emoji}
{TOPIC_LABELS[topic].de}
</span>
{/each}
</div>
{#if ranked.length === 0}
<div class="empty">
{#if pool.length === 0}
<p>{$_('news.feed.loading')}</p>
{:else}
<p>{$_('news.feed.empty')}</p>
<p class="hint">{$_('news.feed.emptyHint')}</p>
{/if}
</div>
{:else}
<div class="card-grid">
{#each ranked as scored (scored.article.id)}
{@const article = scored.article}
{@const isSaved = interestedIds.has(article.id)}
<article class="card">
{#if article.imageUrl}
<button
type="button"
class="card-image-btn"
onclick={() => openReader(article)}
aria-label={$_('news.feed.openArticleAria')}
>
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="card-body" class:is-saved={isSaved}>
<div class="card-meta">
<span class="source">{article.siteName}</span>
<span class="dot">·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
{#if article.readingTimeMinutes}
<span class="dot">·</span>
<span
>{$_('news.feed.readingTimeMin', {
values: { n: article.readingTimeMinutes },
})}</span
>
{/if}
{#if isSaved}
<span class="saved-badge" title={$_('news.feed.savedBadgeTitle')}
>{$_('news.feed.savedBadgeText')}</span
>
{/if}
</div>
<button type="button" class="card-title-btn" onclick={() => openReader(article)}>
{article.title}
</button>
{#if article.excerpt}
<p class="card-excerpt">{article.excerpt}</p>
{/if}
<div class="card-actions">
<button
type="button"
class="reaction-btn interested"
class:active={isSaved}
onclick={() => react(article, 'interested')}
title={isSaved
? $_('news.reactions.interestedSavedTitle')
: $_('news.reactions.interestedTitle')}
disabled={isSaved}
>
❤️ {isSaved
? $_('news.reactions.interestedSaved')
: $_('news.reactions.interested')}
</button>
<button
type="button"
class="reaction-btn not-interested"
onclick={() => react(article, 'not_interested')}
title={$_('news.reactions.notInterestedTitle')}
>
👎 {$_('news.reactions.notInterested')}
</button>
<button
type="button"
class="reaction-btn block"
onclick={() => react(article, 'source_blocked')}
title={$_('news.reactions.blockSource')}
>
🚫 {article.siteName}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</div>
</RoutePage>
<style>
.news-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 0 1rem 4rem;
max-width: 920px;
margin: 0 auto;
}
/* ─── Onboarding ─── */
.hero {
text-align: center;
padding: 1.5rem 0 0.5rem;
}
.hero h1 {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.hero p {
color: hsl(var(--color-muted-foreground));
margin-top: 0.5rem;
}
.steps {
display: flex;
justify-content: center;
gap: 1rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.step {
padding: 0.25rem 0.5rem;
border-radius: 999px;
}
.step.active {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-weight: 600;
}
.step-panel {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
}
.step-panel h2 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.step-panel h3 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hint {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.topic-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: hsl(var(--color-background));
border: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.15s;
}
.topic-pill:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.topic-pill.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.topic-emoji {
font-size: 1.25rem;
}
.lang-row {
display: flex;
gap: 0.75rem;
}
.lang-pill {
flex: 1;
padding: 1rem;
border-radius: 0.75rem;
background: hsl(var(--color-background));
border: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 1rem;
cursor: pointer;
}
.lang-pill.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.sources-list {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 50vh;
overflow-y: auto;
}
.topic-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.source-row {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.source-chip {
padding: 0.375rem 0.625rem;
border-radius: 999px;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.source-chip .lang {
opacity: 0.55;
margin-left: 0.25rem;
}
.source-chip.blocked {
opacity: 0.4;
text-decoration: line-through;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-primary,
.btn-secondary {
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: hsl(var(--color-primary));
color: white;
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: hsl(var(--color-muted-foreground));
border: 1px solid hsl(var(--color-border));
}
/* ─── Feed ─── */
.feed-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.feed-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.meta {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.meta .error {
color: hsl(var(--color-destructive));
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 1rem;
cursor: pointer;
text-decoration: none;
}
.icon-btn:hover {
filter: brightness(1.1);
}
.icon-btn:disabled {
opacity: 0.5;
}
.topic-strip {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.topic-tag {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.empty {
text-align: center;
padding: 3rem 0;
color: hsl(var(--color-muted-foreground));
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
overflow: hidden;
transition: transform 0.15s;
}
.card:hover {
transform: translateY(-2px);
}
.card-image-btn {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: hsl(var(--color-background));
border: none;
padding: 0;
cursor: pointer;
overflow: hidden;
}
.card-image-btn img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem 1rem;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.card-meta .source {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.card-meta .dot {
opacity: 0.6;
}
.card-title-btn {
text-align: left;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
line-height: 1.35;
color: hsl(var(--color-foreground));
}
.card-title-btn:hover {
color: hsl(var(--color-primary));
}
.card-excerpt {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.25rem;
}
.reaction-btn {
font-size: 0.75rem;
padding: 0.375rem 0.625rem;
border-radius: 999px;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
cursor: pointer;
}
.reaction-btn:hover {
filter: brightness(1.1);
}
.reaction-btn.interested:hover {
border-color: hsl(var(--color-primary));
}
.reaction-btn.interested.active {
background: hsl(var(--color-primary) / 0.18);
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
cursor: default;
opacity: 0.85;
}
.reaction-btn.interested.active:hover {
filter: none;
}
.saved-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
border-radius: 999px;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 0.6875rem;
font-weight: 600;
margin-left: auto;
}
.card-body.is-saved {
/* Subtle visual cue that this article is in the reading list,
* but still readable as a feed card. */
background: hsl(var(--color-primary) / 0.04);
}
</style>

View file

@ -1,334 +0,0 @@
<!--
Article reader.
Resolves the [id] param two ways:
1. If it matches a row in `newsCachedFeed` (curated pool), render
that row directly.
2. Otherwise, fall back to `newsArticles` (the saved reading list)
and render through the decryption-aware `useArticle` hook.
This dual-source lookup keeps the URL stable across "I just saved
this article" → reload — both points end up at /news/<curated-id>.
-->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import { cachedFeedTable, articleTable } from '$lib/modules/news/collections';
import { decryptRecords } from '$lib/data/crypto';
import { toArticle, formatRelativeTime } from '$lib/modules/news/queries';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
import type { LocalCachedArticle, Article } from '$lib/modules/news/types';
import { RoutePage } from '$lib/components/shell';
const id = $derived($page.params.id ?? '');
type Loaded =
| { kind: 'curated'; article: LocalCachedArticle }
| { kind: 'saved'; article: Article }
| { kind: 'missing' };
let loaded = $state<Loaded | null>(null);
$effect(() => {
const currentId = id;
if (!currentId) {
loaded = { kind: 'missing' };
return;
}
const obs = liveQuery(async () => {
// Curated pool first.
const cached = await cachedFeedTable.get(currentId);
if (cached) return { kind: 'curated' as const, article: cached };
// Saved list — by sourceCuratedId first, then by primary key.
const savedByCurated = await articleTable.where('sourceCuratedId').equals(currentId).first();
if (savedByCurated) {
const [decrypted] = await decryptRecords('newsArticles', [savedByCurated]);
return { kind: 'saved' as const, article: toArticle(decrypted) };
}
const savedById = await articleTable.get(currentId);
if (savedById) {
const [decrypted] = await decryptRecords('newsArticles', [savedById]);
return { kind: 'saved' as const, article: toArticle(decrypted) };
}
return { kind: 'missing' as const };
});
const sub = obs.subscribe((value) => (loaded = value));
return () => sub.unsubscribe();
});
const html = $derived(
loaded && loaded.kind !== 'missing'
? loaded.kind === 'curated'
? loaded.article.htmlContent
: loaded.article.htmlContent
: null
);
const plain = $derived(
loaded && loaded.kind !== 'missing'
? loaded.kind === 'curated'
? loaded.article.content
: loaded.article.content
: null
);
const title = $derived(loaded && loaded.kind !== 'missing' ? loaded.article.title : '');
const meta = $derived.by(() => {
if (!loaded || loaded.kind === 'missing') return null;
const a = loaded.article;
return {
siteName: a.siteName,
author: a.author,
publishedAt: a.publishedAt,
readingTimeMinutes: a.readingTimeMinutes,
originalUrl: a.originalUrl,
imageUrl: a.imageUrl,
};
});
let fontSize = $state(1);
async function saveAndStay() {
if (!loaded || loaded.kind !== 'curated') return;
await articlesStore.saveFromCurated(loaded.article);
await reactionsStore.react({
articleId: loaded.article.id,
reaction: 'interested',
topic: loaded.article.topic,
sourceSlug: loaded.article.sourceSlug,
});
}
</script>
<svelte:head>
<title>{title || 'Lese-Ansicht'} — Mana</title>
</svelte:head>
<RoutePage appId="news" backHref="/news" title="Artikel">
<div class="reader-shell" style:--reader-font-size="{fontSize}rem">
<header class="reader-bar">
<button type="button" class="bar-btn" onclick={() => goto('/news')}> Zurück</button>
<div class="bar-spacer"></div>
<button
type="button"
class="bar-btn"
onclick={() => (fontSize = Math.max(0.875, fontSize - 0.0625))}
title="Kleiner"
>
A
</button>
<button
type="button"
class="bar-btn"
onclick={() => (fontSize = Math.min(1.25, fontSize + 0.0625))}
title="Größer"
>
A+
</button>
{#if loaded?.kind === 'curated'}
<button type="button" class="bar-btn primary" onclick={saveAndStay}>❤️ Speichern</button>
{/if}
</header>
{#if !loaded}
<div class="placeholder">Lade…</div>
{:else if loaded.kind === 'missing'}
<div class="placeholder">
<p>Artikel nicht gefunden.</p>
<button type="button" class="bar-btn" onclick={() => goto('/news')}>Zurück zum Feed</button>
</div>
{:else}
<article class="reader">
{#if meta?.imageUrl}
<img class="hero-image" src={meta.imageUrl} alt="" />
{/if}
<h1 class="reader-title">{title}</h1>
<div class="reader-meta">
{#if meta?.siteName}
<span class="site">{meta.siteName}</span>
{/if}
{#if meta?.author}
<span>·</span>
<span>{meta.author}</span>
{/if}
{#if meta?.publishedAt}
<span>·</span>
<span>{formatRelativeTime(meta.publishedAt)}</span>
{/if}
{#if meta?.readingTimeMinutes}
<span>·</span>
<span>{meta.readingTimeMinutes} min</span>
{/if}
</div>
{#if html}
<!--
Curated pool stores Mozilla Readability HTML which is
already a stripped-down article DOM. We render it as-is
through Svelte's @html. Source: news.curated_articles in
our own backend, populated by the news-ingester service —
so the trust boundary is "we trust our own ingester
output", same as for chat/messages and notes/content.
-->
<div class="reader-content prose">{@html html}</div>
{:else if plain}
<div class="reader-content prose">
{#each plain.split('\n\n') as para}
<p>{para}</p>
{/each}
</div>
{/if}
{#if meta?.originalUrl}
<footer class="reader-footer">
<a class="external-link" href={meta.originalUrl} target="_blank" rel="noreferrer">
Original öffnen ↗
</a>
</footer>
{/if}
</article>
{/if}
</div>
</RoutePage>
<style>
.reader-shell {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem 4rem;
}
.reader-bar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0;
background: hsl(var(--color-background) / 0.85);
backdrop-filter: blur(8px);
}
.bar-spacer {
flex: 1;
}
.bar-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.bar-btn.primary {
background: hsl(var(--color-primary));
color: white;
border-color: hsl(var(--color-primary));
}
.placeholder {
text-align: center;
padding: 4rem 0;
color: hsl(var(--color-muted-foreground));
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.reader {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 1rem;
}
.hero-image {
width: 100%;
max-height: 360px;
object-fit: cover;
border-radius: 0.75rem;
}
.reader-title {
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
color: hsl(var(--color-foreground));
}
.reader-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.reader-meta .site {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.reader-content {
font-size: var(--reader-font-size, 1rem);
line-height: 1.7;
color: hsl(var(--color-foreground));
}
.reader-content :global(p) {
margin: 1rem 0;
}
.reader-content :global(h2),
.reader-content :global(h3) {
margin-top: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 700;
}
.reader-content :global(h2) {
font-size: 1.4em;
}
.reader-content :global(h3) {
font-size: 1.15em;
}
.reader-content :global(a) {
color: hsl(var(--color-primary));
text-decoration: underline;
}
.reader-content :global(img) {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
.reader-content :global(blockquote) {
border-left: 3px solid hsl(var(--color-primary));
padding: 0.5rem 1rem;
margin: 1rem 0;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.reader-content :global(pre) {
background: hsl(var(--color-muted));
padding: 0.75rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.reader-content :global(code) {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.92em;
}
.reader-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border));
}
.external-link {
color: hsl(var(--color-primary));
text-decoration: none;
font-size: 0.875rem;
}
.external-link:hover {
text-decoration: underline;
}
</style>

View file

@ -1,26 +0,0 @@
<!--
Legacy redirect: /news/add → /articles/add.
The ad-hoc URL-save flow moved to the articles module in M5.
Kept here so existing bookmarks and cross-links keep working.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { RoutePage } from '$lib/components/shell';
onMount(() => {
goto('/articles/add', { replaceState: true });
});
</script>
<RoutePage appId="news" backHref="/news">
<p class="redirect">Verschoben nach <a href="/articles/add">/articles/add</a></p>
</RoutePage>
<style>
.redirect {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -1,242 +0,0 @@
<!--
/news/preferences — topics, languages, reset weights, onboarding rerun.
Reached via the ⚙ button in the news module; not a workbench card.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { usePreferences } from '$lib/modules/news/queries';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { ALL_TOPICS, type Topic, type Language } from '$lib/modules/news/types';
import { TOPIC_LABELS } from '$lib/modules/news/sources-meta';
import { RoutePage } from '$lib/components/shell';
import { _ } from 'svelte-i18n';
const prefs$ = usePreferences();
const prefs = $derived(prefs$.value);
let topicWeightCount = $derived(Object.keys(prefs.topicWeights).length);
let sourceWeightCount = $derived(Object.keys(prefs.sourceWeights).length);
async function toggleTopic(t: Topic) {
const next = prefs.selectedTopics.includes(t)
? prefs.selectedTopics.filter((x) => x !== t)
: [...prefs.selectedTopics, t];
await preferencesStore.setTopics(next);
}
async function toggleLang(l: Language) {
const next = prefs.preferredLanguages.includes(l)
? prefs.preferredLanguages.filter((x) => x !== l)
: [...prefs.preferredLanguages, l];
await preferencesStore.setLanguages(next);
}
async function resetWeights() {
if (!confirm($_('news.preferences.weightsResetConfirm'))) return;
await preferencesStore.resetWeights();
}
async function rerunOnboarding() {
await preferencesStore.applyWeightDiff({});
const { preferencesTable } = await import('$lib/modules/news/collections');
const { encryptRecord } = await import('$lib/data/crypto');
const { PREFERENCES_ID } = await import('$lib/modules/news/types');
const diff = { onboardingCompleted: false, updatedAt: new Date().toISOString() };
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
goto('/news');
}
</script>
<svelte:head>
<title>{$_('news.preferences.page_title_html')}</title>
</svelte:head>
<RoutePage appId="news" backHref="/news">
<div class="pane">
<header class="bar">
<div class="title">
<strong>{$_('news.preferences.title')}</strong>
<span class="sub">{$_('news.preferences.subtitle')}</span>
</div>
</header>
<section class="card">
<h2>{$_('news.preferences.topicsHeading')}</h2>
<p class="hint">{$_('news.preferences.topicsHint')}</p>
<div class="grid">
{#each ALL_TOPICS as topic}
<button
type="button"
class="pill"
class:selected={prefs.selectedTopics.includes(topic)}
onclick={() => toggleTopic(topic)}
>
<span class="emoji">{TOPIC_LABELS[topic].emoji}</span>
<span>{TOPIC_LABELS[topic].de}</span>
</button>
{/each}
</div>
</section>
<section class="card">
<h2>{$_('news.preferences.languagesHeading')}</h2>
<div class="row">
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('de')}
onclick={() => toggleLang('de')}
>
🇩🇪 {$_('news.languages.de')}
</button>
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('en')}
onclick={() => toggleLang('en')}
>
🇬🇧 {$_('news.languages.en')}
</button>
</div>
</section>
<section class="card">
<h2>{$_('news.preferences.sourcesHeading')}</h2>
<p class="hint">
{@html $_('news.preferences.sourcesHintHtml', {
values: { count: prefs.blockedSources.length },
})}
</p>
<a class="btn-link" href="/news/sources">{$_('news.preferences.sourcesLinkArrow')}</a>
</section>
<section class="card">
<h2>{$_('news.preferences.weightsHeading')}</h2>
<p class="hint">
{$_('news.preferences.weightsHint', {
values: { topics: topicWeightCount, sources: sourceWeightCount },
})}
</p>
<button type="button" class="btn-secondary" onclick={resetWeights}
>{$_('news.preferences.weightsReset')}</button
>
</section>
<section class="card">
<h2>{$_('news.preferences.onboardingHeading')}</h2>
<p class="hint">{$_('news.preferences.onboardingHint')}</p>
<button type="button" class="btn-secondary" onclick={rerunOnboarding}>
{$_('news.preferences.onboardingRerun')}
</button>
</section>
</div>
</RoutePage>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.125rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
}
.card h2 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 10px;
background: hsl(var(--color-background, var(--color-card)));
border: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.pill.selected {
border-color: hsl(var(--color-primary, 230 80% 55%));
background: hsl(var(--color-primary, 230 80% 55%) / 0.12);
}
.emoji {
font-size: 1rem;
}
.btn-secondary {
align-self: flex-start;
padding: 0.5rem 0.875rem;
border-radius: 8px;
background: hsl(var(--color-background, var(--color-card)));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.btn-secondary:hover {
background: hsl(var(--color-muted) / 0.5);
}
.btn-link {
align-self: flex-start;
color: hsl(var(--color-primary, 230 80% 55%));
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.btn-link:hover {
text-decoration: underline;
}
</style>

View file

@ -1,26 +0,0 @@
<!--
Legacy redirect: /news/saved → /articles.
The "saved reading list" moved into the articles module in M5.
Kept here so existing bookmarks and cross-links keep working.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { RoutePage } from '$lib/components/shell';
onMount(() => {
goto('/articles', { replaceState: true });
});
</script>
<RoutePage appId="news" backHref="/news">
<p class="redirect">Verschoben nach <a href="/articles">/articles</a></p>
</RoutePage>
<style>
.redirect {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -1,168 +0,0 @@
<!--
/news/sources — list all known sources, grouped by topic, with a
block toggle and a learned-weight indicator.
Source list is the static SOURCES_META mirror; the toggle hits
preferencesStore.toggleBlockedSource which updates the singleton
preferences row in lockstep with the feed engine's blocklist filter.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { usePreferences } from '$lib/modules/news/queries';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { ALL_TOPICS, type Topic } from '$lib/modules/news/types';
import { sourcesForTopic, TOPIC_LABELS } from '$lib/modules/news/sources-meta';
import { RoutePage } from '$lib/components/shell';
const prefs$ = usePreferences();
const prefs = $derived(prefs$.value);
function isBlocked(slug: string): boolean {
return prefs.blockedSources.includes(slug);
}
function weightOf(slug: string): number {
return prefs.sourceWeights[slug] ?? 1.0;
}
function weightLabel(w: number): string {
if (w >= 1.5) return '↑';
if (w >= 1.1) return '↗';
if (w <= 0.5) return '↓';
if (w <= 0.9) return '↘';
return '·';
}
async function toggle(slug: string) {
await preferencesStore.toggleBlockedSource(slug);
}
const visibleTopics: Topic[] = ALL_TOPICS as unknown as Topic[];
</script>
<svelte:head>
<title>Quellen — News — Mana</title>
</svelte:head>
<RoutePage appId="news" backHref="/news">
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/news/preferences')}
>← Einstellungen</button
>
<h1>Quellen</h1>
<p class="hint">
{prefs.blockedSources.length} blockiert. Tippe auf eine Quelle um sie ein- oder auszublenden.
</p>
</header>
{#each visibleTopics as topic}
<section class="topic-section">
<h2>
{TOPIC_LABELS[topic].emoji}
{TOPIC_LABELS[topic].de}
</h2>
<div class="source-grid">
{#each sourcesForTopic(topic) as src}
{@const blocked = isBlocked(src.slug)}
{@const weight = weightOf(src.slug)}
<button type="button" class="source" class:blocked onclick={() => toggle(src.slug)}>
<span class="name">{src.name}</span>
<span class="meta">
<span class="lang">{src.language}</span>
<span class="weight" title="Gewicht: {weight.toFixed(2)}"
>{weightLabel(weight)}</span
>
{#if blocked}
<span class="state">blockiert</span>
{/if}
</span>
</button>
{/each}
</div>
</section>
{/each}
</div>
</RoutePage>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.header {
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.5rem;
}
.topic-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.topic-section h2 {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.source-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
}
.source {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem 0.875rem;
text-align: left;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
cursor: pointer;
}
.source.blocked {
opacity: 0.5;
text-decoration: line-through;
}
.name {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.meta {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.weight {
font-weight: 700;
}
.state {
color: hsl(var(--color-destructive));
font-weight: 500;
}
</style>

View file

@ -1304,10 +1304,8 @@ services:
MANA_CREDITS_URL: http://mana-credits:3002
MANA_MEDIA_URL: http://mana-media:3011
MANA_CRAWLER_URL: http://mana-crawler:3014
# mana-news-pool — Plattform-Service (Lift-B 2026-05-16). Ersetzt
# den ehemaligen news-ingester:3066-Container, der direkt in
# mana_platform.news.curated_articles schrieb. apps/api/news/routes.ts
# ist seit 2026-05-17 ein HTTP-Proxy auf diesen Endpoint.
# mana-news-pool — Plattform-Service (Lift-B 2026-05-16). Wird
# vom news-research-Modul + von der Pageta-Standalone konsumiert.
MANA_NEWS_POOL_URL: http://mana-news-pool:3079
MANA_LLM_DEFAULT_MODEL: ${MANA_LLM_DEFAULT_MODEL:-gemma3:4b}
MANA_SERVICE_KEY: ${MANA_SERVICE_KEY}

View file

@ -251,7 +251,6 @@ scrape_configs:
- https://mana.how/finance
- https://mana.how/places
# mana.how/who: existiert nicht im unified-app — Who läuft als Standalone-Stack auf who.mana.how
- https://mana.how/news
- https://mana.how/mail
- https://mana.how/playground
# ─── Standalone Apps / Games (separate Container, eigene Tunnel-Hostnames) ───

View file

@ -326,30 +326,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
],
},
// ── News ──────────────────────────────────────────────────
{
name: 'save_news_article',
module: 'news',
description:
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
defaultPolicy: 'propose',
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: 'summary',
type: 'string',
description: 'Kurze Begründung warum dieser Artikel relevant ist',
required: false,
},
],
},
// ── Articles (Pocket-style read-it-later) ───────────────
{
name: 'list_articles',

View file

@ -107,9 +107,6 @@ export const APP_ICONS = {
uload: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`
),
news: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#34d399"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ng)"/><rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none"/><line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
'news-research': svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),

View file

@ -413,23 +413,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'news',
name: 'News Hub',
description: {
de: 'Kuratierter Newsfeed',
en: 'Curated News Feed',
},
longDescription: {
de: 'Kuratierter Newsfeed aus öffentlichen Quellen mit persönlicher Leseliste — wähle Themen aus, blende Quellen aus und bau dir deinen eigenen Feed.',
en: 'Curated news feed from public sources with a personal reading list — pick topics, hide sources, and shape your own feed.',
},
icon: APP_ICONS.news,
color: '#10b981',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'news-research',
name: 'News Research',

View file

@ -77,7 +77,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'cards',
'picture',
'quotes',
'news',
'news-research',
'research-lab',
'ai-agents',
@ -99,7 +98,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'mail',
'storage',
'uload',
'news',
'research-lab',
'club-members', // future — ClubDesk Paket A
'club-finance', // future — ClubDesk Paket B
@ -152,7 +150,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'mail',
'uload',
'website',
'news',
'news-research',
'research-lab',
'presi',