chore(mana+api): articles + Backend-Worker raus, pageta trägt allein
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (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
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
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

Pageta ist seit 2026-05-17 standalone live (pageta.mana.how + pageta.com,
voll-featured laut STATUS.md) und deckt alle Articles-Module-Features
ab + mehr (research, reactions, feed, share, snapshot, preferences).
Keine User-Daten im managarten/articles-Modul (Till bestätigt).

Frontend entfernt:
- apps/mana/apps/web/src/routes/(app)/articles/ (9 Routes inkl. (tabs),
  [id], add, import, import/[jobId], settings)
- apps/mana/apps/web/src/lib/modules/articles/ (5 Stores, Queries,
  Collections, Types, Tools, Components, Widgets, ArticlesTabShell,
  consume-pickup, tab-context, parse-urls)
- apps/mana/apps/web/src/lib/i18n/locales/articles/ (DE/EN/ES/FR/IT)

Backend entfernt:
- apps/api/src/modules/articles/ (routes, import-worker, import-projection,
  import-extractor, consent-wall, field-meta, plus Tests)
- apps/api/src/index.ts: articlesRoutes + startArticleImportWorker raus
- apps/api/src/lib/metrics.ts: 5 articles-Metrics raus
  (articlesImportTicks/Items/Extract/JobsCompleted/PickupGc)

"Save-to-Articles"-Features in anderen Modulen entfernt
(User kann später direkt in pageta speichern via Share-Sheet):
- news-research/ListView + routes/(app)/news-research/+page.svelte:
  "Speichern"-Button raus
- writing/tools.ts: save_draft_as_article-Tool raus
- writing/components/ExportMenu.svelte: "Als Artikel speichern"-Option raus
- writing/components/ReferencePicker.svelte: 'article'-Mode raus
- writing/components/ReferenceChip.svelte: KIND_ICON/LABEL ohne 'article'
- writing/utils/reference-resolver.ts: resolveArticle + 'article'-case raus
- writing/utils/reference-resolver.test.ts: kind: 'article' → 'note'
  in Aggregate-Budget-Tests
- writing/utils/prompt-builder.test.ts: 'article'-Resolved-Reference raus
- writing/views/DetailView.svelte: 'articles'-published-Chip raus
- writing/types.ts: DraftReferenceKind ohne 'article',
  DraftPublishModule ohne 'articles'

Aktualisiert (Cross-Refs raus):
- module-registry.ts (articlesModuleConfig)
- module-registry.test.ts (articles-Tabellen + sync-name-Mappings)
- data-layer-listeners.ts (startArticlePickupConsumer)
- app-registry/apps.ts (registerApp 'articles')
- packages/shared-branding/src/mana-apps.ts (articles-Eintrag)
- components/dashboard/widget-registry.ts (ArticlesUnreadWidget)
- types/dashboard.ts (WidgetType 'articles-unread')
- data/crypto/registry.ts (LocalArticle/LocalHighlight)
- data/crypto/plaintext-allowlist.ts (articleTags/articleImportJobs/
  articleImportItems/articleExtractPickup)
- data/tools/init.ts (articlesTools)

NICHT angefasst (mit Absicht):
- data/database.ts db.version()-Stores — Schema-Snapshots sind frozen.
  Tabellen articles, articleHighlights, articleTags, articleImportJobs,
  articleImportItems, articleExtractPickup bleiben im IndexedDB-Schema,
  werden aber nicht mehr beschrieben.
- packages/shared-branding/src/app-icons.ts APP_ICONS.articles (für
  Native-PNG-Generator, harmlos).
- apps/api/src/lib/sync-db.ts Z6 Kommentar (historisches Beispiel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-19 16:43:54 +02:00
parent 001548c74d
commit 0112161e78
82 changed files with 21 additions and 9057 deletions

View file

@ -35,8 +35,6 @@ import { storageRoutes } from './modules/storage/routes';
import { todoRoutes } from './modules/todo/routes';
import { guidesRoutes } from './modules/guides/routes';
import { newsResearchRoutes } from './modules/news-research/routes';
import { articlesRoutes } from './modules/articles/routes';
import { startArticleImportWorker } from './modules/articles/import-worker';
import { tracesRoutes } from './modules/traces/routes';
import { writingRoutes } from './modules/writing/routes';
import { presiRoutes } from './modules/presi/routes';
@ -129,7 +127,6 @@ app.route('/api/v1/storage', storageRoutes);
app.route('/api/v1/todo', todoRoutes);
app.route('/api/v1/guides', guidesRoutes);
app.route('/api/v1/news-research', newsResearchRoutes);
app.route('/api/v1/articles', articlesRoutes);
app.route('/api/v1/traces', tracesRoutes);
app.route('/api/v1/presi', presiRoutes);
app.route('/api/v1/research', researchRoutes);
@ -139,11 +136,6 @@ app.route('/api/v1/writing', writingRoutes);
app.route('/api/v1/personas/admin', personasAdminRoutes);
// ─── Background Workers ─────────────────────────────────────
// Articles bulk-import: ticks every 2s, advisory-lock-gated so multiple
// apps/api replicas never double-process. See
// docs/plans/articles-bulk-import.md.
startArticleImportWorker();
// Forms wave-cron (M10d): scans unlisted snapshots with internal_meta
// for forms-recurrence configs, fires due waves via mana-mail's
// internal bulk-send route. Advisory-lock-gated. See

View file

@ -104,71 +104,3 @@ export const websitePublicReadAge = new Histogram({
buckets: [1, 10, 60, 300, 1800, 3600, 21600, 86400],
registers: [register],
});
// ── Articles bulk-import worker ─────────────────────────
/**
* Every worker tick, regardless of outcome. `result`:
* - `processed` lock acquired, jobs scanned
* - `skipped` advisory lock taken by another instance
* - `error` tick threw (logged + rethrown)
*/
export const articlesImportTicksTotal = new Counter({
name: 'mana_api_articles_import_ticks_total',
help: 'Articles bulk-import worker tick outcomes.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* Each per-item terminal-state transition the worker observes.
* `result`:
* - `extracted` server fetch + Readability succeeded, pickup row written
* - `error` 3 attempts exhausted, item parked as 'error'
* - `consent_wall` extracted but flagged probable_consent_wall
* - `cancelled` flipped from pending cancelled because parent
* job was cancelled mid-flight
*/
export const articlesImportItemsTotal = new Counter({
name: 'mana_api_articles_import_items_total',
help: 'Articles bulk-import items by terminal-from-worker state.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* End-to-end latency of one extractFromUrl call (network fetch +
* JSDOM parse + Readability). Exclude consent-wall flagging that's
* a synchronous post-process. Buckets cover anything from a snappy
* blog (250ms) to the shared-rss timeout ceiling (15s).
*/
export const articlesImportExtractDuration = new Histogram({
name: 'mana_api_articles_import_extract_duration_seconds',
help: 'extractFromUrl roundtrip time inside the bulk-import worker.',
buckets: [0.25, 0.5, 1, 2, 4, 8, 15, 30],
registers: [register],
});
/**
* Job-completion counter. `result`:
* - `done` every item terminal, status flipped to done
* - `cancelled` user cancelled before completion
*/
export const articlesImportJobsCompletedTotal = new Counter({
name: 'mana_api_articles_import_jobs_completed_total',
help: 'Articles bulk-import jobs by terminal status.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* Pickup-row GC sweep how many stale rows were hard-deleted on each
* 30-tick run. Steady-state should be 0 (consumer drains them within
* seconds); a non-zero value over time signals a stuck consumer
* somewhere (closed tabs, broken Web-Lock).
*/
export const articlesImportPickupGcRows = new Counter({
name: 'mana_api_articles_import_pickup_gc_rows_total',
help: 'articleExtractPickup rows hard-deleted by the worker GC sweep.',
registers: [register],
});

View file

@ -1,47 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { looksLikeConsentWall } from './consent-wall';
describe('looksLikeConsentWall', () => {
it('flags short text containing German consent vocabulary', () => {
const text =
'Cookies zustimmen — Wir und unsere Partner speichern Informationen auf einem Endgerät.';
expect(looksLikeConsentWall(text, 14)).toBe(true);
});
it('flags short English consent dialogs', () => {
const text = 'Please accept all cookies to continue using this website.';
expect(looksLikeConsentWall(text, 9)).toBe(true);
});
it('flags JavaScript-disabled walls', () => {
const text = 'JavaScript is disabled. Please enable JavaScript to continue.';
expect(looksLikeConsentWall(text, 7)).toBe(true);
});
it('does NOT flag long articles even if they mention cookies', () => {
// Long-form article that happens to mention cookies in body. The
// heuristic only fires below the wordcount threshold (300) so a
// real article about cookies isn't misclassified.
const text = 'cookie consent ' + 'lorem '.repeat(400);
expect(looksLikeConsentWall(text, 800)).toBe(false);
});
it('does NOT flag short text without consent vocabulary', () => {
const text = 'A short blog post about hiking trails in the Black Forest.';
expect(looksLikeConsentWall(text, 11)).toBe(false);
});
it('is case-insensitive', () => {
const text = 'COOKIES ZUSTIMMEN — KLICKE HIER';
expect(looksLikeConsentWall(text, 4)).toBe(true);
});
it('returns false on empty content', () => {
expect(looksLikeConsentWall('', 0)).toBe(false);
});
it('returns false at exactly the wordcount threshold (boundary check)', () => {
const text = 'cookie consent ' + 'lorem '.repeat(300);
expect(looksLikeConsentWall(text, 300)).toBe(false);
});
});

View file

@ -1,37 +0,0 @@
/**
* Consent-wall heuristic shared by every server-side article-extract
* path:
* - `/api/v1/articles/extract` and `/extract/html` (single-URL)
* - The bulk-import worker's `extractOneItem` (background)
*
* When the extracted text is suspiciously short AND contains GDPR /
* cookie-consent vocabulary, the server's anonymous fetch most likely
* hit a consent dialog instead of the article itself. The caller can
* use the flag to nudge the user toward the browser-HTML bookmarklet
* (which fetches with the user's existing session cookies) rather
* than silently persisting the GDPR overlay text as the article body.
*/
const CONSENT_KEYWORDS = [
'cookies zustimmen',
'cookie consent',
'zustimmung',
'accept all cookies',
'consent to the use',
'enable javascript',
'javascript is disabled',
'please enable',
'privacy center',
'datenschutz­einstellungen',
'datenschutzeinstellungen',
];
/** Wordcount floor below which the heuristic is considered. Real
* articles are typically >300 words; consent dialogs are <50. */
const CONSENT_WORDCOUNT_THRESHOLD = 300;
export function looksLikeConsentWall(content: string, wordCount: number): boolean {
if (wordCount >= CONSENT_WORDCOUNT_THRESHOLD) return false;
const haystack = content.toLowerCase();
return CONSENT_KEYWORDS.some((needle) => haystack.includes(needle));
}

View file

@ -1,51 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { fieldMetaTime } from './field-meta';
describe('fieldMetaTime — wire-shape adapter for sync_changes.field_meta', () => {
it('passes through legacy plain ISO strings unchanged', () => {
expect(fieldMetaTime('2026-04-28T21:14:30.000Z')).toBe('2026-04-28T21:14:30.000Z');
});
it('extracts the `at` field from F3 object stamps', () => {
expect(
fieldMetaTime({
at: '2026-04-28T21:14:30.000Z',
actor: { kind: 'system', principalId: 'system:foo', displayName: 'Foo' },
origin: 'system',
})
).toBe('2026-04-28T21:14:30.000Z');
});
it('returns "" for undefined / null (so callers can fall back)', () => {
expect(fieldMetaTime(undefined)).toBe('');
expect(fieldMetaTime(null)).toBe('');
});
it('returns "" for malformed objects without an at-string', () => {
expect(fieldMetaTime({})).toBe('');
expect(fieldMetaTime({ at: 12345 })).toBe('');
expect(fieldMetaTime({ at: null })).toBe('');
});
it('returns "" for non-string non-object inputs', () => {
expect(fieldMetaTime(42)).toBe('');
expect(fieldMetaTime(true)).toBe('');
expect(fieldMetaTime([])).toBe('');
});
// Regression: this is the bug that triggered the cross-service fix.
// Before fieldMetaTime, a string >= object compare evaluated to false
// stably and the older value won. Now both shapes fold to comparable
// ISO strings.
it('makes string-vs-object comparison work correctly across both shapes', () => {
const earlierLegacy = '2026-04-28T21:00:00.000Z';
const laterF3 = {
at: '2026-04-28T22:00:00.000Z',
actor: { kind: 'user', principalId: 'u', displayName: 'Du' },
origin: 'user',
};
// The F3 stamp is later in time, so its normalised form must
// compare strictly greater than the legacy stamp.
expect(fieldMetaTime(laterF3) > fieldMetaTime(earlierLegacy)).toBe(true);
});
});

View file

@ -1,32 +0,0 @@
/**
* Wire-shape adapter for `sync_changes.field_meta`.
*
* Two shapes coexist on the wire today:
*
* - Legacy plaintext writes: { state: 'ISO-8601' }
* - Field-meta-overhaul (F3): { state: { at, actor, origin } }
*
* Any LWW projection that string-compares per-field timestamps MUST
* fold both into a comparable form, otherwise the moment one side is
* an F3 object the comparison becomes `'[object Object]' >= 'ISO…'`
* (false), the older value wins and the projection lies.
*
* Sister helper at `services/mana-ai/src/db/field-meta.ts` same
* logic, deliberately duplicated. Both services treat sync_changes as
* a read-only event log; sharing infrastructure code across services
* (apps/api services/mana-ai) is out of scope.
*/
/** Returns the ISO-string timestamp of a single `field_meta[k]` slot,
* regardless of whether the wire format is the legacy plain string
* or the F3 `{ at, actor, origin }` object. Returns the empty string
* when no usable value is present so the LWW comparison treats the
* field as never-stamped (callers fall back to row.created_at). */
export function fieldMetaTime(meta: unknown): string {
if (typeof meta === 'string') return meta;
if (meta && typeof meta === 'object') {
const at = (meta as { at?: unknown }).at;
if (typeof at === 'string') return at;
}
return '';
}

View file

@ -1,226 +0,0 @@
/**
* Articles Bulk-Import per-item extraction + write-back.
*
* For one `articleImportItems` row in state='pending':
*
* 1. Flip to state='extracting' (so other ticks / the UI see progress).
* 2. Run `extractFromUrl` against the URL.
* 3a. On success write a `articleExtractPickup` row carrying the
* full ExtractedArticle payload + flip the item to 'extracted'.
* The client-side pickup-consumer picks it up, encrypts the
* article into the user's IndexedDB, and flips the item to 'saved'
* (or 'consent-wall' if the warning fired).
* 3b. On failure bump `attempts`, flip back to 'pending' if
* attempts < 3, else flip to state='error' with the technical
* error message.
*
* Every state-change is one `sync_changes` row attributed to the
* `system:articles-import-worker` actor (built inline below kept out
* of the shared-ai SystemSource union for now to keep the worker self-
* contained; can be hoisted later). Origin is `'system'` so the
* conflict-detection gate on the client doesn't surface these as
* user-visible toasts.
*
* Plan: docs/plans/articles-bulk-import.md.
*/
import { extractFromUrl } from '@mana/shared-rss';
import {
makeFieldMeta,
makeSystemActor,
originFromActor,
SYSTEM_ARTICLES_IMPORT_WORKER,
type Actor,
type FieldOrigin,
} from '@mana/shared-ai';
import { getSyncConnection } from '../../lib/sync-db';
import { articlesImportExtractDuration, articlesImportItemsTotal } from '../../lib/metrics';
import { looksLikeConsentWall } from './consent-wall';
import type { ImportItemRow } from './import-projection';
const MAX_ATTEMPTS = 3;
const CLIENT_ID = 'articles-import-worker';
/** System-actor blob stamped on every worker write sourced from the
* blessed SystemSource union in @mana/shared-ai so the actor.ts audit
* + Workbench filters know about it. */
const WORKER_ACTOR: Actor = makeSystemActor(SYSTEM_ARTICLES_IMPORT_WORKER);
const WORKER_ORIGIN: FieldOrigin = originFromActor(WORKER_ACTOR);
export interface ExtractStats {
itemId: string;
terminal: 'pending' | 'extracted' | 'error';
}
/**
* Run one extraction round-trip for a single item. Idempotent at the
* sync_changes level if two ticks race the same item the field-LWW
* merge yields a single coherent state on the client.
*/
export async function extractOneItem(item: ImportItemRow): Promise<ExtractStats> {
if (item.state !== 'pending') {
return {
itemId: item.id,
terminal: item.state === 'error' ? 'error' : 'extracted',
};
}
// Step 1 — claim. Flip the item to 'extracting' before the slow
// fetch so concurrent ticks (and the UI) see we own it.
const nowClaim = new Date().toISOString();
await writeItemUpdate(item.userId, item.id, {
state: 'extracting',
lastAttemptAt: nowClaim,
attempts: item.attempts + 1,
});
// Step 2 — fetch + parse. Hard-failure path returns null; we treat
// that as a single failed attempt and recycle.
const extractStart = Date.now();
const extracted = await extractFromUrl(item.url);
articlesImportExtractDuration.observe((Date.now() - extractStart) / 1000);
const nowDone = new Date().toISOString();
if (!extracted) {
const nextAttempts = item.attempts + 1;
const nextState = nextAttempts >= MAX_ATTEMPTS ? 'error' : 'pending';
await writeItemUpdate(item.userId, item.id, {
state: nextState,
error: nextState === 'error' ? 'Extraktion fehlgeschlagen nach mehreren Versuchen.' : null,
lastAttemptAt: nowDone,
});
if (nextState === 'error') {
articlesImportItemsTotal.inc({ result: 'error' });
}
return { itemId: item.id, terminal: nextState === 'error' ? 'error' : 'pending' };
}
// Step 3 — write the Pickup row (server payload for the client) and
// flip item state to 'extracted' so the consume-pickup path picks it
// up. Pickup row first so a client liveQuery seeing the 'extracted'
// state can immediately find the matching pickup payload.
const pickupId = `pickup-${item.id}`;
const wordCount = extracted.wordCount ?? 0;
const readingTimeMinutes = extracted.readingTimeMinutes ?? 0;
const warning = looksLikeConsentWall(extracted.content, wordCount)
? 'probable_consent_wall'
: null;
await writePickupInsert(item.userId, pickupId, {
itemId: item.id,
spaceId: item.spaceId ?? null,
payload: {
originalUrl: item.url,
title: extracted.title ?? '',
excerpt: extracted.excerpt ?? null,
content: extracted.content,
htmlContent: extracted.htmlContent ?? '',
author: extracted.byline ?? null,
siteName: extracted.siteName ?? null,
wordCount,
readingTimeMinutes,
...(warning && { warning }),
},
});
await writeItemUpdate(item.userId, item.id, {
state: 'extracted',
warning,
error: null,
lastAttemptAt: nowDone,
});
articlesImportItemsTotal.inc({ result: warning ? 'consent_wall' : 'extracted' });
return { itemId: item.id, terminal: 'extracted' };
}
// ─── Sync-changes write helpers (worker-attributed) ──────────
/**
* Worker-attributed update on an `articleImportItems` row. Exported so
* the worker tick can flip pending items to 'cancelled' when the parent
* job is cancelled, without going through the extraction pipeline.
*/
export async function writeItemUpdate(
userId: string,
itemId: string,
patch: Record<string, unknown>
): Promise<void> {
await insertSyncChange({
userId,
recordId: itemId,
appId: 'articles',
tableName: 'articleImportItems',
op: 'update',
data: patch,
});
}
async function writePickupInsert(
userId: string,
pickupId: string,
data: Record<string, unknown>
): Promise<void> {
await insertSyncChange({
userId,
recordId: pickupId,
appId: 'articles',
tableName: 'articleExtractPickup',
op: 'insert',
data,
});
}
/**
* Worker-attributed update on an `articleImportJobs` row. Counter-only
* for now (savedCount, errorCount, ) plus status flips like
* 'queued' 'running' and 'running' 'done'.
*/
export async function writeJobUpdate(
userId: string,
jobId: string,
patch: Record<string, unknown>
): Promise<void> {
await insertSyncChange({
userId,
recordId: jobId,
appId: 'articles',
tableName: 'articleImportJobs',
op: 'update',
data: patch,
});
}
interface InsertParams {
userId: string;
recordId: string;
appId: string;
tableName: string;
op: 'insert' | 'update' | 'delete';
data: Record<string, unknown>;
}
async function insertSyncChange(params: InsertParams): Promise<void> {
const sql = getSyncConnection();
const now = new Date().toISOString();
const fieldMeta: Record<string, unknown> = {};
for (const key of Object.keys(params.data)) {
fieldMeta[key] = makeFieldMeta(now, WORKER_ACTOR, WORKER_ORIGIN);
}
const actorJson = WORKER_ACTOR as unknown;
const dataJson = params.data as unknown;
const fmJson = fieldMeta as unknown;
await sql.begin(async (tx) => {
await tx`SELECT set_config('app.current_user_id', ${params.userId}, true)`;
await tx`
INSERT INTO sync_changes
(app_id, table_name, record_id, user_id, op, data, field_meta, client_id, schema_version, actor, origin)
VALUES
(${params.appId}, ${params.tableName}, ${params.recordId}, ${params.userId}, ${params.op},
${tx.json(dataJson as never)}, ${tx.json(fmJson as never)},
${CLIENT_ID}, 1, ${tx.json(actorJson as never)}, ${WORKER_ORIGIN})
`;
});
}
// looksLikeConsentWall lives in ./consent-wall.ts — shared with routes.ts.

View file

@ -1,250 +0,0 @@
/**
* Articles Bulk-Import sync_changes live record projection.
*
* Mirror of `services/mana-ai/src/db/missions-projection.ts` and
* `apps/api/src/lib/sync-db.ts:readLatestRecords()`, specialised for the
* two tables the import-worker tick reads each cycle:
*
* articleImportJobs to find running jobs whose lease is free
* articleImportItems to find pending items inside those jobs
*
* No materialized snapshots yet this is the simple "replay every row
* for these tables" path. The total volume is small (a few hundred rows
* per active job, all import history per user) and the worker tick is
* the only consumer. If the table grows we can plug in the same
* `mission_snapshots` pattern mana-ai uses; the projection API stays
* the same.
*
* Plan: docs/plans/articles-bulk-import.md.
*/
import { getSyncConnection } from '../../lib/sync-db';
import { fieldMetaTime } from './field-meta';
type Row = Record<string, unknown>;
interface ChangeRow {
user_id: string;
record_id: string;
op: string;
data: Row | null;
/** See `./field-meta.ts` wire shape is two-tone (legacy ISO string
* vs. F3 `{at, actor, origin}` object). */
field_meta: Record<string, unknown> | null;
created_at: Date;
}
export interface ImportJobRow {
id: string;
userId: string;
spaceId: string | null;
totalUrls: number;
status: 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
startedAt: string | null;
finishedAt: string | null;
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
}
export type ImportItemState =
| 'pending'
| 'extracting'
| 'extracted'
| 'saved'
| 'duplicate'
| 'consent-wall'
| 'error'
| 'cancelled';
export interface ImportItemRow {
id: string;
userId: string;
spaceId: string | null;
jobId: string;
idx: number;
url: string;
state: ImportItemState;
articleId: string | null;
warning: 'probable_consent_wall' | null;
error: string | null;
attempts: number;
lastAttemptAt: string | null;
}
/**
* Cross-user scan: which jobs need attention this tick. RLS is
* intentionally NOT applied the worker is a privileged consumer that
* needs to see all users' running jobs in one pass. Per-user RLS
* scoping is applied on the write-back path in import-extractor.ts.
*/
export async function listClaimableJobs(): Promise<ImportJobRow[]> {
const sql = getSyncConnection();
const rows = await sql<ChangeRow[]>`
SELECT user_id, record_id, op, data, field_meta, created_at
FROM sync_changes
WHERE app_id = 'articles' AND table_name = 'articleImportJobs'
ORDER BY user_id, record_id, created_at ASC
`;
const out: ImportJobRow[] = [];
for (const m of mergeByUserAndRecord(rows).values()) {
const job = projectJob(m.userId, m.recordId, m.merged);
if (!job) continue;
if (job.status !== 'running' && job.status !== 'queued') continue;
out.push(job);
}
return out;
}
/**
* Per-job item scan. Returns ALL items so the worker can compute
* job-completion + counter deltas in one pass.
*/
export async function listItemsForJob(userId: string, jobId: string): Promise<ImportItemRow[]> {
const sql = getSyncConnection();
const rows = await sql<ChangeRow[]>`
SELECT user_id, record_id, op, data, field_meta, created_at
FROM sync_changes
WHERE app_id = 'articles'
AND table_name = 'articleImportItems'
AND user_id = ${userId}
ORDER BY record_id, created_at ASC
`;
const out: ImportItemRow[] = [];
for (const m of mergeByUserAndRecord(rows).values()) {
const item = projectItem(m.userId, m.recordId, m.merged);
if (!item || item.jobId !== jobId) continue;
out.push(item);
}
out.sort((a, b) => a.idx - b.idx);
return out;
}
// ─── Internal: LWW merge per (userId, recordId) ──────────────
interface MergedEntry {
userId: string;
recordId: string;
merged: Row | null;
}
function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map<string, MergedEntry> {
const out = new Map<string, MergedEntry>();
type Cur = {
key: string;
userId: string;
recordId: string;
record: Row | null;
/** Per-field LWW timestamps (normalised to ISO strings see
* fieldMetaTime). Both wire shapes are folded down to plain
* strings here so the projection comparison stays trivial. */
fm: Record<string, string>;
};
let current: Cur | null = null;
const flush = (c: Cur) => {
out.set(c.key, { userId: c.userId, recordId: c.recordId, merged: c.record });
};
for (const r of rows) {
const key = `${r.user_id}:${r.record_id}`;
if (!current || current.key !== key) {
if (current) flush(current);
current = { key, userId: r.user_id, recordId: r.record_id, record: null, fm: {} };
}
if (r.op === 'delete') {
current.record = null;
continue;
}
if (!r.data) continue;
const rowCreatedAt = r.created_at.toISOString();
if (!current.record) {
current.record = { id: r.record_id, ...r.data };
const initFM = r.field_meta ?? {};
current.fm = {};
for (const k of Object.keys(initFM)) {
current.fm[k] = fieldMetaTime(initFM[k]) || rowCreatedAt;
}
continue;
}
const rowFM = r.field_meta ?? {};
for (const [k, v] of Object.entries(r.data)) {
const serverTime = fieldMetaTime(rowFM[k]) || rowCreatedAt;
const localTime = current.fm[k] ?? '';
if (serverTime >= localTime) {
current.record[k] = v;
current.fm[k] = serverTime;
}
}
}
if (current) flush(current);
return out;
}
function projectJob(userId: string, recordId: string, merged: Row | null): ImportJobRow | null {
if (!merged || merged.deletedAt) return null;
const totalUrls = num(merged.totalUrls);
const status = str(merged.status);
if (totalUrls == null || !isJobStatus(status)) return null;
return {
id: recordId,
userId,
spaceId: optStr(merged.spaceId),
totalUrls,
status,
startedAt: optStr(merged.startedAt),
finishedAt: optStr(merged.finishedAt),
savedCount: num(merged.savedCount) ?? 0,
duplicateCount: num(merged.duplicateCount) ?? 0,
errorCount: num(merged.errorCount) ?? 0,
warningCount: num(merged.warningCount) ?? 0,
};
}
function projectItem(userId: string, recordId: string, merged: Row | null): ImportItemRow | null {
if (!merged || merged.deletedAt) return null;
const jobId = str(merged.jobId);
const url = str(merged.url);
const state = str(merged.state);
const idx = num(merged.idx);
if (!jobId || !url || !isItemState(state) || idx == null) return null;
return {
id: recordId,
userId,
spaceId: optStr(merged.spaceId),
jobId,
idx,
url,
state,
articleId: optStr(merged.articleId),
warning: merged.warning === 'probable_consent_wall' ? 'probable_consent_wall' : null,
error: optStr(merged.error),
attempts: num(merged.attempts) ?? 0,
lastAttemptAt: optStr(merged.lastAttemptAt),
};
}
function isJobStatus(s: string): s is ImportJobRow['status'] {
return s === 'queued' || s === 'running' || s === 'paused' || s === 'done' || s === 'cancelled';
}
function isItemState(s: string): s is ImportItemState {
return (
s === 'pending' ||
s === 'extracting' ||
s === 'extracted' ||
s === 'saved' ||
s === 'duplicate' ||
s === 'consent-wall' ||
s === 'error' ||
s === 'cancelled'
);
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
function str(v: unknown): string {
return typeof v === 'string' ? v : '';
}
function optStr(v: unknown): string | null {
return typeof v === 'string' && v ? v : null;
}

View file

@ -1,80 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { countByState } from './import-worker';
import type { ImportItemRow } from './import-projection';
function item(state: ImportItemRow['state'], idx = 0): ImportItemRow {
return {
id: `i-${idx}`,
userId: 'u-1',
spaceId: 'sp-1',
jobId: 'j-1',
idx,
url: `https://example.com/${idx}`,
state,
articleId: null,
warning: null,
error: null,
attempts: 0,
lastAttemptAt: null,
};
}
describe('countByState — worker job-counter rollup', () => {
it('returns zeros for empty input + allTerminal=false', () => {
const c = countByState([]);
expect(c).toEqual({
saved: 0,
duplicate: 0,
error: 0,
consentWall: 0,
cancelled: 0,
allTerminal: false,
});
});
it('counts each terminal state independently', () => {
const c = countByState([
item('saved', 0),
item('saved', 1),
item('duplicate', 2),
item('error', 3),
item('cancelled', 4),
]);
expect(c.saved).toBe(2);
expect(c.duplicate).toBe(1);
expect(c.error).toBe(1);
expect(c.cancelled).toBe(1);
expect(c.allTerminal).toBe(true);
});
it('treats consent-wall as semantically saved (so progress UI advances)', () => {
// One real-saved + two consent-wall = three "saved" from the
// user's perspective, but the warning counter tracks the wall hits.
const c = countByState([item('saved', 0), item('consent-wall', 1), item('consent-wall', 2)]);
expect(c.saved).toBe(3);
expect(c.consentWall).toBe(2);
expect(c.allTerminal).toBe(true);
});
it('does not flag allTerminal when any item is non-terminal', () => {
const states: ImportItemRow['state'][] = ['pending', 'extracting', 'extracted'];
for (const nonTerminal of states) {
const c = countByState([item('saved', 0), item(nonTerminal, 1)]);
expect(c.allTerminal).toBe(false);
}
});
it('preserves the saved + consent-wall sum when both are present', () => {
// Regression check: saved must include consent-wall items so the
// finished-counter UI doesn't off-by-one.
const c = countByState([
item('saved', 0),
item('saved', 1),
item('consent-wall', 2),
item('error', 3),
]);
expect(c.saved).toBe(3); // 2 saved + 1 consent-wall
expect(c.consentWall).toBe(1);
expect(c.error).toBe(1);
});
});

View file

@ -1,327 +0,0 @@
/**
* Articles Bulk-Import background worker.
*
* Boots from `apps/api/src/index.ts`. On every tick:
*
* 1. Try `pg_try_advisory_xact_lock` on a fixed key. If another
* apps/api instance already holds it, skip this tick. The lock
* is per-transaction so we never need a heartbeat a crashed
* worker's tx auto-aborts and the next tick claims it cleanly.
* 2. Project the live state of `articleImportJobs` and pick the
* ones still 'queued' or 'running'.
* 3. For each job: project items, take up to N pending items,
* extract concurrently. Each extraction writes a Pickup row +
* flips the item state via `import-extractor.ts`.
* 4. Fold terminal item states into job counters
* (savedCount / duplicateCount / errorCount / warningCount).
* When every item is terminal, flip the job to 'done'.
*
* No own state every meaningful transition is a `sync_changes` row.
* The worker is therefore stateless across restarts.
*
* Plan: docs/plans/articles-bulk-import.md.
*/
// Operational logs (boot, tick errors, GC summary, stale-recovery
// sweep) go to console intentionally — same pattern as
// services/mana-ai/src/cron/tick.ts. Captured by the apps/api stdout
// aggregator; structured signal lives in Prometheus counters.
/* eslint-disable no-console */
import { getSyncConnection } from '../../lib/sync-db';
import {
articlesImportJobsCompletedTotal,
articlesImportPickupGcRows,
articlesImportTicksTotal,
} from '../../lib/metrics';
import {
listClaimableJobs,
listItemsForJob,
type ImportItemRow,
type ImportJobRow,
} from './import-projection';
import { extractOneItem, writeItemUpdate, writeJobUpdate } from './import-extractor';
/** Counts ticks so the pickup-GC sweep can run every Nth one rather
* than on every 2-second cycle (the DELETE is cheap but not free). */
let tickCount = 0;
/** Run pickup-GC every 30 ticks ≈ once per minute. */
const PICKUP_GC_EVERY_N_TICKS = 30;
const TICK_INTERVAL_MS = 2_000;
const PER_JOB_CONCURRENCY = 3;
/**
* If an item has been in `state='extracting'` longer than this without
* a follow-up state-write, it's orphaned (worker crashed mid-fetch,
* pod restart, OOM, ) and gets bounced back to `pending` so the next
* tick can re-claim it.
*
* Tuned so a slow but live extraction (15 s shared-rss fetch timeout +
* a few seconds of JSDOM parse on a 2 MB page) doesn't reset
* prematurely 5 minutes is comfortable headroom.
*/
const STALE_EXTRACTING_MS = 5 * 60 * 1000;
/** TTL for `articleExtractPickup` rows. The pickup-consumer normally
* deletes them seconds after the worker writes them; anything older
* than this is garbage from a stuck consumer (all tabs closed,
* Web-Lock mismatch, ) and would otherwise accumulate without bound. */
const PICKUP_TTL_MS = 24 * 60 * 60 * 1000;
/** Fixed int8 lock key — derived from the ASCII bytes of 'ARTI'. */
const ADVISORY_LOCK_KEY = 0x4152_5449;
let timer: ReturnType<typeof setInterval> | null = null;
let running = false;
/**
* Start the recurring tick. Idempotent safe to call multiple times.
* Intended to be called once from `apps/api/src/index.ts` at boot.
*
* Disable via `ARTICLES_IMPORT_WORKER_DISABLED=true` (for tests, or
* when running multiple apps/api instances and you want to designate
* a different one as the worker).
*/
export function startArticleImportWorker(): void {
if (timer) return;
if (process.env.ARTICLES_IMPORT_WORKER_DISABLED === 'true') {
console.log('[articles-import] worker disabled via env');
return;
}
console.log(
`[articles-import] worker starting — tick=${TICK_INTERVAL_MS}ms, concurrency=${PER_JOB_CONCURRENCY}`
);
timer = setInterval(() => {
void runTickGuarded();
}, TICK_INTERVAL_MS);
}
export function stopArticleImportWorker(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
async function runTickGuarded(): Promise<void> {
if (running) return;
running = true;
try {
const result = await runTickOnce();
articlesImportTicksTotal.inc({ result: result.skipped ? 'skipped' : 'processed' });
if (typeof result.pickupGcRows === 'number' && result.pickupGcRows > 0) {
articlesImportPickupGcRows.inc(result.pickupGcRows);
}
} catch (err) {
articlesImportTicksTotal.inc({ result: 'error' });
console.error('[articles-import] tick error:', err);
} finally {
running = false;
}
}
/**
* One tick body. Exported for tests + a potential
* `/internal/articles-import/tick`-style admin route.
*/
export async function runTickOnce(): Promise<{
skipped: boolean;
jobsConsidered: number;
itemsProcessed: number;
pickupGcRows?: number;
}> {
if (!(await tryAcquireLock())) {
return { skipped: true, jobsConsidered: 0, itemsProcessed: 0 };
}
tickCount++;
let pickupGcRows: number | undefined;
if (tickCount % PICKUP_GC_EVERY_N_TICKS === 0) {
pickupGcRows = await runPickupGc();
}
const jobs = await listClaimableJobs();
let itemsProcessed = 0;
for (const job of jobs) {
itemsProcessed += await processOneJob(job);
}
return { skipped: false, jobsConsidered: jobs.length, itemsProcessed, pickupGcRows };
}
/**
* Hard-delete pickup rows older than `PICKUP_TTL_MS`. The
* pickup-consumer on a healthy client removes each row seconds after
* the worker writes it; anything older is residue from a stuck
* consumer (all tabs closed, Web-Lock mismatch). Without this sweep
* the rows would accumulate without bound in sync_changes.
*
* Runs against `sync_changes` directly, not via a soft-delete on the
* row data pickup rows are server-write inbox only, never editable
* by users; a hard DELETE keeps the table tight.
*/
async function runPickupGc(): Promise<number> {
const sql = getSyncConnection();
const cutoff = new Date(Date.now() - PICKUP_TTL_MS).toISOString();
const rows = await sql<{ count: string }[]>`
WITH deleted AS (
DELETE FROM sync_changes
WHERE app_id = 'articles'
AND table_name = 'articleExtractPickup'
AND created_at < ${cutoff}
RETURNING 1
)
SELECT count(*)::text AS count FROM deleted
`;
const n = parseInt(rows[0]?.count ?? '0', 10);
if (n > 0) console.log(`[articles-import] pickup-gc: removed ${n} rows older than 24h`);
return n;
}
/**
* Brief advisory-lock probe via a single short transaction. Returns
* true if we won the probe that's a soft signal for "you're the
* worker for this tick"; the lock releases as the probe tx commits.
* For multi-instance deploys this is a soft-only coordination if
* two probes happen to interleave their work, the field-LWW merge on
* the client still produces a coherent state.
*/
async function tryAcquireLock(): Promise<boolean> {
const sql = getSyncConnection();
let acquired = false;
await sql.begin(async (tx) => {
const rows = await tx<{ acquired: boolean }[]>`
SELECT pg_try_advisory_xact_lock(${ADVISORY_LOCK_KEY}) AS acquired
`;
acquired = rows[0]?.acquired === true;
});
return acquired;
}
async function processOneJob(job: ImportJobRow): Promise<number> {
const items = await listItemsForJob(job.userId, job.id);
// Crash-recovery sweep — bounce items that have been 'extracting'
// for too long back to 'pending'. Without this, a worker that
// crashed (or got OOM'd, restarted mid-extract) leaves orphaned
// items in 'extracting' forever; the job never completes. Worker
// re-attribution happens via the next tick's claim path.
const now = Date.now();
for (const it of items) {
if (it.state !== 'extracting') continue;
const since = it.lastAttemptAt ? Date.parse(it.lastAttemptAt) : 0;
if (!Number.isFinite(since)) continue;
if (now - since < STALE_EXTRACTING_MS) continue;
console.warn(
`[articles-import] resetting stale extracting item ${it.id} (job=${job.id}) — ${Math.round((now - since) / 1000)}s old`
);
await writeItemUpdate(it.userId, it.id, { state: 'pending' });
}
// Flip 'queued' → 'running' so the UI shows progress.
if (job.status === 'queued') {
await writeJobUpdate(job.userId, job.id, {
status: 'running',
startedAt: new Date().toISOString(),
});
}
// Counter-derivation from current item states.
const counts = countByState(items);
const counterPatch: Record<string, unknown> = {};
let dirty = false;
if (counts.saved !== job.savedCount) {
counterPatch.savedCount = counts.saved;
dirty = true;
}
if (counts.duplicate !== job.duplicateCount) {
counterPatch.duplicateCount = counts.duplicate;
dirty = true;
}
if (counts.error !== job.errorCount) {
counterPatch.errorCount = counts.error;
dirty = true;
}
if (counts.consentWall !== job.warningCount) {
counterPatch.warningCount = counts.consentWall;
dirty = true;
}
if (counts.allTerminal && job.status !== 'done') {
counterPatch.status = 'done';
counterPatch.finishedAt = new Date().toISOString();
dirty = true;
articlesImportJobsCompletedTotal.inc({ result: 'done' });
}
if (dirty) {
await writeJobUpdate(job.userId, job.id, counterPatch);
}
if (counts.allTerminal) return 0;
// Cancelled → flip every still-pending item to 'cancelled'.
if (job.status === 'cancelled') {
const pending = items.filter((i) => i.state === 'pending');
for (const it of pending) {
await writeItemUpdate(it.userId, it.id, { state: 'cancelled' });
}
return pending.length;
}
// Paused → already-extracting items finish their roundtrip; nothing
// new gets claimed.
if (job.status === 'paused') return 0;
// Running → claim up to PER_JOB_CONCURRENCY pending items in
// parallel. We deliberately don't try to rescue 'extracting' items:
// if a worker died mid-fetch they stay 'extracting' forever for
// now. Future polish: time-out 'extracting' rows older than ~5min
// and bounce them back to 'pending'.
const claimable = items.filter((i) => i.state === 'pending').slice(0, PER_JOB_CONCURRENCY);
if (claimable.length === 0) return 0;
await Promise.allSettled(claimable.map((it) => extractOneItem(it)));
return claimable.length;
}
export interface StateCounts {
saved: number;
duplicate: number;
error: number;
consentWall: number;
cancelled: number;
allTerminal: boolean;
}
export function countByState(items: readonly ImportItemRow[]): StateCounts {
let saved = 0;
let duplicate = 0;
let error = 0;
let consentWall = 0;
let cancelled = 0;
let nonTerminal = 0;
for (const it of items) {
switch (it.state) {
case 'saved':
saved++;
break;
case 'duplicate':
duplicate++;
break;
case 'error':
error++;
break;
case 'consent-wall':
saved++; // consent-wall is "saved with warning" semantically
consentWall++;
break;
case 'cancelled':
cancelled++;
break;
default:
nonTerminal++;
}
}
return {
saved,
duplicate,
error,
consentWall,
cancelled,
allTerminal: items.length > 0 && nonTerminal === 0,
};
}

View file

@ -1,128 +0,0 @@
/**
* Articles module server-side URL extraction.
*
* Two endpoints, both thin wrappers around `@mana/shared-rss`:
*
* POST /extract server fetches the URL itself, then runs
* Readability on the HTML it got back. Works
* for simple sites but fails on anything behind
* a cookie-consent wall or a paywall the
* server has no user session.
* POST /extract/html client already has the rendered HTML (from a
* browser bookmarklet running in the user's
* own tab with all their cookies applied).
* Server just runs Readability on that. This
* is how we bypass Golem / Spiegel / Zeit /
* Heise-style consent dialogs: use the user's
* already-consented session, not the server's
* anonymous fetch.
*
* Consent-wall heuristic: when /extract returns a suspiciously short
* payload that contains consent-dialog vocabulary we still hand the
* extracted text back but flag it with `warning: 'probable_consent_wall'`
* so the client can offer the bookmarklet-v2 path instead of pretending
* a 4-line "Cookies zustimmen" blob is the article.
*/
import { Hono } from 'hono';
import { extractFromUrl, extractFromHtml } from '@mana/shared-rss';
import { looksLikeConsentWall } from './consent-wall';
const routes = new Hono();
function isValidHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
// POST /extract — server fetches the URL + extracts. Legacy path.
routes.post('/extract', async (c) => {
const body = await c.req.json<{ url?: string }>().catch(() => ({}) as { url?: string });
const url = body.url;
if (!url || typeof url !== 'string') {
return c.json({ error: 'URL is required' }, 400);
}
if (!isValidHttpUrl(url)) {
return c.json({ error: 'Invalid URL' }, 400);
}
const extracted = await extractFromUrl(url);
if (!extracted) {
return c.json({ error: 'Extraction failed' }, 502);
}
const warning = looksLikeConsentWall(extracted.content, extracted.wordCount)
? 'probable_consent_wall'
: undefined;
return c.json({
originalUrl: url,
title: extracted.title,
excerpt: extracted.excerpt,
content: extracted.content,
htmlContent: extracted.htmlContent,
author: extracted.byline,
siteName: extracted.siteName,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
...(warning && { warning }),
});
});
// POST /extract/html — client supplies HTML (from the user's browser
// tab, where cookies + JS rendering already happened). We only run
// Readability on it. Cap payload to 10 MiB so a pathological site
// can't exhaust server memory via the bookmarklet — typical rendered
// article HTML is 200-800 KB.
const MAX_HTML_BYTES = 10 * 1024 * 1024;
routes.post('/extract/html', async (c) => {
const body = await c.req
.json<{ url?: string; html?: string }>()
.catch(() => ({}) as { url?: string; html?: string });
const url = body.url;
const html = body.html;
if (!url || typeof url !== 'string') {
return c.json({ error: 'URL is required' }, 400);
}
if (!html || typeof html !== 'string') {
return c.json({ error: 'HTML is required' }, 400);
}
if (!isValidHttpUrl(url)) {
return c.json({ error: 'Invalid URL' }, 400);
}
if (html.length > MAX_HTML_BYTES) {
return c.json({ error: 'HTML payload too large' }, 413);
}
const extracted = await extractFromHtml(html, url);
if (!extracted) {
return c.json({ error: 'Extraction failed' }, 502);
}
// The consent-wall heuristic still applies here — a rare case is
// that the user bookmarklet-fires BEFORE the consent dialog is
// dismissed. Flag it so the client doesn't silently persist garbage.
const warning = looksLikeConsentWall(extracted.content, extracted.wordCount)
? 'probable_consent_wall'
: undefined;
return c.json({
originalUrl: url,
title: extracted.title,
excerpt: extracted.excerpt,
content: extracted.content,
htmlContent: extracted.htmlContent,
author: extracted.byline,
siteName: extracted.siteName,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
...(warning && { warning }),
});
});
export { routes as articlesRoutes };

View file

@ -729,42 +729,8 @@ registerApp({
},
});
registerApp({
id: 'articles',
name: 'Artikel',
color: '#F97316',
icon: BookOpen,
views: {
// ArticlesTabShell enthält intern alle drei Tabs (Übersicht /
// Leseliste / Highlights). Im Workbench-Karten-Kontext lassen
// sich die Tabs ohne Page-Navigation wechseln. In den direkten
// SvelteKit-Routen (/articles, /articles/list, /articles/highlights)
// wird dieselbe Shell mit passendem initialTab gemountet.
list: { load: () => import('$lib/modules/articles/ArticlesTabShell.svelte') },
detail: { load: () => import('$lib/modules/articles/views/DetailView.svelte') },
},
contextMenuActions: [
{
id: 'new-article',
label: 'URL speichern',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'articles', action: 'new' } })
),
},
],
collection: 'articles',
paramKey: 'articleId',
// dragType: 'article' absichtlich weggelassen — der DragType-Union in
// @mana/shared-ui/dnd kennt noch keinen 'article'-Slot. Wenn später
// Drag-to-calendar / Drag-to-todo gebraucht wird, erweitern wir den
// Union dort und hängen es hier ein.
getDisplayData: (item) => ({
title: (item.title as string) || 'Artikel',
subtitle: (item.siteName as string) || undefined,
}),
});
// Articles-Modul: dekommissioniert 2026-05-19, lebt als pageta standalone
// auf pageta.mana.how / pageta.com (Code/pageta).
registerApp({
id: 'research-lab',

View file

@ -25,7 +25,6 @@ import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte';
import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte';
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
import BroadcastsWidget from '$lib/modules/broadcasts/widgets/BroadcastsWidget.svelte';
@ -51,7 +50,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'day-timeline': DayTimelineWidget,
'activity-feed': ActivityFeedWidget,
period: PeriodWidget,
'articles-unread': ArticlesUnreadWidget,
'body-stats': BodyStatsWidget,
'invoices-open': InvoicesOpenWidget,
broadcasts: BroadcastsWidget,

View file

@ -21,10 +21,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'activities', // TODO: audit
'albumItems', // TODO: audit
'albums', // TODO: audit
'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags.
'articleImportJobs', // Bulk-import job header (counters, status, lease metadata). Pure operational state, no user-typed content. See docs/plans/articles-bulk-import.md.
'articleImportItems', // One row per URL in a bulk job. URL is plaintext by necessity — server-worker reads it without master-key access (same rationale as articles.originalUrl).
'articleExtractPickup', // Short-lived server-write inbox; the client picks up the extracted payload, encrypts it into the articles table, deletes the row. Plaintext by necessity (server has no master key); empty in steady state.
'automations', // TODO: audit
'boardViews', // TODO: audit
'budgets', // TODO: audit

View file

@ -87,7 +87,6 @@ import type {
LocalBroadcastTemplate,
LocalBroadcastSettings,
} from '../../modules/broadcasts/types';
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
import type { LocalMeImage } from '../../modules/profile/types';
import type {
LocalDraft,
@ -682,20 +681,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// and are themselves encrypted. Offsets + color + articleId are
// structural — the reader needs them for range scans and rendering.
//
// articleTags is intentionally NOT registered — pure FK junction
// (articleId, tagId), zero user-typed content. Tag names live in
// globalTags, which has its own encryption policy. Lives on the
// plaintext-allowlist alongside noteTags / eventTags / placeTags.
articles: entry<LocalArticle>([
'title',
'excerpt',
'content',
'htmlContent',
'author',
'userNote',
]),
articleHighlights: entry<LocalHighlight>(['text', 'note', 'contextBefore', 'contextAfter']),
// ─── Library ─────────────────────────────────────────────
// Reading / watching log with a kind discriminator (book / movie /
// series / comic) in one table. User-typed text (title, original

View file

@ -23,7 +23,6 @@ import { cleanupTombstones } from './quota';
import { pruneActivityLog } from './activity';
import { SYNC_TELEMETRY_EVENT, type SyncTelemetryDetail } from './sync-telemetry';
import { installConflictListener } from './conflict-store.svelte';
import { startArticlePickupConsumer } from '$lib/modules/articles/consume-pickup';
/** How often to run the tombstone cleanup. 24h is a comfortable cadence
* given that the cutoff is 30 days runs roughly once per app session. */
@ -107,12 +106,6 @@ export function installDataLayerListeners(): () => void {
// coalescing, auto-dismiss, and the restore-write path.
const disposeConflict = installConflictListener();
// ─── Articles bulk-import: pickup consumer ─────────────────
// Drains `articleExtractPickup` rows the server-worker drops for
// successful URL extractions. Web-Lock-coordinated for multi-tab
// safety. See docs/plans/articles-bulk-import.md.
const disposeArticlePickup = startArticlePickupConsumer();
// ─── Periodic cleanup loop ─────────────────────────────────
// Runs once on boot, then daily. Two independent jobs share the
// schedule so we never have a third interval competing for the same
@ -158,6 +151,5 @@ export function installDataLayerListeners(): () => void {
window.removeEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry);
window.clearInterval(cleanupTimer);
disposeConflict();
disposeArticlePickup();
};
}

View file

@ -254,14 +254,6 @@ describe('module-registry — snapshot', () => {
quiz: ['quizzes', 'quizQuestions', 'quizAttempts'],
profile: ['userContext', 'meImages'],
library: ['libraryEntries'],
articles: [
'articles',
'articleHighlights',
'articleTags',
'articleImportJobs',
'articleImportItems',
'articleExtractPickup',
],
invoices: ['invoices', 'invoiceClients', 'invoiceSettings'],
broadcasts: ['broadcastCampaigns', 'broadcastTemplates', 'broadcastSettings'],
wetter: ['wetterLocations', 'wetterSettings'],
@ -303,10 +295,6 @@ describe('module-registry — snapshot', () => {
playgroundMessages: 'messages',
quizQuestions: 'questions',
quizAttempts: 'attempts',
articleHighlights: 'highlights',
articleImportJobs: 'importJobs',
articleImportItems: 'importItems',
articleExtractPickup: 'extractPickup',
wetterLocations: 'locations',
wetterSettings: 'settings',
});

View file

@ -87,7 +87,6 @@ import { moodModuleConfig } from '$lib/modules/mood/module.config';
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { profileModuleConfig } from '$lib/modules/profile/module.config';
import { libraryModuleConfig } from '$lib/modules/library/module.config';
import { articlesModuleConfig } from '$lib/modules/articles/module.config';
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
@ -138,7 +137,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
quizModuleConfig,
profileModuleConfig,
libraryModuleConfig,
articlesModuleConfig,
invoicesModuleConfig,
broadcastModuleConfig,
wetterModuleConfig,

View file

@ -26,7 +26,6 @@ import { lastsTools } from '$lib/modules/lasts/tools';
import { guidesTools } from '$lib/modules/guides/tools';
import { inventoryTools } from '$lib/modules/inventory/tools';
import { newsResearchTools } from '$lib/modules/news-research/tools';
import { articlesTools } from '$lib/modules/articles/tools';
import { recipesTools } from '$lib/modules/recipes/tools';
import { questionsTools } from '$lib/modules/questions/tools';
import { meditateTools } from '$lib/modules/meditate/tools';
@ -71,7 +70,6 @@ export function initTools(): void {
registerTools(guidesTools);
registerTools(inventoryTools);
registerTools(newsResearchTools);
registerTools(articlesTools);
registerTools(recipesTools);
registerTools(questionsTools);
registerTools(meditateTools);

View file

@ -1,100 +0,0 @@
{
"detail_view": {
"page_title_html": "{title} — Mana",
"untitled_fallback": "Artikel",
"loading": "Lädt…",
"not_found": "Artikel nicht gefunden.",
"back_to_list": "Zurück zur Liste",
"meta_word_count": "{n} Wörter",
"meta_reading_minutes": "{n} min",
"tag_add_label": "Tag",
"tag_placeholder": "Tag suchen oder erstellen…",
"toolbar_aria": "Lese-Werkzeuge",
"back_aria": "Zurück zur Liste",
"back_tip": "Zurück zur Leseliste",
"font_smaller_aria": "Schrift kleiner",
"font_smaller_tip": "Schrift kleiner",
"font_larger_aria": "Schrift größer",
"font_larger_tip": "Schrift größer",
"font_serif_tip": "Serif-Schrift",
"font_serif_label": "Serif",
"font_sans_tip": "Sans-Serif-Schrift",
"font_sans_label": "Sans",
"theme_light_aria": "Heller Modus",
"theme_light_tip": "Heller Modus",
"theme_sepia_aria": "Sepia-Modus",
"theme_sepia_tip": "Sepia-Modus",
"theme_dark_aria": "Dunkler Modus",
"theme_dark_tip": "Dunkler Modus",
"mark_unread_label": "Als ungelesen markieren",
"mark_read_label": "Als gelesen markieren",
"fav_remove": "Favorit entfernen",
"fav_mark": "Als Favorit markieren",
"archive_label": "Artikel archivieren",
"open_original": "Original-Seite öffnen",
"delete_label": "Artikel löschen",
"confirm_delete": "Artikel wirklich löschen?"
},
"import": {
"bulk_link": "Mehrere URLs auf einmal? → Bulk-Import",
"form_title": "Mehrere Artikel importieren",
"form_subtitle": "Eine URL pro Zeile (oder durch Leerzeichen / Komma getrennt). Mana extrahiert sie nacheinander im Hintergrund.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} gültig",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} doppelt (übersprungen)",
"count_invalid": "{n} ungültig",
"invalid_details_summary": "Ungültige Zeilen anzeigen ({n})",
"error_no_urls": "Mindestens eine gültige URL einfügen.",
"error_overlimit": "Zu viele URLs ({n}). Maximal {max} pro Job — splitte den Import.",
"error_failed": "Job konnte nicht erstellt werden.",
"submit_label": "{n} URLs importieren",
"submit_busy": "Erstelle Job…",
"hint": "Im Hintergrund — du kannst den Tab schließen und später zurückkommen. Bei 50 URLs dauert es grob 510 Minuten. Den Fortschritt siehst du auf der Detailseite.",
"jobs_heading": "Bisherige Imports",
"filter_all": "Alle ({n})",
"filter_active": "Aktiv ({n})",
"filter_done": "Fertig ({n})",
"filter_errors": "Mit Fehlern ({n})",
"empty_filter": "Keine Jobs in dieser Ansicht.",
"status_queued": "Wartet",
"status_running": "Läuft",
"status_paused": "Pausiert",
"status_done": "Fertig",
"status_cancelled": "Abgebrochen",
"jobs_meta_errors": "{n} Fehler",
"jobs_meta_dups": "{n} Duplikate",
"jobs_meta_warnings": "{n} Warnungen",
"detail_title": "Import-Job",
"detail_not_found": "Job nicht gefunden.",
"detail_progress_aria": "Fortschritt",
"detail_counter_total": "{done} / {total} verarbeitet",
"detail_counter_saved": "{n} gespeichert",
"detail_counter_dups": "{n} Duplikate",
"detail_counter_warns": "{n} mit Cookie-Wand",
"detail_counter_errors": "{n} Fehler",
"action_pause": "Pause",
"action_resume": "Fortsetzen",
"action_cancel": "Abbrechen",
"action_retry": "Fehler wiederholen",
"action_delete": "Löschen",
"confirm_cancel": "Job wirklich abbrechen? Bisherige Artikel bleiben gespeichert.",
"confirm_delete": "Job-Historie löschen? Artikel bleiben.",
"consent_hint_strong": "Cookie-Wand erkannt",
"consent_hint_body": "{n, plural, one {# Artikel hat} other {# Artikel haben}} nur den Cookie-Zustimmungs-Dialog gespeichert (der Server sieht keine Cookies). Lösung:",
"consent_hint_link": "Browser-HTML-Bookmarklet",
"consent_hint_after_link": "aus dem Tab in dem du dem Cookie zugestimmt hast benutzen — überschreibt den Teaser durch den echten Artikel.",
"item_pending": "Wartet",
"item_extracting": "Extrahiert…",
"item_extracted": "Server fertig",
"item_saved": "✓ Gespeichert",
"item_duplicate": "· Duplikat",
"item_consent_wall": "⚠ Cookie-Wand",
"item_error": "✗ Fehler",
"item_cancelled": "Abgebrochen",
"item_action_view_teaser": "Teaser ansehen",
"item_action_rescue": "Erneut speichern",
"item_action_rescue_tip": "Mit Bookmarklet erneut speichern — überschreibt den Teaser durch den echten Artikel",
"item_action_open": "Öffnen"
}
}

View file

@ -1,100 +0,0 @@
{
"detail_view": {
"page_title_html": "{title} — Mana",
"untitled_fallback": "Article",
"loading": "Loading…",
"not_found": "Article not found.",
"back_to_list": "Back to list",
"meta_word_count": "{n} words",
"meta_reading_minutes": "{n} min",
"tag_add_label": "Tag",
"tag_placeholder": "Search or create tag…",
"toolbar_aria": "Reading tools",
"back_aria": "Back to list",
"back_tip": "Back to reading list",
"font_smaller_aria": "Smaller font",
"font_smaller_tip": "Smaller font",
"font_larger_aria": "Larger font",
"font_larger_tip": "Larger font",
"font_serif_tip": "Serif font",
"font_serif_label": "Serif",
"font_sans_tip": "Sans-serif font",
"font_sans_label": "Sans",
"theme_light_aria": "Light mode",
"theme_light_tip": "Light mode",
"theme_sepia_aria": "Sepia mode",
"theme_sepia_tip": "Sepia mode",
"theme_dark_aria": "Dark mode",
"theme_dark_tip": "Dark mode",
"mark_unread_label": "Mark as unread",
"mark_read_label": "Mark as read",
"fav_remove": "Remove favorite",
"fav_mark": "Mark as favorite",
"archive_label": "Archive article",
"open_original": "Open original page",
"delete_label": "Delete article",
"confirm_delete": "Really delete article?"
},
"import": {
"bulk_link": "Multiple URLs at once? → Bulk import",
"form_title": "Import multiple articles",
"form_subtitle": "One URL per line (or separated by spaces / commas). Mana extracts them one after the other in the background.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} valid",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} duplicate (skipped)",
"count_invalid": "{n} invalid",
"invalid_details_summary": "Show invalid lines ({n})",
"error_no_urls": "Add at least one valid URL.",
"error_overlimit": "Too many URLs ({n}). Maximum {max} per job — split the import.",
"error_failed": "Could not create job.",
"submit_label": "Import {n} URLs",
"submit_busy": "Creating job…",
"hint": "Runs in the background — you can close the tab and come back later. 50 URLs take roughly 510 minutes. Progress is on the detail page.",
"jobs_heading": "Past imports",
"filter_all": "All ({n})",
"filter_active": "Active ({n})",
"filter_done": "Done ({n})",
"filter_errors": "With errors ({n})",
"empty_filter": "No jobs in this view.",
"status_queued": "Queued",
"status_running": "Running",
"status_paused": "Paused",
"status_done": "Done",
"status_cancelled": "Cancelled",
"jobs_meta_errors": "{n} errors",
"jobs_meta_dups": "{n} duplicates",
"jobs_meta_warnings": "{n} warnings",
"detail_title": "Import job",
"detail_not_found": "Job not found.",
"detail_progress_aria": "Progress",
"detail_counter_total": "{done} / {total} processed",
"detail_counter_saved": "{n} saved",
"detail_counter_dups": "{n} duplicates",
"detail_counter_warns": "{n} with cookie wall",
"detail_counter_errors": "{n} errors",
"action_pause": "Pause",
"action_resume": "Resume",
"action_cancel": "Cancel",
"action_retry": "Retry errors",
"action_delete": "Delete",
"confirm_cancel": "Really cancel job? Already-saved articles stay.",
"confirm_delete": "Delete job history? Articles stay.",
"consent_hint_strong": "Cookie wall detected",
"consent_hint_body": "{n, plural, one {# article has} other {# articles have}} only saved the cookie consent dialog (the server sees no cookies). Fix:",
"consent_hint_link": "Browser HTML bookmarklet",
"consent_hint_after_link": "from the tab where you already accepted — overwrites the teaser with the real article.",
"item_pending": "Waiting",
"item_extracting": "Extracting…",
"item_extracted": "Server done",
"item_saved": "✓ Saved",
"item_duplicate": "· Duplicate",
"item_consent_wall": "⚠ Cookie wall",
"item_error": "✗ Error",
"item_cancelled": "Cancelled",
"item_action_view_teaser": "View teaser",
"item_action_rescue": "Save again",
"item_action_rescue_tip": "Save again with the bookmarklet — overwrites the teaser with the real article",
"item_action_open": "Open"
}
}

View file

@ -1,100 +0,0 @@
{
"detail_view": {
"page_title_html": "{title} — Mana",
"untitled_fallback": "Artículo",
"loading": "Cargando…",
"not_found": "Artículo no encontrado.",
"back_to_list": "Volver a la lista",
"meta_word_count": "{n} palabras",
"meta_reading_minutes": "{n} min",
"tag_add_label": "Tag",
"tag_placeholder": "Buscar o crear etiqueta…",
"toolbar_aria": "Herramientas de lectura",
"back_aria": "Volver a la lista",
"back_tip": "Volver a la lista de lectura",
"font_smaller_aria": "Fuente más pequeña",
"font_smaller_tip": "Fuente más pequeña",
"font_larger_aria": "Fuente más grande",
"font_larger_tip": "Fuente más grande",
"font_serif_tip": "Fuente serif",
"font_serif_label": "Serif",
"font_sans_tip": "Fuente sans-serif",
"font_sans_label": "Sans",
"theme_light_aria": "Modo claro",
"theme_light_tip": "Modo claro",
"theme_sepia_aria": "Modo sepia",
"theme_sepia_tip": "Modo sepia",
"theme_dark_aria": "Modo oscuro",
"theme_dark_tip": "Modo oscuro",
"mark_unread_label": "Marcar como no leído",
"mark_read_label": "Marcar como leído",
"fav_remove": "Quitar de favoritos",
"fav_mark": "Marcar como favorito",
"archive_label": "Archivar artículo",
"open_original": "Abrir página original",
"delete_label": "Eliminar artículo",
"confirm_delete": "¿Eliminar realmente el artículo?"
},
"import": {
"bulk_link": "¿Varias URLs a la vez? → Importación masiva",
"form_title": "Importar varios artículos",
"form_subtitle": "Una URL por línea (o separadas por espacios / comas). Mana las extrae una tras otra en segundo plano.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} válidas",
"count_overlimit_suffix": " / máx {max}",
"count_dup": "{n} duplicadas (omitidas)",
"count_invalid": "{n} inválidas",
"invalid_details_summary": "Mostrar líneas inválidas ({n})",
"error_no_urls": "Añade al menos una URL válida.",
"error_overlimit": "Demasiadas URLs ({n}). Máximo {max} por job — divide la importación.",
"error_failed": "No se pudo crear el job.",
"submit_label": "Importar {n} URLs",
"submit_busy": "Creando job…",
"hint": "Se ejecuta en segundo plano — puedes cerrar la pestaña y volver más tarde. 50 URLs tardan unos 510 minutos. El progreso está en la página de detalle.",
"jobs_heading": "Importaciones anteriores",
"filter_all": "Todos ({n})",
"filter_active": "Activos ({n})",
"filter_done": "Completados ({n})",
"filter_errors": "Con errores ({n})",
"empty_filter": "Ningún job en esta vista.",
"status_queued": "En cola",
"status_running": "En ejecución",
"status_paused": "Pausado",
"status_done": "Completado",
"status_cancelled": "Cancelado",
"jobs_meta_errors": "{n} errores",
"jobs_meta_dups": "{n} duplicados",
"jobs_meta_warnings": "{n} advertencias",
"detail_title": "Job de importación",
"detail_not_found": "Job no encontrado.",
"detail_progress_aria": "Progreso",
"detail_counter_total": "{done} / {total} procesados",
"detail_counter_saved": "{n} guardados",
"detail_counter_dups": "{n} duplicados",
"detail_counter_warns": "{n} con muro de cookies",
"detail_counter_errors": "{n} errores",
"action_pause": "Pausar",
"action_resume": "Reanudar",
"action_cancel": "Cancelar",
"action_retry": "Reintentar errores",
"action_delete": "Eliminar",
"confirm_cancel": "¿Cancelar realmente el job? Los artículos ya guardados permanecen.",
"confirm_delete": "¿Eliminar el historial del job? Los artículos permanecen.",
"consent_hint_strong": "Muro de cookies detectado",
"consent_hint_body": "{n, plural, one {# artículo solo ha} other {# artículos solo han}} guardado el diálogo de consentimiento de cookies (el servidor no ve cookies). Solución:",
"consent_hint_link": "bookmarklet HTML del navegador",
"consent_hint_after_link": "desde la pestaña donde ya aceptaste — sobrescribe el teaser con el artículo real.",
"item_pending": "En espera",
"item_extracting": "Extrayendo…",
"item_extracted": "Servidor listo",
"item_saved": "✓ Guardado",
"item_duplicate": "· Duplicado",
"item_consent_wall": "⚠ Muro de cookies",
"item_error": "✗ Error",
"item_cancelled": "Cancelado",
"item_action_view_teaser": "Ver teaser",
"item_action_rescue": "Guardar de nuevo",
"item_action_rescue_tip": "Guardar de nuevo con el bookmarklet — sobrescribe el teaser con el artículo real",
"item_action_open": "Abrir"
}
}

View file

@ -1,100 +0,0 @@
{
"detail_view": {
"page_title_html": "{title} — Mana",
"untitled_fallback": "Article",
"loading": "Chargement…",
"not_found": "Article introuvable.",
"back_to_list": "Retour à la liste",
"meta_word_count": "{n} mots",
"meta_reading_minutes": "{n} min",
"tag_add_label": "Tag",
"tag_placeholder": "Rechercher ou créer un tag…",
"toolbar_aria": "Outils de lecture",
"back_aria": "Retour à la liste",
"back_tip": "Retour à la liste de lecture",
"font_smaller_aria": "Police plus petite",
"font_smaller_tip": "Police plus petite",
"font_larger_aria": "Police plus grande",
"font_larger_tip": "Police plus grande",
"font_serif_tip": "Police serif",
"font_serif_label": "Serif",
"font_sans_tip": "Police sans-serif",
"font_sans_label": "Sans",
"theme_light_aria": "Mode clair",
"theme_light_tip": "Mode clair",
"theme_sepia_aria": "Mode sépia",
"theme_sepia_tip": "Mode sépia",
"theme_dark_aria": "Mode sombre",
"theme_dark_tip": "Mode sombre",
"mark_unread_label": "Marquer comme non lu",
"mark_read_label": "Marquer comme lu",
"fav_remove": "Retirer des favoris",
"fav_mark": "Marquer comme favori",
"archive_label": "Archiver l'article",
"open_original": "Ouvrir la page d'origine",
"delete_label": "Supprimer l'article",
"confirm_delete": "Vraiment supprimer l'article ?"
},
"import": {
"bulk_link": "Plusieurs URLs à la fois ? → Import groupé",
"form_title": "Importer plusieurs articles",
"form_subtitle": "Une URL par ligne (ou séparées par des espaces / virgules). Mana les extrait l'une après l'autre en arrière-plan.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} valides",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} doublons (ignorés)",
"count_invalid": "{n} invalides",
"invalid_details_summary": "Afficher les lignes invalides ({n})",
"error_no_urls": "Ajoute au moins une URL valide.",
"error_overlimit": "Trop d'URLs ({n}). Maximum {max} par job — divise l'import.",
"error_failed": "Impossible de créer le job.",
"submit_label": "Importer {n} URLs",
"submit_busy": "Création du job…",
"hint": "S'exécute en arrière-plan — tu peux fermer l'onglet et revenir plus tard. 50 URLs prennent environ 510 minutes. La progression est visible sur la page de détail.",
"jobs_heading": "Imports passés",
"filter_all": "Tous ({n})",
"filter_active": "Actifs ({n})",
"filter_done": "Terminés ({n})",
"filter_errors": "Avec erreurs ({n})",
"empty_filter": "Aucun job dans cette vue.",
"status_queued": "En attente",
"status_running": "En cours",
"status_paused": "En pause",
"status_done": "Terminé",
"status_cancelled": "Annulé",
"jobs_meta_errors": "{n} erreurs",
"jobs_meta_dups": "{n} doublons",
"jobs_meta_warnings": "{n} avertissements",
"detail_title": "Job d'import",
"detail_not_found": "Job introuvable.",
"detail_progress_aria": "Progression",
"detail_counter_total": "{done} / {total} traités",
"detail_counter_saved": "{n} enregistrés",
"detail_counter_dups": "{n} doublons",
"detail_counter_warns": "{n} avec mur de cookies",
"detail_counter_errors": "{n} erreurs",
"action_pause": "Pause",
"action_resume": "Reprendre",
"action_cancel": "Annuler",
"action_retry": "Réessayer les erreurs",
"action_delete": "Supprimer",
"confirm_cancel": "Vraiment annuler le job ? Les articles déjà enregistrés restent.",
"confirm_delete": "Supprimer l'historique du job ? Les articles restent.",
"consent_hint_strong": "Mur de cookies détecté",
"consent_hint_body": "{n, plural, one {# article n'a enregistré} other {# articles n'ont enregistré}} que la boîte de dialogue de consentement (le serveur ne voit aucun cookie). Solution :",
"consent_hint_link": "bookmarklet HTML du navigateur",
"consent_hint_after_link": "depuis l'onglet où tu as déjà accepté — remplace le teaser par l'article réel.",
"item_pending": "En attente",
"item_extracting": "Extraction…",
"item_extracted": "Serveur terminé",
"item_saved": "✓ Enregistré",
"item_duplicate": "· Doublon",
"item_consent_wall": "⚠ Mur de cookies",
"item_error": "✗ Erreur",
"item_cancelled": "Annulé",
"item_action_view_teaser": "Voir le teaser",
"item_action_rescue": "Réenregistrer",
"item_action_rescue_tip": "Réenregistrer avec le bookmarklet — remplace le teaser par l'article réel",
"item_action_open": "Ouvrir"
}
}

View file

@ -1,100 +0,0 @@
{
"detail_view": {
"page_title_html": "{title} — Mana",
"untitled_fallback": "Articolo",
"loading": "Caricamento…",
"not_found": "Articolo non trovato.",
"back_to_list": "Torna alla lista",
"meta_word_count": "{n} parole",
"meta_reading_minutes": "{n} min",
"tag_add_label": "Tag",
"tag_placeholder": "Cerca o crea un tag…",
"toolbar_aria": "Strumenti di lettura",
"back_aria": "Torna alla lista",
"back_tip": "Torna alla lista di lettura",
"font_smaller_aria": "Font più piccolo",
"font_smaller_tip": "Font più piccolo",
"font_larger_aria": "Font più grande",
"font_larger_tip": "Font più grande",
"font_serif_tip": "Font serif",
"font_serif_label": "Serif",
"font_sans_tip": "Font sans-serif",
"font_sans_label": "Sans",
"theme_light_aria": "Modalità chiara",
"theme_light_tip": "Modalità chiara",
"theme_sepia_aria": "Modalità seppia",
"theme_sepia_tip": "Modalità seppia",
"theme_dark_aria": "Modalità scura",
"theme_dark_tip": "Modalità scura",
"mark_unread_label": "Segna come non letto",
"mark_read_label": "Segna come letto",
"fav_remove": "Rimuovi dai preferiti",
"fav_mark": "Segna come preferito",
"archive_label": "Archivia articolo",
"open_original": "Apri pagina originale",
"delete_label": "Elimina articolo",
"confirm_delete": "Eliminare davvero l'articolo?"
},
"import": {
"bulk_link": "Più URL contemporaneamente? → Import multiplo",
"form_title": "Importa più articoli",
"form_subtitle": "Una URL per riga (o separate da spazi / virgole). Mana le estrae una dopo l'altra in background.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} valide",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} duplicate (ignorate)",
"count_invalid": "{n} non valide",
"invalid_details_summary": "Mostra righe non valide ({n})",
"error_no_urls": "Aggiungi almeno una URL valida.",
"error_overlimit": "Troppe URL ({n}). Massimo {max} per job — dividi l'import.",
"error_failed": "Impossibile creare il job.",
"submit_label": "Importa {n} URL",
"submit_busy": "Creazione del job…",
"hint": "Funziona in background — puoi chiudere la scheda e tornare più tardi. 50 URL richiedono circa 510 minuti. Lo stato di avanzamento è nella pagina di dettaglio.",
"jobs_heading": "Import precedenti",
"filter_all": "Tutti ({n})",
"filter_active": "Attivi ({n})",
"filter_done": "Completati ({n})",
"filter_errors": "Con errori ({n})",
"empty_filter": "Nessun job in questa vista.",
"status_queued": "In attesa",
"status_running": "In esecuzione",
"status_paused": "In pausa",
"status_done": "Completato",
"status_cancelled": "Annullato",
"jobs_meta_errors": "{n} errori",
"jobs_meta_dups": "{n} duplicati",
"jobs_meta_warnings": "{n} avvisi",
"detail_title": "Job di import",
"detail_not_found": "Job non trovato.",
"detail_progress_aria": "Avanzamento",
"detail_counter_total": "{done} / {total} elaborati",
"detail_counter_saved": "{n} salvati",
"detail_counter_dups": "{n} duplicati",
"detail_counter_warns": "{n} con cookie wall",
"detail_counter_errors": "{n} errori",
"action_pause": "Pausa",
"action_resume": "Riprendi",
"action_cancel": "Annulla",
"action_retry": "Riprova errori",
"action_delete": "Elimina",
"confirm_cancel": "Annullare davvero il job? Gli articoli già salvati rimangono.",
"confirm_delete": "Eliminare la cronologia del job? Gli articoli rimangono.",
"consent_hint_strong": "Cookie wall rilevato",
"consent_hint_body": "{n, plural, one {# articolo ha} other {# articoli hanno}} salvato solo la finestra di consenso ai cookie (il server non vede cookie). Soluzione:",
"consent_hint_link": "bookmarklet HTML del browser",
"consent_hint_after_link": "dalla scheda dove hai già accettato — sovrascrive il teaser con l'articolo reale.",
"item_pending": "In attesa",
"item_extracting": "Estrazione…",
"item_extracted": "Server pronto",
"item_saved": "✓ Salvato",
"item_duplicate": "· Duplicato",
"item_consent_wall": "⚠ Cookie wall",
"item_error": "✗ Errore",
"item_cancelled": "Annullato",
"item_action_view_teaser": "Vedi teaser",
"item_action_rescue": "Salva di nuovo",
"item_action_rescue_tip": "Salva di nuovo con il bookmarklet — sovrascrive il teaser con l'articolo reale",
"item_action_open": "Apri"
}
}

View file

@ -1,150 +0,0 @@
<!--
ArticlesTabShell — Tab-Leiste + Settings + QuickAdd oben, vier Tabs
darunter: Leseliste / Highlights / Favoriten / Stats.
Tab-Wechsel läuft INTERN über $state (Admin-Tabbed-Card-Pattern), nicht
über URL-Navigation. Das ist kritisch wenn die Shell als Workbench-App-
Karte gemountet wird — goto() würde dort den User aus der Karte
rauskicken.
Bookmarkbarkeit kommt über die drei SvelteKit-Routen, die jeweils mit
`initialTab` den Startpunkt setzen. Innerhalb der Shell gewechselte
Tabs ändern die URL NICHT — das ist by design.
-->
<script lang="ts">
import { setContext } from 'svelte';
import { Gear } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import ListView from './ListView.svelte';
import HighlightsView from './views/HighlightsView.svelte';
import StatsView from './views/StatsView.svelte';
import QuickAddInput from './components/QuickAddInput.svelte';
import { ARTICLES_TAB_CONTEXT, type ArticlesTabContext, type ArticlesTabId } from './tab-context';
interface Props {
initialTab?: ArticlesTabId;
}
let { initialTab = 'list' }: Props = $props();
// svelte-ignore state_referenced_locally
let activeTab = $state<ArticlesTabId>(initialTab);
const TABS: { id: ArticlesTabId; label: string }[] = [
{ id: 'list', label: 'Leseliste' },
{ id: 'highlights', label: 'Highlights' },
{ id: 'favorites', label: 'Favoriten' },
{ id: 'stats', label: 'Stats' },
];
setContext<ArticlesTabContext>(ARTICLES_TAB_CONTEXT, {
switchTo(tab: ArticlesTabId) {
activeTab = tab;
},
});
</script>
<div class="tab-shell">
<header class="top-bar">
<QuickAddInput />
<button
type="button"
class="icon-btn"
title="Einstellungen — Bookmarklet + Share-Target"
aria-label="Artikel-Einstellungen"
onclick={() => goto('/articles/settings')}
>
<Gear size={18} weight="regular" />
</button>
</header>
<nav class="tabs" aria-label="Artikel-Ansichten">
{#each TABS as t (t.id)}
<button
type="button"
class="tab"
class:active={activeTab === t.id}
aria-current={activeTab === t.id ? 'page' : undefined}
onclick={() => (activeTab = t.id)}
>
{t.label}
</button>
{/each}
</nav>
<div class="tab-body">
{#if activeTab === 'list'}
<ListView />
{:else if activeTab === 'highlights'}
<HighlightsView />
{:else if activeTab === 'favorites'}
<ListView initialFilter="favorites" />
{:else if activeTab === 'stats'}
<StatsView />
{/if}
</div>
</div>
<style>
.tab-shell {
display: flex;
flex-direction: column;
gap: 1rem;
/* Innen-Padding als Single-Source-of-Truth. In Workbench-Karten */
/* hat ModuleShell's `.shell-body` null padding — ohne das hier würde */
/* der QuickAdd-Input direkt am Card-Rand kleben. Im Route-Kontext */
/* liegt dieses Padding innerhalb des (app)-Layout-Wrappers und */
/* ergibt insgesamt ein ruhig gespaciedes Bild. */
padding: 1rem 1.25rem;
}
.top-bar {
display: flex;
align-items: flex-start;
gap: 0.6rem;
}
.icon-btn {
padding: 0.45rem 0.55rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 2.35rem;
}
.icon-btn:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.tabs {
display: flex;
gap: 0.15rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
.tab {
padding: 0.55rem 0.9rem;
color: var(--color-text-muted, #64748b);
background: transparent;
border: none;
font: inherit;
font-size: 0.92rem;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.tab:hover {
color: inherit;
}
.tab.active {
color: #f97316;
border-bottom-color: #f97316;
}
</style>

View file

@ -1,329 +0,0 @@
<!--
Articles — ListView
Filter chips (Alle | Ungelesen | In Arbeit | Favoriten | Archiv) + card
list with per-card tag chips. Tag names + colours come from the global
tags table via useAllTags; the per-article tag ids via a batched
getTagIdsForMany to avoid N+1.
-->
<script lang="ts">
import { page } from '$app/stores';
import { onMount, untrack } from 'svelte';
import ArticleCard from './components/ArticleCard.svelte';
import HomeSectionWeiterlesen from './components/HomeSectionWeiterlesen.svelte';
import { useAllArticles, useArticleTagMap } from './queries';
import { useAllTags } from './stores/tags.svelte';
import type { Article } from './types';
type Filter = 'all' | 'unread' | 'reading' | 'finished' | 'favorites' | 'archived';
const ALLOWED_FILTERS: Filter[] = [
'all',
'unread',
'reading',
'finished',
'favorites',
'archived',
];
const FILTERS: { id: Filter; label: string }[] = [
{ id: 'all', label: 'Alle' },
{ id: 'unread', label: 'Ungelesen' },
{ id: 'reading', label: 'In Arbeit' },
{ id: 'finished', label: 'Gelesen' },
{ id: 'favorites', label: 'Favoriten' },
{ id: 'archived', label: 'Archiv' },
];
interface Props {
/** Pre-selected filter (Workbench / Tab-Shell context). Wenn gesetzt,
* überstimmt er den URL-Query-Param. */
initialFilter?: Filter;
}
let { initialFilter }: Props = $props();
const articles$ = useAllArticles();
const articles = $derived(articles$.value);
const tagMap$ = $derived.by(() => useArticleTagMap(articles.map((a) => a.id)));
const allTags$ = useAllTags();
// initialFilter ist ein einmaliger Seed (Shell-Tabs mounten ListView
// immer frisch — es gibt keinen Case wo der Prop sich live ändert).
// untrack() sagt Svelte explizit, dass das kein state_referenced_locally-
// Unfall ist.
let filter = $state<Filter>(untrack(() => initialFilter ?? 'all'));
let siteFilter = $state<string | null>(null);
let tagFilter = $state<string | null>(null);
// Deep-link support via Query-Param — nur wenn KEIN initialFilter-Prop
// gesetzt wurde (sonst gewinnt die Shell). In der Shell wird die
// ListView ohne URL-Sync gerendert; die direkten /articles/list-
// Routen dagegen haben die Params.
onMount(() => {
if (initialFilter) {
untrack(() => {
siteFilter = null;
tagFilter = null;
});
return;
}
const params = $page.url.searchParams;
const f = params.get('filter');
if (f && (ALLOWED_FILTERS as string[]).includes(f)) {
filter = f as Filter;
}
siteFilter = params.get('site') || null;
tagFilter = params.get('tag') || null;
});
// Continue-Reading-Strip: erscheint nur wenn Filter 'all' oder 'reading'
// ist — auf anderen Filtern ist es verwirrend (ungelesen / archiv etc.
// haben nichts mit "weiterlesen" zu tun).
const readingArticles = $derived(articles.filter((a) => a.status === 'reading'));
const showContinueReading = $derived(
readingArticles.length > 0 && (filter === 'all' || filter === 'reading')
);
function matchesStatus(a: Article, f: Filter): boolean {
switch (f) {
case 'all':
return a.status !== 'archived';
case 'unread':
return a.status === 'unread';
case 'reading':
return a.status === 'reading';
case 'finished':
return a.status === 'finished';
case 'favorites':
return a.isFavorite && a.status !== 'archived';
case 'archived':
return a.status === 'archived';
}
}
const filtered = $derived.by(() => {
let result = articles.filter((a) => matchesStatus(a, filter));
if (siteFilter) {
const needle = siteFilter.toLowerCase();
result = result.filter((a) => (a.siteName ?? '').toLowerCase() === needle);
}
if (tagFilter) {
result = result.filter((a) => (tagMap$.value.get(a.id) ?? []).includes(tagFilter!));
}
return result;
});
const counts = $derived.by(() => ({
all: articles.filter((x) => x.status !== 'archived').length,
unread: articles.filter((x) => x.status === 'unread').length,
reading: articles.filter((x) => x.status === 'reading').length,
finished: articles.filter((x) => x.status === 'finished').length,
favorites: articles.filter((x) => x.isFavorite && x.status !== 'archived').length,
archived: articles.filter((x) => x.status === 'archived').length,
}));
function tagsFor(article: Article) {
const ids = tagMap$.value.get(article.id) ?? [];
if (ids.length === 0) return [];
const all = allTags$.value;
return ids
.map((id) => all.find((t) => t.id === id))
.filter((t): t is (typeof all)[number] => !!t);
}
function clearSiteFilter() {
siteFilter = null;
}
function clearTagFilter() {
tagFilter = null;
}
const tagFilterLabel = $derived(
tagFilter ? (allTags$.value.find((t) => t.id === tagFilter)?.name ?? tagFilter) : null
);
</script>
<div class="list-view">
{#if showContinueReading}
<HomeSectionWeiterlesen articles={readingArticles} />
{/if}
<div class="filter-bar">
<div class="filter-row" role="tablist" aria-label="Filter">
{#each FILTERS as f (f.id)}
<button
type="button"
class="filter-chip"
class:active={filter === f.id}
role="tab"
aria-selected={filter === f.id}
onclick={() => (filter = f.id)}
>
{f.label}
<span class="count">{counts[f.id]}</span>
</button>
{/each}
</div>
{#if siteFilter || tagFilter}
<div class="sub-filters" aria-label="Zusatz-Filter">
{#if siteFilter}
<button type="button" class="sub-filter" onclick={clearSiteFilter}>
Quelle: {siteFilter} <span class="x">×</span>
</button>
{/if}
{#if tagFilter}
<button type="button" class="sub-filter" onclick={clearTagFilter}>
Tag: {tagFilterLabel} <span class="x">×</span>
</button>
{/if}
</div>
{/if}
</div>
{#if articles$.loading}
<p class="muted center">Lädt…</p>
{:else if articles.length === 0}
<div class="empty-state">
<p class="empty-headline">Noch nichts gespeichert.</p>
<p class="empty-sub">
Geh auf die Übersicht und füge oben eine URL ein — der Server extrahiert den Artikel mit
Readability, alles bleibt verschlüsselt offline verfügbar.
</p>
</div>
{:else if filtered.length === 0}
<div class="empty-state">
<p class="empty-headline">Nichts in diesem Filter.</p>
<p class="empty-sub">Probier einen anderen Filter oder speichere weitere Artikel.</p>
</div>
{:else}
<ul class="article-list">
{#each filtered as article (article.id)}
<li>
<ArticleCard {article} tags={tagsFor(article)} />
</li>
{/each}
</ul>
{/if}
</div>
<style>
.list-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.filter-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-row {
display: flex;
gap: 0.35rem;
flex-wrap: nowrap;
overflow-x: auto;
/* Schmale Scrollbar, damit die Chips auch auf Mobile ohne Umbruch
* erreichbar bleiben. `scroll-snap-type` macht das Scroll-Gefühl
* snappy — Chip für Chip einrasten statt frei gleiten. */
scroll-snap-type: x proximity;
scrollbar-width: thin;
/* Ein kleiner Fade am rechten Rand wäre schön, verzichten wir */
/* drauf — spart Komplexität, Browser zeigt seine native overflow-*/
/* affordance. */
padding-bottom: 0.25rem;
margin-bottom: -0.25rem;
}
.filter-chip {
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
/* Nicht-aktive Chips: leichte Hintergrund-Füllung + sichtbarer
* Border, damit sie klar als tappable Elements lesbar sind.
* currentColor-basierte Mixes adaptieren automatisch an Light/
* Sepia/Dark-Themes. */
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 5%, transparent);
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
scroll-snap-align: start;
white-space: nowrap;
}
.filter-chip:hover {
border-color: color-mix(in srgb, currentColor 35%, transparent);
background: color-mix(in srgb, currentColor 9%, transparent);
}
.filter-chip.active {
background: #f97316;
border-color: #f97316;
color: white;
}
.filter-chip .count {
font-size: 0.72rem;
opacity: 0.8;
padding: 0 0.35rem;
background: color-mix(in srgb, currentColor 15%, transparent);
border-radius: 999px;
}
.sub-filters {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.sub-filter {
font: inherit;
font-size: 0.78rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, #f97316 40%, transparent);
background: color-mix(in srgb, #f97316 10%, transparent);
color: #ea580c;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.sub-filter .x {
opacity: 0.7;
font-weight: 600;
}
.sub-filter:hover .x {
opacity: 1;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.empty-state {
margin-top: 3rem;
padding: 2rem;
text-align: center;
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.15));
border-radius: 0.75rem;
}
.empty-headline {
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.empty-sub {
margin: 0 0 1.25rem 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.article-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View file

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

View file

@ -1,25 +0,0 @@
/**
* Articles module Dexie accessors.
*
* No guest seed: articles are by definition URLs the user chose to save,
* so an empty state is the honest first-run experience. The ListView's
* empty-state hints the user toward /articles/add instead.
*/
import { db } from '$lib/data/database';
import type {
LocalArticle,
LocalArticleExtractPickup,
LocalArticleImportItem,
LocalArticleImportJob,
LocalArticleTag,
LocalHighlight,
} from './types';
export const articleTable = db.table<LocalArticle>('articles');
export const articleHighlightTable = db.table<LocalHighlight>('articleHighlights');
export const articleTagTable = db.table<LocalArticleTag>('articleTags');
export const articleImportJobTable = db.table<LocalArticleImportJob>('articleImportJobs');
export const articleImportItemTable = db.table<LocalArticleImportItem>('articleImportItems');
export const articleExtractPickupTable =
db.table<LocalArticleExtractPickup>('articleExtractPickup');

View file

@ -1,529 +0,0 @@
<!--
AddUrlForm — three paths in, one preview/save UI out:
1. User types / pastes a URL manually.
2. `?url=…` query (Web Share Target, bookmarklet v1) — auto-fills
the input and triggers server-side fetch + Readability.
3. `?source=bookmarklet` (bookmarklet v2) — waits for the opener
tab to postMessage the rendered HTML over, then runs the
browser-HTML extract path. This bypasses cookie-consent walls
and soft paywalls because the HTML already came from the user's
own authenticated session.
If the server-side extract returns `warning: 'probable_consent_wall'`
we keep the preview but add a prominent CTA suggesting the user try
the browser-HTML path instead.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { articlesStore } from '../stores/articles.svelte';
import { extractArticle, extractFromHtml, type ExtractedArticle } from '../api';
import type { Article } from '../types';
let url = $state('');
let preview = $state<ExtractedArticle | null>(null);
let duplicate = $state<Article | null>(null);
let loading = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
// a11y: don't use the `autofocus` attribute — route the focus through a
// use:action so screen-readers announce the page first and the focus
// happens deliberately after mount.
function focusOnMount(node: HTMLInputElement) {
node.focus();
}
/**
* Extract the first URL-shaped token from a string — some share
* senders (Chrome Android, WhatsApp) stuff the URL into the `text`
* slot instead of `url`, often prefixed with the page title.
*/
function firstUrl(text: string): string {
const m = text.match(/https?:\/\/\S+/i);
return m ? m[0] : '';
}
// ─── Bookmarklet-v2 handshake ─────────────────────────
//
// When the settings bookmarklet opens Mana with ?source=bookmarklet,
// the opener tab wants to push its rendered HTML over via
// postMessage so we can extract WITH the user's cookies/consent
// already applied. Protocol:
//
// Mana (us) Opener (the article site)
// │ │
// │ mana-ready │
// │ ─────────────────→ │
// │ │
// │ mana-html │
// │ ←───────────────── │ { url, html, title }
// │ │
//
// We only TRUST the `html` payload — we don't navigate using it, we
// hand it to our own backend at /api/v1/articles/extract/html which
// sanitises via Readability. Origin of the sender is free to be
// anything (that's the whole point) so we don't gate on it.
let messageHandler: ((e: MessageEvent) => void) | null = null;
let bookmarkletTimeout: ReturnType<typeof setTimeout> | null = null;
async function handleBookmarkletHtml(payload: { url: string; html: string; title?: string }) {
const trimmed = payload.url.trim();
if (!trimmed) {
error = 'Bookmarklet hat keine URL mitgeschickt.';
return;
}
url = trimmed;
reset();
loading = true;
try {
const alreadySaved = await articlesStore.findByUrl(trimmed);
if (alreadySaved) {
duplicate = alreadySaved;
return;
}
const extracted = await extractFromHtml(trimmed, payload.html);
await persistOrShowWarning(extracted);
} catch (e) {
error = e instanceof Error ? e.message : 'Extraktion fehlgeschlagen.';
} finally {
loading = false;
}
}
function armBookmarkletHandshake() {
if (typeof window === 'undefined') return;
messageHandler = (e: MessageEvent) => {
const data = e.data as { type?: string; url?: string; html?: string; title?: string } | null;
if (
!data ||
data.type !== 'mana-html' ||
typeof data.url !== 'string' ||
typeof data.html !== 'string'
) {
return;
}
// One-shot — disarm so a misbehaving parent can't flood us.
if (messageHandler) window.removeEventListener('message', messageHandler);
messageHandler = null;
if (bookmarkletTimeout) {
clearTimeout(bookmarkletTimeout);
bookmarkletTimeout = null;
}
void handleBookmarkletHtml({ url: data.url, html: data.html, title: data.title });
};
window.addEventListener('message', messageHandler);
// Tell the opener we're ready to receive. targetOrigin '*' is fine
// here — the payload in THIS direction is just a readiness ping,
// no data worth origin-gating.
try {
window.opener?.postMessage({ type: 'mana-ready' }, '*');
} catch {
// Opener may be cross-origin closed — fall through to timeout.
}
// Bail out if nothing arrives within 30s so the UI doesn't sit
// spinning forever when the user re-opened this page from a stale
// bookmarklet-v2 link without an opener tab.
loading = true;
bookmarkletTimeout = setTimeout(() => {
if (loading && !preview && !duplicate && !error) {
loading = false;
error =
'Bookmarklet-Handshake ist fehlgeschlagen. Öffne das Bookmarklet aus dem Browser-Tab in dem der Artikel läuft.';
}
}, 30_000);
}
onMount(() => {
const params = $page.url.searchParams;
if (params.get('source') === 'bookmarklet') {
armBookmarkletHandshake();
return;
}
const fromUrl = params.get('url')?.trim() ?? '';
const fromText = params.get('text')?.trim() ?? '';
const candidate = fromUrl || firstUrl(fromText);
if (candidate) {
url = candidate;
// Fire-and-forget — the handler is idempotent enough that a
// stray second click does no harm.
void handleSubmit();
}
});
onDestroy(() => {
if (messageHandler && typeof window !== 'undefined') {
window.removeEventListener('message', messageHandler);
messageHandler = null;
}
if (bookmarkletTimeout) {
clearTimeout(bookmarkletTimeout);
bookmarkletTimeout = null;
}
});
function reset() {
preview = null;
duplicate = null;
error = null;
}
async function handleSubmit() {
reset();
const trimmed = url.trim();
if (!trimmed) {
error = 'Bitte eine URL einfügen.';
return;
}
try {
new URL(trimmed);
} catch {
error = 'Das sieht nicht nach einer gültigen URL aus.';
return;
}
loading = true;
try {
const alreadySaved = await articlesStore.findByUrl(trimmed);
if (alreadySaved) {
duplicate = alreadySaved;
return;
}
const extracted = await extractArticle(trimmed);
await persistOrShowWarning(extracted);
} catch (e) {
error = e instanceof Error ? e.message : 'Extraktion fehlgeschlagen.';
} finally {
loading = false;
}
}
/**
* Normal path: extract succeeded cleanly → persist + navigate to the
* reader so the user never has to press a second "Save" button.
* Fallback: if the server flagged a probable consent wall, stop and
* surface the preview + warning card so the user can decide whether
* to keep the teaser anyway or re-save via the HTML bookmarklet.
*/
async function persistOrShowWarning(extracted: ExtractedArticle) {
if (extracted.warning === 'probable_consent_wall') {
preview = extracted;
return;
}
await persistAndGo(extracted);
}
async function persistAndGo(extracted: ExtractedArticle) {
saving = true;
try {
const saved = await articlesStore.saveFromExtracted(extracted);
goto(`/articles/${saved.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
saving = false;
}
}
/** Consent-wall path only: user saw the warning and still wants to keep this. */
async function saveDespiteWarning() {
if (!preview) return;
await persistAndGo(preview);
}
</script>
<div class="add-shell">
<header class="header">
<h1>Artikel speichern</h1>
<p class="subtitle">URL einfügen — Mana extrahiert + speichert direkt.</p>
</header>
<div class="input-row">
<input
type="url"
class="url-input"
bind:value={url}
placeholder="https://…"
disabled={loading || saving}
onkeydown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
use:focusOnMount
/>
<button type="button" class="primary" disabled={loading || saving} onclick={handleSubmit}>
{#if saving}Speichere…{:else if loading}Lädt…{:else}Speichern{/if}
</button>
</div>
<p class="bulk-link">
<a href="/articles/import">{$_('articles.import.bulk_link')}</a>
</p>
{#if (loading || saving) && !error && !preview && !duplicate}
<div class="loading-block" role="status">
<span class="spinner" aria-hidden="true"></span>
<div class="loading-text">
<p class="loading-headline">
{saving ? 'Speichere in deine Leseliste…' : 'Server extrahiert den Artikel…'}
</p>
<p class="loading-sub">
{saving
? 'Gleich weiter zum Reader.'
: 'Dauert normalerweise 25 Sekunden. Nach 25 Sekunden geben wir auf.'}
</p>
</div>
</div>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
{#if duplicate}
<div class="duplicate">
<p class="dup-headline">Den hast du schon gespeichert.</p>
<p class="dup-title">{duplicate.title}</p>
<div class="dup-actions">
<button type="button" class="primary" onclick={() => goto(`/articles/${duplicate!.id}`)}>
Zum gespeicherten Artikel
</button>
<button type="button" class="secondary" onclick={reset}>Andere URL</button>
</div>
</div>
{/if}
{#if preview}
<!--
Preview-Karte erscheint nur noch im Warning-Fall (Consent-Wall).
Happy-Path geht direkt zu persistAndGo() und navigiert weg, bevor
das Template diesen Block überhaupt rendert.
-->
<div class="consent-warning" role="alert">
<p class="cw-headline">Cookie-Wand erkannt</p>
<p class="cw-body">
Der Server hat wahrscheinlich nicht den Artikel bekommen, sondern nur den
Cookie-Zustimmungs-Dialog der Seite — er hat keine Session / Cookies. Lösung: das
<strong>Browser-HTML-Bookmarklet</strong> aus
<a href="/articles/settings">Einstellungen</a> benutzen. Läuft in deinem Tab wo du schon zugestimmt
hast und schickt Mana das echte Artikel-HTML.
</p>
<p class="cw-body cw-body-muted">
Falls du den Teaser trotzdem speichern willst — OK, kannst du später nochmal mit dem
HTML-Bookmarklet überschreiben.
</p>
</div>
<article class="preview">
<h2 class="preview-title">{preview.title}</h2>
<div class="preview-meta">
{#if preview.siteName}<span>{preview.siteName}</span>{/if}
{#if preview.author}<span>· {preview.author}</span>{/if}
{#if preview.readingTimeMinutes}<span>· {preview.readingTimeMinutes} min</span>{/if}
{#if preview.wordCount}<span>· {preview.wordCount} Wörter</span>{/if}
</div>
{#if preview.excerpt}
<p class="preview-excerpt">{preview.excerpt}</p>
{/if}
<div class="preview-actions">
<button type="button" class="primary" disabled={saving} onclick={saveDespiteWarning}>
{saving ? 'Speichere…' : 'Trotzdem speichern'}
</button>
<button type="button" class="secondary" onclick={reset} disabled={saving}>
Abbrechen
</button>
</div>
</article>
{/if}
</div>
<style>
.add-shell {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.25rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
}
.subtitle {
color: var(--color-text-muted, #64748b);
margin: 0;
font-size: 0.95rem;
}
.input-row {
display: flex;
gap: 0.55rem;
margin-bottom: 0.45rem;
}
.bulk-link {
margin: 0 0 0.9rem 0;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.bulk-link a {
color: #ea580c;
text-decoration: none;
}
.bulk-link a:hover {
text-decoration: underline;
}
.url-input {
flex: 1;
padding: 0.6rem 0.85rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
.url-input:focus {
outline: 2px solid #f97316;
outline-offset: 1px;
border-color: transparent;
}
button {
padding: 0.55rem 1rem;
border-radius: 0.55rem;
font: inherit;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
button:disabled {
opacity: 0.6;
cursor: progress;
}
.primary {
background: #f97316;
color: white;
border-color: #f97316;
}
.primary:hover:not(:disabled) {
background: #ea580c;
border-color: #ea580c;
}
.secondary {
background: transparent;
color: inherit;
border-color: var(--color-border, rgba(0, 0, 0, 0.15));
}
.secondary:hover:not(:disabled) {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.error {
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.9rem;
}
.loading-block {
display: flex;
gap: 0.8rem;
align-items: center;
padding: 0.75rem 0.95rem;
border-radius: 0.55rem;
border: 1px solid color-mix(in srgb, #f97316 30%, transparent);
background: color-mix(in srgb, #f97316 5%, transparent);
}
.spinner {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid color-mix(in srgb, #f97316 30%, transparent);
border-top-color: #f97316;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.loading-headline {
margin: 0;
font-weight: 500;
font-size: 0.92rem;
}
.loading-sub {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.82rem;
}
.consent-warning {
margin-top: 1rem;
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent);
border-radius: 0.55rem;
background: color-mix(in srgb, #f59e0b 8%, transparent);
}
.cw-headline {
margin: 0 0 0.35rem 0;
font-weight: 600;
font-size: 0.95rem;
}
.cw-body {
margin: 0 0 0.45rem 0;
font-size: 0.88rem;
line-height: 1.5;
}
.cw-body:last-child {
margin-bottom: 0;
}
.cw-body-muted {
color: var(--color-text-muted, #64748b);
}
.consent-warning a {
color: #ea580c;
text-decoration: underline;
}
.preview,
.duplicate {
margin-top: 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.75rem;
background: color-mix(in srgb, #f97316 3%, transparent);
}
.preview-title {
margin: 0 0 0.4rem 0;
font-size: 1.2rem;
line-height: 1.35;
}
.preview-meta {
font-size: 0.82rem;
color: var(--color-text-muted, #64748b);
margin-bottom: 0.7rem;
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.preview-excerpt {
margin: 0 0 1rem 0;
line-height: 1.55;
}
.preview-actions,
.dup-actions {
display: flex;
gap: 0.5rem;
}
.dup-headline {
margin: 0 0 0.3rem 0;
font-weight: 600;
}
.dup-title {
margin: 0 0 0.9rem 0;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -1,170 +0,0 @@
<!--
ArticleCard — shared card used by ListView, HomeView sections, and
anywhere else an article needs a compact clickable preview.
Two layout variants:
- variant="row" (default): full-width card, meta + title +
excerpt + tags. Used in vertical lists.
- variant="compact" slimmer, no excerpt, shows reading-progress
bar underneath — meant for horizontal
carousels (Continue-Reading section).
Parent passes the article + an optional tag list; navigation is
inlined so callers don't need to wire onclick themselves. A parent
that wants a different destination (e.g. a list filter) can override
via `href`.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { TagChip } from '@mana/shared-ui';
import type { Article } from '../types';
type CardTag = { id: string; name: string; color?: string | null };
interface Props {
article: Article;
tags?: CardTag[];
variant?: 'row' | 'compact';
/** Override the default `/articles/<id>` navigation target. */
href?: string;
}
let { article, tags = [], variant = 'row', href }: Props = $props();
function openArticle() {
goto(href ?? `/articles/${article.id}`);
}
const progressPercent = $derived(Math.round((article.readingProgress ?? 0) * 100));
</script>
<button type="button" class="article-card variant-{variant}" onclick={openArticle}>
<div class="meta">
{#if article.siteName}
<span class="site">{article.siteName}</span>
{/if}
{#if article.readingTimeMinutes}
<span class="reading-time">{article.readingTimeMinutes} min</span>
{/if}
{#if variant === 'row'}
<span class="status status-{article.status}">{article.status}</span>
{/if}
{#if article.isFavorite}
<span class="fav" aria-label="Favorit"></span>
{/if}
</div>
<div class="title">{article.title}</div>
{#if variant === 'row' && article.excerpt}
<div class="excerpt">{article.excerpt}</div>
{/if}
{#if variant === 'row' && tags.length > 0}
<div class="tags">
{#each tags as tag (tag.id)}
<TagChip name={tag.name} color={tag.color} />
{/each}
</div>
{/if}
{#if variant === 'compact' && progressPercent > 0}
<div class="progress" aria-label="Lesefortschritt {progressPercent}%">
<div class="progress-bar" style:width="{progressPercent}%"></div>
</div>
{/if}
</button>
<style>
.article-card {
width: 100%;
text-align: left;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.6rem;
background: var(--color-surface, transparent);
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.article-card:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.2));
}
.variant-compact {
gap: 0.3rem;
padding: 0.75rem 0.9rem;
}
.meta {
display: flex;
gap: 0.6rem;
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
align-items: center;
}
.site {
font-weight: 500;
}
.status {
padding: 0.08rem 0.45rem;
border-radius: 999px;
font-size: 0.7rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.status-finished {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-reading {
background: rgba(249, 115, 22, 0.12);
color: #f97316;
}
.status-archived {
background: rgba(100, 116, 139, 0.15);
color: #64748b;
}
.fav {
color: #f59e0b;
}
.title {
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
.variant-compact .title {
font-size: 0.95rem;
/* Limit to 3 lines so the card heights stay uniform in the carousel. */
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.excerpt {
color: var(--color-text-muted, #64748b);
font-size: 0.88rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.tags {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.1rem;
}
.progress {
margin-top: auto;
height: 3px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #f97316;
transition: width 200ms ease;
}
</style>

View file

@ -1,234 +0,0 @@
<!--
BulkImportForm — paste a list of URLs (one per line / whitespace /
comma separated), live-validates with `parseUrls`, kicks off an
import job + navigates to its detail view.
Plan: docs/plans/articles-bulk-import.md.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { articleImportsStore, MAX_URLS_PER_JOB, parseUrls } from '../stores/imports.svelte';
let raw = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
const parsed = $derived(parseUrls(raw));
const overLimit = $derived(parsed.valid.length > MAX_URLS_PER_JOB);
async function handleSubmit() {
if (busy) return;
if (parsed.valid.length === 0) {
error = $_('articles.import.error_no_urls');
return;
}
if (overLimit) {
error = $_('articles.import.error_overlimit', {
values: { n: parsed.valid.length, max: MAX_URLS_PER_JOB },
});
return;
}
busy = true;
error = null;
try {
const jobId = await articleImportsStore.createJob(parsed.valid);
goto(`/articles/import/${jobId}`);
} catch (e) {
error = e instanceof Error ? e.message : $_('articles.import.error_failed');
busy = false;
}
}
</script>
<div class="bulk-shell">
<header class="header">
<h1>{$_('articles.import.form_title')}</h1>
<p class="subtitle">{$_('articles.import.form_subtitle')}</p>
</header>
<textarea
class="url-area"
bind:value={raw}
placeholder={$_('articles.import.form_placeholder')}
rows="10"
disabled={busy}
></textarea>
<div class="counter-row" aria-live="polite">
<span class="counter counter-valid" class:counter-overlimit={overLimit}>
{$_('articles.import.count_valid', { values: { n: parsed.valid.length } })}{overLimit
? $_('articles.import.count_overlimit_suffix', { values: { max: MAX_URLS_PER_JOB } })
: ''}
</span>
{#if parsed.duplicates.length > 0}
<span class="counter counter-dup">
{$_('articles.import.count_dup', { values: { n: parsed.duplicates.length } })}
</span>
{/if}
{#if parsed.invalid.length > 0}
<span class="counter counter-invalid">
{$_('articles.import.count_invalid', { values: { n: parsed.invalid.length } })}
</span>
{/if}
</div>
{#if overLimit}
<p class="error" role="alert">
{$_('articles.import.error_overlimit', {
values: { n: parsed.valid.length, max: MAX_URLS_PER_JOB },
})}
</p>
{/if}
{#if parsed.invalid.length > 0}
<details class="invalid-details">
<summary>
{$_('articles.import.invalid_details_summary', { values: { n: parsed.invalid.length } })}
</summary>
<ul class="invalid-list">
{#each parsed.invalid as bad (bad)}
<li><code>{bad}</code></li>
{/each}
</ul>
</details>
{/if}
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="actions">
<button
type="button"
class="primary"
onclick={handleSubmit}
disabled={busy || parsed.valid.length === 0 || overLimit}
>
{#if busy}
{$_('articles.import.submit_busy')}
{:else}
{$_('articles.import.submit_label', { values: { n: parsed.valid.length } })}
{/if}
</button>
</div>
<p class="hint">{$_('articles.import.hint')}</p>
</div>
<style>
.bulk-shell {
max-width: 760px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
}
.subtitle {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.95rem;
}
.url-area {
width: 100%;
min-height: 11rem;
padding: 0.7rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.88rem;
line-height: 1.45;
resize: vertical;
}
.url-area:focus {
outline: 2px solid #f97316;
outline-offset: 1px;
border-color: transparent;
}
.counter-row {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.counter {
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-weight: 500;
}
.counter-valid {
background: color-mix(in srgb, #16a34a 12%, transparent);
color: #16a34a;
}
.counter-overlimit {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.counter-dup {
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #b45309;
}
.counter-invalid {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.invalid-details {
font-size: 0.85rem;
}
.invalid-details summary {
cursor: pointer;
color: var(--color-text-muted, #64748b);
}
.invalid-list {
margin: 0.45rem 0 0 0.5rem;
padding-left: 0.85rem;
}
.invalid-list code {
font-size: 0.82rem;
word-break: break-all;
}
.error {
margin: 0;
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.9rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.primary {
padding: 0.6rem 1.1rem;
border-radius: 0.55rem;
border: 1px solid #f97316;
background: #f97316;
color: white;
font: inherit;
font-weight: 500;
cursor: pointer;
}
.primary:hover:not(:disabled) {
background: #ea580c;
border-color: #ea580c;
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.82rem;
line-height: 1.45;
}
</style>

View file

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

View file

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

View file

@ -1,97 +0,0 @@
<!--
Top-Sources: die Top-5-Quellen nach Artikelanzahl. Klick filtert
die ListView nach siteName.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import type { SiteCount } from '../queries';
import { getArticlesTabContext } from '../tab-context';
interface Props {
sources: SiteCount[];
}
let { sources }: Props = $props();
const tabCtx = getArticlesTabContext();
function openSource(siteName: string) {
if (tabCtx) {
// Im Workbench-Kontext können wir nicht auf die Liste routen
// und einen Site-Filter per Query-Param setzen (die Shell
// mountet die ListView ohne URL-Sync). Als Kompromiss:
// Switch nur auf den Tab — der User sieht die ganze Liste
// und sortiert dort selbst. Nicht ideal; siehe Plan-TODO.
tabCtx.switchTo('list');
} else {
goto(`/articles/list?site=${encodeURIComponent(siteName)}`);
}
}
</script>
{#if sources.length > 0}
<section class="section">
<header class="section-header">
<h2>Deine Quellen</h2>
</header>
<ul class="list">
{#each sources as src (src.siteName)}
<li>
<button type="button" class="source-row" onclick={() => openSource(src.siteName)}>
<span class="name">{src.siteName}</span>
<span class="count">{src.count}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
<style>
.section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.section-header h2 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, #64748b);
}
.list {
list-style: none;
padding: 0;
margin: 0;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
overflow: hidden;
}
.list li + li {
border-top: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
}
.source-row {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.9rem;
font: inherit;
background: transparent;
color: inherit;
border: none;
cursor: pointer;
}
.source-row:hover {
background: color-mix(in srgb, currentColor 4%, transparent);
}
.name {
font-weight: 500;
}
.count {
font-size: 0.82rem;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -1,63 +0,0 @@
<!--
One-line stats strip — gespeichert diese Woche, gelesen diese
Woche, ø Lesezeit aller aktiven (unread + reading) Artikel.
-->
<script lang="ts">
import type { Article } from '../types';
interface Props {
savedThisWeek: number;
finishedThisWeek: number;
articles: Article[];
}
let { savedThisWeek, finishedThisWeek, articles }: Props = $props();
const avgReadMin = $derived.by(() => {
const active = articles.filter((a) => a.status === 'unread' || a.status === 'reading');
if (active.length === 0) return null;
const total = active.reduce((sum, a) => sum + (a.readingTimeMinutes ?? 0), 0);
return Math.round(total / active.length);
});
</script>
<section class="stats">
<div class="cell">
<strong>{savedThisWeek}</strong>
<span>diese Woche gespeichert</span>
</div>
<div class="cell">
<strong>{finishedThisWeek}</strong>
<span>diese Woche gelesen</span>
</div>
{#if avgReadMin !== null}
<div class="cell">
<strong>ø {avgReadMin} min</strong>
<span>pro Artikel in der Leseliste</span>
</div>
{/if}
</section>
<style>
.stats {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.85rem 1rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
}
.cell {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.cell strong {
font-size: 1.25rem;
font-weight: 600;
}
.cell span {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -1,71 +0,0 @@
<!--
Continue-Reading-Section: horizontales Carousel mit Artikeln die
aktuell `status='reading'` haben. Nur gerendert wenn >0 Artikel.
-->
<script lang="ts">
import ArticleCard from './ArticleCard.svelte';
import type { Article } from '../types';
interface Props {
articles: Article[];
}
let { articles }: Props = $props();
</script>
{#if articles.length > 0}
<section class="section">
<header class="section-header">
<h2>Weiterlesen</h2>
<span class="count">{articles.length}</span>
</header>
<div class="carousel">
{#each articles as article (article.id)}
<div class="slot">
<ArticleCard {article} variant="compact" />
</div>
{/each}
</div>
</section>
{/if}
<style>
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.section-header h2 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, #64748b);
}
.count {
font-size: 0.72rem;
color: var(--color-text-muted, #64748b);
opacity: 0.7;
}
.carousel {
display: flex;
gap: 0.65rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scroll-snap-type: x proximity;
/* Stretches to the shell edges even when the parent has padding — */
/* lets cards scroll off the visible right edge rather than clipping */
/* awkwardly. */
scrollbar-width: thin;
}
.slot {
flex: 0 0 260px;
scroll-snap-align: start;
display: flex;
}
</style>

View file

@ -1,435 +0,0 @@
<!--
JobDetailView — live progress of a bulk-import job. Drives the
/articles/import/[jobId] route.
Header: status, total, counters, action bar (pause/resume/cancel/retry).
Body: per-item rows with state pill + URL + action link.
Plan: docs/plans/articles-bulk-import.md.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { articleImportsStore } from '../stores/imports.svelte';
import { useImportItems, useImportJob } from '../queries';
import type { ArticleImportItem, ArticleImportItemState } from '../types';
interface Props {
jobId: string;
}
let { jobId }: Props = $props();
const job$ = $derived(useImportJob(jobId));
const items$ = $derived(useImportItems(jobId));
const job = $derived(job$.value);
const items = $derived(items$.value);
let busyAction = $state<string | null>(null);
const totalDone = $derived(job ? job.savedCount + job.duplicateCount + job.errorCount : 0);
const progressPct = $derived(
job && job.totalUrls > 0 ? Math.round((totalDone / job.totalUrls) * 100) : 0
);
async function withBusy(name: string, fn: () => Promise<unknown>) {
busyAction = name;
try {
await fn();
} finally {
busyAction = null;
}
}
function statePill(state: ArticleImportItemState): { label: string; klass: string } {
switch (state) {
case 'pending':
return { label: $_('articles.import.item_pending'), klass: 'pill-pending' };
case 'extracting':
return { label: $_('articles.import.item_extracting'), klass: 'pill-extracting' };
case 'extracted':
return { label: $_('articles.import.item_extracted'), klass: 'pill-extracted' };
case 'saved':
return { label: $_('articles.import.item_saved'), klass: 'pill-saved' };
case 'duplicate':
return { label: $_('articles.import.item_duplicate'), klass: 'pill-dup' };
case 'consent-wall':
return { label: $_('articles.import.item_consent_wall'), klass: 'pill-warn' };
case 'error':
return { label: $_('articles.import.item_error'), klass: 'pill-error' };
case 'cancelled':
return { label: $_('articles.import.item_cancelled'), klass: 'pill-cancelled' };
}
}
function shortUrl(item: ArticleImportItem): string {
try {
const u = new URL(item.url);
return u.host + u.pathname.replace(/\/$/, '');
} catch {
return item.url;
}
}
</script>
<div class="job-shell">
{#if !job}
<p class="empty">{$_('articles.import.detail_not_found')}</p>
{:else}
{@const j = job}
<header class="header">
<div class="title-row">
<h1>{$_('articles.import.detail_title')}</h1>
<span class="status status-{j.status}">{j.status}</span>
</div>
<div class="progress-bar" aria-label={$_('articles.import.detail_progress_aria')}>
<div class="progress-fill" style="width: {progressPct}%"></div>
</div>
<div class="counters">
<span class="counter">
{$_('articles.import.detail_counter_total', {
values: { done: totalDone, total: j.totalUrls },
})}
</span>
{#if j.savedCount > 0}
<span class="counter ok">
{$_('articles.import.detail_counter_saved', { values: { n: j.savedCount } })}
</span>
{/if}
{#if j.duplicateCount > 0}
<span class="counter dup">
{$_('articles.import.detail_counter_dups', { values: { n: j.duplicateCount } })}
</span>
{/if}
{#if j.warningCount > 0}
<span class="counter warn">
{$_('articles.import.detail_counter_warns', { values: { n: j.warningCount } })}
</span>
{/if}
{#if j.errorCount > 0}
<span class="counter err">
{$_('articles.import.detail_counter_errors', { values: { n: j.errorCount } })}
</span>
{/if}
</div>
<div class="actions">
{#if j.status === 'running' || j.status === 'queued'}
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={() => withBusy('pause', () => articleImportsStore.pauseJob(jobId))}
>
{$_('articles.import.action_pause')}
</button>
{/if}
{#if j.status === 'paused'}
<button
type="button"
class="primary"
disabled={busyAction !== null}
onclick={() => withBusy('resume', () => articleImportsStore.resumeJob(jobId))}
>
{$_('articles.import.action_resume')}
</button>
{/if}
{#if j.status === 'running' || j.status === 'queued' || j.status === 'paused'}
<button
type="button"
class="danger"
disabled={busyAction !== null}
onclick={() => {
if (confirm($_('articles.import.confirm_cancel')))
void withBusy('cancel', () => articleImportsStore.cancelJob(jobId));
}}
>
{$_('articles.import.action_cancel')}
</button>
{/if}
{#if j.errorCount > 0}
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={() => withBusy('retry', () => articleImportsStore.retryFailed(jobId))}
>
{$_('articles.import.action_retry')}
</button>
{/if}
{#if j.status === 'done' || j.status === 'cancelled'}
<button
type="button"
class="ghost"
disabled={busyAction !== null}
onclick={() => {
if (confirm($_('articles.import.confirm_delete'))) {
void withBusy('delete', async () => {
await articleImportsStore.deleteJob(jobId);
goto('/articles/import');
});
}
}}
>
{$_('articles.import.action_delete')}
</button>
{/if}
</div>
</header>
{#if j.warningCount > 0}
<aside class="consent-hint" role="note">
<strong>{$_('articles.import.consent_hint_strong')}</strong>:
{$_('articles.import.consent_hint_body', { values: { n: j.warningCount } })}
<a href="/articles/settings">{$_('articles.import.consent_hint_link')}</a>
{$_('articles.import.consent_hint_after_link')}
</aside>
{/if}
<ul class="items">
{#each items as item (item.id)}
{@const pill = statePill(item.state)}
<li class="item">
<span class="pill {pill.klass}">{pill.label}</span>
<span class="url" title={item.url}>{shortUrl(item)}</span>
{#if item.state === 'consent-wall' && item.articleId}
<span class="action-group">
<a class="action" href="/articles/{item.articleId}">
{$_('articles.import.item_action_view_teaser')}
</a>
<a
class="action action-rescue"
href={`/articles/add?source=bookmarklet&url=${encodeURIComponent(item.url)}`}
title={$_('articles.import.item_action_rescue_tip')}
>
{$_('articles.import.item_action_rescue')}
</a>
</span>
{:else if item.articleId && (item.state === 'saved' || item.state === 'duplicate')}
<a class="action" href="/articles/{item.articleId}">
{$_('articles.import.item_action_open')}
</a>
{:else if item.state === 'error' && item.error}
<span class="error-msg" title={item.error}>{item.error}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style>
.job-shell {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem;
}
.empty {
color: var(--color-text-muted, #64748b);
}
.header {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.title-row {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.title-row h1 {
margin: 0;
font-size: 1.45rem;
}
.status {
font-size: 0.75rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.status-queued,
.status-paused {
background: color-mix(in srgb, #64748b 14%, transparent);
color: #475569;
}
.status-running {
background: color-mix(in srgb, #f97316 16%, transparent);
color: #ea580c;
}
.status-done {
background: color-mix(in srgb, #16a34a 14%, transparent);
color: #16a34a;
}
.status-cancelled {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.progress-bar {
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #f97316;
transition: width 220ms ease;
}
.counters {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.counter {
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 6%, transparent);
}
.counter.ok {
color: #16a34a;
}
.counter.dup {
color: var(--color-text-muted, #64748b);
}
.counter.warn {
color: #b45309;
}
.counter.err {
color: #ef4444;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.actions button {
padding: 0.4rem 0.85rem;
border-radius: 0.45rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.actions .primary {
background: #f97316;
border-color: #f97316;
color: white;
}
.actions .primary:hover:not(:disabled) {
background: #ea580c;
}
.actions .danger:hover:not(:disabled) {
border-color: #ef4444;
color: #ef4444;
}
.actions .ghost {
opacity: 0.7;
}
.actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.item {
display: grid;
grid-template-columns: 9rem 1fr auto;
gap: 0.65rem;
align-items: center;
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
border-radius: 0.45rem;
font-size: 0.88rem;
}
.pill {
font-size: 0.76rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-align: center;
white-space: nowrap;
}
.pill-pending {
background: color-mix(in srgb, #64748b 10%, transparent);
color: #64748b;
}
.pill-extracting,
.pill-extracted {
background: color-mix(in srgb, #f97316 12%, transparent);
color: #ea580c;
}
.pill-saved {
background: color-mix(in srgb, #16a34a 14%, transparent);
color: #16a34a;
}
.pill-dup {
background: color-mix(in srgb, #64748b 12%, transparent);
color: #475569;
}
.pill-warn {
background: color-mix(in srgb, #f59e0b 14%, transparent);
color: #b45309;
}
.pill-error {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.pill-cancelled {
background: color-mix(in srgb, #64748b 8%, transparent);
color: #64748b;
opacity: 0.7;
}
.url {
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
font-size: 0.82rem;
color: #ea580c;
text-decoration: none;
}
.action:hover {
text-decoration: underline;
}
.action-group {
display: inline-flex;
gap: 0.65rem;
}
.action-rescue {
color: #b45309;
}
.consent-hint {
margin: 0 0 0.75rem 0;
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent);
background: color-mix(in srgb, #f59e0b 8%, transparent);
font-size: 0.85rem;
line-height: 1.5;
}
.consent-hint a {
color: #b45309;
text-decoration: underline;
}
.error-msg {
font-size: 0.78rem;
color: #ef4444;
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -1,260 +0,0 @@
<!--
JobsList — index of all bulk-import jobs in the active space, newest
first. Click → /articles/import/[jobId].
Plan: docs/plans/articles-bulk-import.md.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { useImportJobs } from '../queries';
import type { ArticleImportJob } from '../types';
type Filter = 'all' | 'active' | 'done' | 'errors';
const jobs$ = useImportJobs();
const allJobs = $derived(jobs$.value);
let filter = $state<Filter>('all');
const activeCount = $derived(
allJobs.filter((j) => j.status === 'queued' || j.status === 'running' || j.status === 'paused')
.length
);
const doneCount = $derived(allJobs.filter((j) => j.status === 'done').length);
const errorCount = $derived(allJobs.filter((j) => j.errorCount > 0).length);
const visibleJobs = $derived(
filter === 'all'
? allJobs
: filter === 'active'
? allJobs.filter(
(j) => j.status === 'queued' || j.status === 'running' || j.status === 'paused'
)
: filter === 'done'
? allJobs.filter((j) => j.status === 'done')
: allJobs.filter((j) => j.errorCount > 0)
);
function progress(job: ArticleImportJob): string {
const done = job.savedCount + job.duplicateCount + job.errorCount;
return `${done} / ${job.totalUrls}`;
}
function statusLabel(s: ArticleImportJob['status']): string {
switch (s) {
case 'queued':
return $_('articles.import.status_queued');
case 'running':
return $_('articles.import.status_running');
case 'paused':
return $_('articles.import.status_paused');
case 'done':
return $_('articles.import.status_done');
case 'cancelled':
return $_('articles.import.status_cancelled');
}
}
</script>
{#if allJobs.length > 0}
<section class="jobs-list">
<header class="list-header">
<h2>{$_('articles.import.jobs_heading')}</h2>
<nav class="filter-tabs" aria-label="Filter">
<button
type="button"
class="tab"
class:tab-active={filter === 'all'}
onclick={() => (filter = 'all')}
>
{$_('articles.import.filter_all', { values: { n: allJobs.length } })}
</button>
<button
type="button"
class="tab"
class:tab-active={filter === 'active'}
onclick={() => (filter = 'active')}
disabled={activeCount === 0}
>
{$_('articles.import.filter_active', { values: { n: activeCount } })}
</button>
<button
type="button"
class="tab"
class:tab-active={filter === 'done'}
onclick={() => (filter = 'done')}
disabled={doneCount === 0}
>
{$_('articles.import.filter_done', { values: { n: doneCount } })}
</button>
<button
type="button"
class="tab"
class:tab-active={filter === 'errors'}
onclick={() => (filter = 'errors')}
disabled={errorCount === 0}
>
{$_('articles.import.filter_errors', { values: { n: errorCount } })}
</button>
</nav>
</header>
{#if visibleJobs.length === 0}
<p class="empty-filter">{$_('articles.import.empty_filter')}</p>
{/if}
<ul>
{#each visibleJobs as job (job.id)}
<button type="button" class="row" onclick={() => goto(`/articles/import/${job.id}`)}>
<span class="status status-{job.status}">{statusLabel(job.status)}</span>
<span class="progress">{progress(job)}</span>
<span class="meta">
{#if job.errorCount > 0}
<span class="meta-err">
{$_('articles.import.jobs_meta_errors', { values: { n: job.errorCount } })}
</span>
{/if}
{#if job.duplicateCount > 0}
<span class="meta-dup">
{$_('articles.import.jobs_meta_dups', { values: { n: job.duplicateCount } })}
</span>
{/if}
{#if job.warningCount > 0}
<span class="meta-warn">
{$_('articles.import.jobs_meta_warnings', { values: { n: job.warningCount } })}
</span>
{/if}
</span>
<span class="when">{new Date(job.createdAt).toLocaleString()}</span>
</button>
{/each}
</ul>
</section>
{/if}
<style>
.jobs-list {
max-width: 760px;
margin: 1.5rem auto 0;
padding: 0 1.5rem;
}
.list-header {
display: flex;
gap: 0.85rem;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 0.65rem;
}
.jobs-list h2 {
margin: 0;
font-size: 1.05rem;
}
.filter-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tab {
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: var(--color-text-muted, #64748b);
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.tab:hover:not(:disabled) {
border-color: color-mix(in srgb, #f97316 60%, transparent);
color: inherit;
}
.tab:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tab-active {
background: #f97316;
color: white;
border-color: #f97316;
}
.tab-active:hover:not(:disabled) {
background: #ea580c;
color: white;
}
.empty-filter {
margin: 0.5rem 0 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.85rem;
}
.jobs-list ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
display: grid;
grid-template-columns: 6rem 5rem 1fr auto;
gap: 0.65rem;
align-items: center;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.55rem;
background: var(--color-surface, transparent);
font: inherit;
text-align: left;
cursor: pointer;
}
.row:hover {
border-color: color-mix(in srgb, #f97316 60%, transparent);
}
.status {
font-size: 0.78rem;
font-weight: 500;
padding: 0.12rem 0.5rem;
border-radius: 999px;
text-align: center;
}
.status-queued,
.status-paused {
background: color-mix(in srgb, #64748b 12%, transparent);
color: #475569;
}
.status-running {
background: color-mix(in srgb, #f97316 14%, transparent);
color: #ea580c;
}
.status-done {
background: color-mix(in srgb, #16a34a 14%, transparent);
color: #16a34a;
}
.status-cancelled {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.progress {
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.meta {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
}
.meta-err {
color: #ef4444;
}
.meta-warn {
color: #b45309;
}
.meta-dup {
color: var(--color-text-muted, #64748b);
}
.when {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
white-space: nowrap;
}
</style>

View file

@ -1,133 +0,0 @@
<!--
QuickAddInput — inline URL-Eingabe, liegt in der Shell-Header.
Auf Enter/Klick: saveFromUrl → Reader. Kein Preview, kein Dialog.
Consent-Wall-Fälle gehen durch zum /articles/add-Formular wenn der
Nutzer dort Preview + Warn-Karte braucht.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { LinkSimple } from '@mana/shared-icons';
import { articlesStore } from '../stores/articles.svelte';
let url = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
async function handleSubmit() {
const trimmed = url.trim();
if (!trimmed || busy) return;
try {
new URL(trimmed);
} catch {
error = 'Das sieht nicht nach einer gültigen URL aus.';
return;
}
busy = true;
error = null;
try {
const { article } = await articlesStore.saveFromUrl(trimmed);
url = '';
goto(`/articles/${article.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
} finally {
busy = false;
}
}
</script>
<div class="quick-add-wrap">
<div class="quick-add" role="search">
<LinkSimple size={18} weight="regular" class="quick-add-icon" />
<input
type="url"
class="quick-input"
bind:value={url}
placeholder="URL einfügen und Enter drücken…"
disabled={busy}
onkeydown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
/>
<button
type="button"
class="quick-submit"
disabled={busy || !url.trim()}
onclick={handleSubmit}
aria-label="URL speichern"
>
{#if busy}Speichere…{:else}Speichern{/if}
</button>
</div>
{#if error}
<p class="quick-error" role="alert">{error}</p>
{/if}
</div>
<style>
.quick-add-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
min-width: 240px;
}
.quick-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.5rem 0.35rem 0.65rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: var(--color-surface, transparent);
}
.quick-add :global(.quick-add-icon) {
color: var(--color-text-muted, #64748b);
flex-shrink: 0;
}
.quick-add:focus-within {
border-color: #f97316;
}
.quick-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font: inherit;
color: inherit;
padding: 0.3rem 0;
}
.quick-input:disabled {
opacity: 0.6;
}
.quick-submit {
padding: 0.35rem 0.85rem;
border-radius: 0.4rem;
border: 1px solid #f97316;
background: #f97316;
color: white;
font: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.quick-submit:hover:not(:disabled) {
background: #ea580c;
border-color: #ea580c;
}
.quick-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quick-error {
margin: 0;
padding: 0.35rem 0.65rem;
border-radius: 0.4rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.82rem;
}
</style>

View file

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

View file

@ -1,202 +0,0 @@
/**
* Articles Bulk-Import client-side Pickup Consumer.
*
* The server-side import-worker drops `articleExtractPickup` rows for
* each successful URL extraction. This consumer:
*
* 1. Watches the pickup table via `liveQuery`.
* 2. For each new row: calls `articlesStore.saveFromExtracted()` so
* the existing Single-URL save-path runs unchanged (encrypt
* `articleTable.add()` emit ArticleSaved domain event).
* 3. Updates the matching `articleImportItems` row to state='saved'
* (or 'duplicate' / 'consent-wall') with the resulting articleId.
* 4. Deletes the pickup row so the inbox stays empty in steady state.
*
* Multi-tab coordination via `navigator.locks.request('mana:articles:pickup')`:
* any number of tabs can subscribe, but only the lock-holder consumes.
* Falls back to per-row in-memory dedupe when locks aren't available
* (older Safari) the field-LWW merge on the server forgives the rare
* double-process.
*
* Plan: docs/plans/articles-bulk-import.md.
*/
import { liveQuery, type Subscription } from 'dexie';
import { emitDomainEvent } from '$lib/data/events';
import {
articleExtractPickupTable,
articleImportItemTable,
articleImportJobTable,
} from './collections';
import { articlesStore } from './stores/articles.svelte';
import type {
ArticleImportItemState,
LocalArticleExtractPickup,
LocalArticleImportJob,
} from './types';
const LOCK_NAME = 'mana:articles:pickup';
/** In-memory guard so a quick liveQuery double-tick doesn't race the
* same pickup row through `consumeOne` twice. Reset on tab close. */
const inFlight = new Set<string>();
let subscription: Subscription | null = null;
let jobWatchSubscription: Subscription | null = null;
/** Track which jobs we've already emitted ArticleImportFinished for so a
* liveQuery re-tick doesn't double-fire when other rows change. */
const finishedEmitted = new Set<string>();
/**
* Start watching the pickup inbox. Idempotent second call returns
* the existing dispose function.
*
* Disable via `localStorage('mana:articles:pickup:disabled')` (string
* 'true') escape hatch for users who want to debug without the
* consumer running.
*/
export function startArticlePickupConsumer(): () => void {
if (typeof window === 'undefined') return () => {};
if (subscription) return stopArticlePickupConsumer;
if (window.localStorage?.getItem('mana:articles:pickup:disabled') === 'true') {
console.log('[articles-import] pickup consumer disabled via localStorage');
return () => {};
}
const query = liveQuery(async () =>
articleExtractPickupTable.filter((r) => !r.deletedAt).toArray()
);
subscription = query.subscribe({
next: (rows: LocalArticleExtractPickup[]) => {
void runConsume(rows);
},
error: (err) => {
console.error('[articles-import] pickup liveQuery error:', err);
},
});
// Independently watch the jobs table for status='done' flips so we
// can emit `ArticleImportFinished` once per job. Server-worker
// flips the status; this is the only client-side observer for the
// terminal transition.
const jobsQuery = liveQuery(async () =>
articleImportJobTable.filter((j) => j.status === 'done' && !j.deletedAt).toArray()
);
jobWatchSubscription = jobsQuery.subscribe({
next: (jobs: LocalArticleImportJob[]) => {
for (const j of jobs) {
if (finishedEmitted.has(j.id)) continue;
finishedEmitted.add(j.id);
emitDomainEvent('ArticleImportFinished', 'articles', 'articleImportJobs', j.id, {
jobId: j.id,
totalUrls: j.totalUrls,
savedCount: j.savedCount ?? 0,
duplicateCount: j.duplicateCount ?? 0,
errorCount: j.errorCount ?? 0,
warningCount: j.warningCount ?? 0,
});
}
},
error: (err) => {
console.error('[articles-import] job-watch liveQuery error:', err);
},
});
return stopArticlePickupConsumer;
}
export function stopArticlePickupConsumer(): void {
subscription?.unsubscribe();
subscription = null;
jobWatchSubscription?.unsubscribe();
jobWatchSubscription = null;
inFlight.clear();
finishedEmitted.clear();
}
/**
* Drain the current set of pickup rows under the multi-tab Web-Lock.
* If the lock is held by another tab, this returns immediately and the
* other tab's run handles the rows.
*/
async function runConsume(rows: readonly LocalArticleExtractPickup[]): Promise<void> {
if (rows.length === 0) return;
const locks = (navigator as Navigator & { locks?: LockManager }).locks;
if (!locks) {
await drain(rows);
return;
}
await locks.request(LOCK_NAME, { ifAvailable: true }, async (lock) => {
if (!lock) {
// Another tab is the consumer — leave the rows alone.
return;
}
await drain(rows);
});
}
async function drain(rows: readonly LocalArticleExtractPickup[]): Promise<void> {
for (const row of rows) {
if (inFlight.has(row.id)) continue;
inFlight.add(row.id);
try {
await consumeOne(row);
} catch (err) {
console.error('[articles-import] consumeOne failed:', row.id, err);
} finally {
inFlight.delete(row.id);
}
}
}
async function consumeOne(row: LocalArticleExtractPickup): Promise<void> {
const item = await articleImportItemTable.get(row.itemId);
// Stale pickup row — item was deleted, cancelled, or already
// consumed by a previous tab. Just clean up the inbox.
if (!item || item.state !== 'extracted' || item.deletedAt) {
await articleExtractPickupTable.delete(row.id);
return;
}
// Dedupe race: user may have single-saved this URL via QuickAddInput
// while the bulk job was running. Don't write a duplicate Article
// row; just point the import item at the existing one.
const existing = await articlesStore.findByUrl(row.payload.originalUrl);
if (existing) {
await articleImportItemTable.update(item.id, {
state: 'duplicate',
articleId: existing.id,
});
await articleExtractPickupTable.delete(row.id);
return;
}
// Happy path: persist via the existing single-URL pipeline. This
// runs encryptRecord + articleTable.add and emits the ArticleSaved
// domain event, exactly like a manual `Save URL` would.
const article = await articlesStore.saveFromExtracted({
originalUrl: row.payload.originalUrl,
title: row.payload.title,
excerpt: row.payload.excerpt,
content: row.payload.content,
htmlContent: row.payload.htmlContent,
author: row.payload.author,
siteName: row.payload.siteName,
wordCount: row.payload.wordCount,
readingTimeMinutes: row.payload.readingTimeMinutes,
warning: row.payload.warning,
});
const nextState: ArticleImportItemState =
row.payload.warning === 'probable_consent_wall' ? 'consent-wall' : 'saved';
await articleImportItemTable.update(item.id, {
state: nextState,
articleId: article.id,
warning: row.payload.warning ?? null,
});
await articleExtractPickupTable.delete(row.id);
}

View file

@ -1,35 +0,0 @@
/**
* Articles module barrel exports.
*/
export { articlesStore } from './stores/articles.svelte';
export { highlightsStore } from './stores/highlights.svelte';
export { articleTagOps } from './stores/tags.svelte';
export {
useAllArticles,
useArticle,
useArticleHighlights,
useAllHighlights,
useArticleTagIds,
useArticleTagMap,
useStats,
toArticle,
toHighlight,
filterByStatus,
searchArticles,
} from './queries';
export type { ArticlesStats, SiteCount, HighlightWithArticle } from './queries';
export { articleTable, articleHighlightTable, articleTagTable } from './collections';
export type {
LocalArticle,
LocalHighlight,
LocalArticleTag,
Article,
Highlight,
ArticleStatus,
HighlightColor,
} from './types';

View file

@ -1,68 +0,0 @@
/**
* Markdown export for the highlights collection view.
*
* Groups highlights by article, dumps them in the order
* `useAllHighlights` returned them (chronological), and wraps the whole
* thing in a small header with the export date so the user can paste
* the result into Obsidian, Notion, a Markdown note whatever.
*
* Kept in a standalone file so the export logic can be unit-tested
* without needing the Svelte render tree.
*/
import type { HighlightWithArticle } from '../queries';
/** Escape the minimum set of Markdown specials that show up in article
* titles and highlight text so pasted output doesn't accidentally
* format parts of the quote. We don't escape inside the quoted block
* itself the reader's expectation is "see what I highlighted". */
function escapeTitle(text: string): string {
return text.replace(/([\\*_`[\]<>])/g, '\\$1');
}
function formatDate(iso: string): string {
try {
return new Date(iso).toISOString().slice(0, 10);
} catch {
return iso.slice(0, 10);
}
}
export function renderHighlightsMarkdown(
rows: HighlightWithArticle[],
now: Date = new Date()
): string {
const header = `# Mana Highlights — ${now.toISOString().slice(0, 10)}\n`;
if (rows.length === 0) {
return `${header}\n_Keine Highlights._\n`;
}
// Preserve the incoming chronological order but group consecutive
// rows for the same article together. Using a manual walk instead of
// Map-groupBy keeps the per-section header below the most-recent row
// for that article, matching what the UI shows.
const blocks: string[] = [header];
let currentArticleId: string | null = null;
for (const row of rows) {
if (row.article.id !== currentArticleId) {
currentArticleId = row.article.id;
blocks.push('');
blocks.push(`## ${escapeTitle(row.article.title)}`);
const subtitle = [row.article.siteName, row.article.originalUrl]
.filter((s): s is string => !!s)
.join(' · ');
if (subtitle) blocks.push(`_${subtitle}_`);
blocks.push('');
}
const savedAt = row.highlight.createdAt ? ` _(${formatDate(row.highlight.createdAt)})_` : '';
blocks.push(`- > ${row.highlight.text.replace(/\n+/g, ' ')}${savedAt}`);
if (row.highlight.note) {
blocks.push(`${row.highlight.note.replace(/\n+/g, ' ')}`);
}
}
blocks.push('');
return blocks.join('\n');
}

View file

@ -1,195 +0,0 @@
/**
* Highlight offset resolution.
*
* We persist each highlight as a `{ startOffset, endOffset }` pair of
* plain-text character offsets into the Reader's root. "Plain text" here
* is the concatenation of all text nodes in document order i.e. the
* value of `root.textContent` so `<p>Hello <strong>world</strong></p>`
* has the offsets `H=0, e=1, …, w=6, o=7, …`. <br>, <img>, and block
* boundaries contribute zero characters, which matches `textContent`'s
* behaviour and the user's mental model of "what did I actually select?"
*
* Storing offsets into the rendered DOM (as opposed to the article's raw
* `content` field) means we don't have to reconcile Readability's
* whitespace normalisation with the browser's. On re-open we walk the
* same DOM and find the node for each offset.
*
* The context-snippet fields on `LocalHighlight` (`contextBefore`,
* `contextAfter`) are populated here for re-anchor purposes in later
* milestones (when the article is re-extracted and the offsets drift).
*/
const CONTEXT_CHARS = 40;
export interface TextOffsetPair {
start: number;
end: number;
}
/**
* A single contiguous text-node slice between `start` and `end` offsets.
* Used when wrapping a multi-node range into highlight spans.
*/
export interface TextSlice {
node: Text;
/** inclusive offset inside this text node */
start: number;
/** exclusive offset inside this text node */
end: number;
}
/**
* Resolve a DOM Range to plain-text offsets relative to `root`.
*
* Returns null if the range is collapsed, not inside the root, or
* crosses element boundaries we can't map (shouldn't happen for normal
* user selections inside the reader body).
*/
export function rangeToTextOffsets(range: Range, root: Element): TextOffsetPair | null {
if (range.collapsed) return null;
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
return null;
}
let start = -1;
let end = -1;
let offset = 0;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null = walker.nextNode();
while (node) {
const text = node as Text;
const length = text.data.length;
if (text === range.startContainer) {
start = offset + Math.min(range.startOffset, length);
}
if (text === range.endContainer) {
end = offset + Math.min(range.endOffset, length);
break;
}
offset += length;
node = walker.nextNode();
}
// Edge case: range boundaries are element nodes (e.g. the user
// triple-clicked a paragraph). Fall back to the first/last text
// descendant so we still get something saveable.
if (start === -1) start = descendantOffset(root, range.startContainer, range.startOffset);
if (end === -1) end = descendantOffset(root, range.endContainer, range.endOffset);
if (start < 0 || end < 0 || end <= start) return null;
return { start, end };
}
function descendantOffset(root: Element, container: Node, offset: number): number {
// Element-container ranges report offset in terms of child *nodes*, not
// characters. Translate by summing textContent of siblings before the
// child index.
if (container.nodeType === Node.TEXT_NODE) {
// Already a text node but wasn't hit in the main walk — find its absolute offset.
let total = 0;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let n: Node | null = walker.nextNode();
while (n) {
if (n === container) return total + Math.min(offset, (n as Text).data.length);
total += (n as Text).data.length;
n = walker.nextNode();
}
return total;
}
const childIndex = Math.min(offset, container.childNodes.length);
let total = textLengthBefore(root, container, childIndex);
return total;
}
function textLengthBefore(root: Element, container: Node, childIndex: number): number {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let total = 0;
let n: Node | null = walker.nextNode();
while (n) {
// Stop once we're past the target child.
if (isDescendantAtIndexOrLater(container, childIndex, n)) return total;
total += (n as Text).data.length;
n = walker.nextNode();
}
return total;
}
function isDescendantAtIndexOrLater(container: Node, index: number, candidate: Node): boolean {
// Walk up from candidate until we hit a direct child of container.
let node: Node | null = candidate;
while (node && node.parentNode !== container) {
node = node.parentNode;
}
if (!node) return false;
const idx = Array.prototype.indexOf.call(container.childNodes, node);
return idx >= index;
}
/**
* Resolve `{ start, end }` plain-text offsets back into a list of
* contiguous text-node slices, suitable for wrapping in highlight
* spans. Multi-paragraph selections yield multiple slices, one per
* text node touched.
*/
export function textOffsetsToSlices(root: Element, start: number, end: number): TextSlice[] {
const slices: TextSlice[] = [];
if (end <= start) return slices;
let offset = 0;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null = walker.nextNode();
while (node) {
const text = node as Text;
const length = text.data.length;
const nodeStart = offset;
const nodeEnd = offset + length;
if (nodeEnd > start && nodeStart < end) {
slices.push({
node: text,
start: Math.max(0, start - nodeStart),
end: Math.min(length, end - nodeStart),
});
}
if (nodeEnd >= end) break;
offset = nodeEnd;
node = walker.nextNode();
}
return slices;
}
export interface SelectionSnapshot {
start: number;
end: number;
text: string;
contextBefore: string | null;
contextAfter: string | null;
}
/**
* Package a user Range into everything we need to persist a highlight:
* offsets, selected text, and ~40 chars of surrounding context for
* later re-anchor attempts.
*/
export function extractSelectionSnapshot(range: Range, root: Element): SelectionSnapshot | null {
const offsets = rangeToTextOffsets(range, root);
if (!offsets) return null;
const whole = root.textContent ?? '';
const text = whole.slice(offsets.start, offsets.end).trim();
if (!text) return null;
const before = whole.slice(Math.max(0, offsets.start - CONTEXT_CHARS), offsets.start) || null;
const after = whole.slice(offsets.end, offsets.end + CONTEXT_CHARS) || null;
return {
start: offsets.start,
end: offsets.end,
text,
contextBefore: before,
contextAfter: after,
};
}

View file

@ -1,28 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
/**
* Articles module saved web articles + highlights + tag links + bulk-
* import jobs.
*
* `articleTags` is a pure junction into globalTags (the core `tags`
* appId). The junction itself syncs under `articles` appId with its
* owning rows, the same pattern every other tagged module uses
* (noteTags, eventTags, contactTags, placeTags, ).
*
* `articleImportJobs` + `articleImportItems` + `articleExtractPickup`
* implement the durable bulk-import pipeline (docs/plans/articles-bulk-
* import.md). All three sync under the articles appId so multi-device
* progress and server-worker state-transitions ride the standard
* sync_changes channel.
*/
export const articlesModuleConfig: ModuleConfig = {
appId: 'articles',
tables: [
{ name: 'articles' },
{ name: 'articleHighlights', syncName: 'highlights' },
{ name: 'articleTags' },
{ name: 'articleImportJobs', syncName: 'importJobs' },
{ name: 'articleImportItems', syncName: 'importItems' },
{ name: 'articleExtractPickup', syncName: 'extractPickup' },
],
};

View file

@ -1,60 +0,0 @@
/**
* Pure URL-list parser for the bulk-import flow. Extracted into its
* own module so tests can import + exercise it without booting Dexie
* (collections.ts and stores/imports.svelte.ts have a transitive
* dependency on the database, which won't open under fake-indexeddb
* if any registered table is currently in a half-migrated state).
*
* Plan: docs/plans/articles-bulk-import.md.
*/
export interface ParsedUrls {
valid: string[];
invalid: string[];
duplicates: string[];
}
/**
* Splits the raw textarea blob on any whitespace + comma, drops empty
* tokens, validates with `new URL` + http(s) scheme check, and
* deduplicates while preserving first-occurrence order.
*
* parseUrls('https://a.com\nhttps://a.com\nbroken')
* { valid: ['https://a.com/'],
* invalid: ['broken'],
* duplicates: ['https://a.com/'] }
*/
export function parseUrls(raw: string): ParsedUrls {
const tokens = raw
.split(/[\s,]+/)
.map((t) => t.trim())
.filter(Boolean);
const valid: string[] = [];
const invalid: string[] = [];
const duplicates: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
// Reject anything without an http(s) scheme — `new URL('foo.com')`
// would happily accept it as an opaque URI and the server-side
// fetch would then 400 on us.
let parsed: URL;
try {
parsed = new URL(token);
} catch {
invalid.push(token);
continue;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
invalid.push(token);
continue;
}
const canonical = parsed.toString();
if (seen.has(canonical)) {
duplicates.push(canonical);
continue;
}
seen.add(canonical);
valid.push(canonical);
}
return { valid, invalid, duplicates };
}

View file

@ -1,371 +0,0 @@
/**
* Reactive queries + type converters for the Articles module.
*
* Reads always flow through `scopedForModule` so the current space /
* scene-scope filter applies transparently module code never needs
* to know which space it's in.
*/
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule, scopedGet } from '$lib/data/scope';
import { articleTagOps } from './stores/tags.svelte';
import type {
Article,
ArticleImportItem,
ArticleImportJob,
ArticleStatus,
Highlight,
LocalArticle,
LocalArticleImportItem,
LocalArticleImportJob,
LocalHighlight,
} from './types';
// ─── Type Converters ─────────────────────────────────────
export function toArticle(local: LocalArticle): Article {
const now = new Date().toISOString();
return {
id: local.id,
originalUrl: local.originalUrl,
title: local.title,
excerpt: local.excerpt ?? null,
content: local.content,
htmlContent: local.htmlContent ?? null,
author: local.author ?? null,
siteName: local.siteName ?? null,
imageUrl: local.imageUrl ?? null,
wordCount: local.wordCount ?? null,
readingTimeMinutes: local.readingTimeMinutes ?? null,
publishedAt: local.publishedAt ?? null,
status: local.status,
readingProgress: local.readingProgress ?? 0,
isFavorite: local.isFavorite ?? false,
savedAt: local.savedAt,
readAt: local.readAt ?? null,
userNote: local.userNote ?? null,
extractedVersion: local.extractedVersion ?? 1,
createdAt: local.createdAt ?? now,
updatedAt: deriveUpdatedAt(local),
};
}
export function toHighlight(local: LocalHighlight): Highlight {
const now = new Date().toISOString();
return {
id: local.id,
articleId: local.articleId,
text: local.text,
note: local.note ?? null,
color: local.color,
startOffset: local.startOffset,
endOffset: local.endOffset,
contextBefore: local.contextBefore ?? null,
contextAfter: local.contextAfter ?? null,
createdAt: local.createdAt ?? now,
updatedAt: deriveUpdatedAt(local),
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllArticles() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalArticle, string>('articles', 'articles').toArray();
const visible = locals.filter((a) => !a.deletedAt);
const decrypted = await decryptRecords('articles', visible);
return decrypted
.map(toArticle)
.sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? ''));
}, [] as Article[]);
}
export function useArticle(id: string) {
return useScopedLiveQuery(
async () => {
// scopedGet returns undefined if the article belongs to another
// space — protects against URL-manipulated deep links.
const local = await scopedGet<LocalArticle>('articles', id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('articles', [local]);
return decrypted ? toArticle(decrypted) : null;
},
null as Article | null
);
}
/**
* Tag IDs currently linked to this article. Live reacts to both
* `articleTags` junction writes and tag CRUD on the global `tags`
* table, so the DetailView's TagField stays in sync with both sides.
*/
export function useArticleTagIds(articleId: string) {
return useScopedLiveQuery(async () => articleTagOps.getTagIds(articleId), [] as string[]);
}
/**
* Batched tag-id lookup for the ListView. Returns a Map keyed by
* articleId; entries with no tags are absent from the map. Single
* Dexie query regardless of how many articles are shown.
*/
export function useArticleTagMap(articleIds: string[]) {
return useScopedLiveQuery(
async () => articleTagOps.getTagIdsForMany(articleIds),
new Map<string, string[]>()
);
}
export interface SiteCount {
siteName: string;
count: number;
}
export interface ArticlesStats {
total: number;
unread: number;
reading: number;
finished: number;
archived: number;
favorites: number;
savedThisWeek: number;
finishedThisWeek: number;
topSites: SiteCount[];
totalHighlights: number;
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Aggregate stats for the dashboard widget + stats section. One live
* query over scope-filtered articles + highlights; decrypts only the
* articles (needed for title-based top-sites grouping).
*/
export function useStats() {
return useScopedLiveQuery(
async () => {
const [articleRows, highlightRows] = await Promise.all([
scopedForModule<LocalArticle, string>('articles', 'articles').toArray(),
scopedForModule<LocalHighlight, string>('articles', 'articleHighlights').toArray(),
]);
const visible = articleRows.filter((a) => !a.deletedAt);
const decrypted = await decryptRecords('articles', visible);
const now = Date.now();
const weekAgo = now - WEEK_MS;
const byStatus: Record<ArticleStatus, number> = {
unread: 0,
reading: 0,
finished: 0,
archived: 0,
};
let favorites = 0;
let savedThisWeek = 0;
let finishedThisWeek = 0;
const siteCounts = new Map<string, number>();
for (const a of decrypted) {
byStatus[a.status] = (byStatus[a.status] ?? 0) + 1;
if (a.isFavorite) favorites++;
const savedTs = a.savedAt ? Date.parse(a.savedAt) : NaN;
if (Number.isFinite(savedTs) && savedTs >= weekAgo) savedThisWeek++;
const readTs = a.readAt ? Date.parse(a.readAt) : NaN;
if (Number.isFinite(readTs) && readTs >= weekAgo) finishedThisWeek++;
if (a.siteName) {
siteCounts.set(a.siteName, (siteCounts.get(a.siteName) ?? 0) + 1);
}
}
const topSites: SiteCount[] = [...siteCounts.entries()]
.map(([siteName, count]) => ({ siteName, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
const totalHighlights = highlightRows.filter((h) => !h.deletedAt).length;
return {
total: decrypted.length,
unread: byStatus.unread,
reading: byStatus.reading,
finished: byStatus.finished,
archived: byStatus.archived,
favorites,
savedThisWeek,
finishedThisWeek,
topSites,
totalHighlights,
} satisfies ArticlesStats;
},
{
total: 0,
unread: 0,
reading: 0,
finished: 0,
archived: 0,
favorites: 0,
savedThisWeek: 0,
finishedThisWeek: 0,
topSites: [],
totalHighlights: 0,
} as ArticlesStats
);
}
/**
* Cross-article highlights query for `/articles/highlights`. Fetches
* all articles + all highlights in the active scope, pairs them up,
* and returns rows shaped for rendering as a chronological collection.
* Articles without highlights are excluded.
*/
export interface HighlightWithArticle {
highlight: Highlight;
article: Pick<Article, 'id' | 'title' | 'siteName' | 'originalUrl'>;
}
export function useAllHighlights() {
return useScopedLiveQuery(async () => {
const [articleRows, highlightRows] = await Promise.all([
scopedForModule<LocalArticle, string>('articles', 'articles').toArray(),
scopedForModule<LocalHighlight, string>('articles', 'articleHighlights').toArray(),
]);
const liveArticles = articleRows.filter((a) => !a.deletedAt);
const liveHighlights = highlightRows.filter((h) => !h.deletedAt);
if (liveHighlights.length === 0) return [] as HighlightWithArticle[];
const [decArticles, decHighlights] = await Promise.all([
decryptRecords('articles', liveArticles),
decryptRecords('articleHighlights', liveHighlights),
]);
const byId = new Map(decArticles.map((a) => [a.id, toArticle(a)]));
return decHighlights
.map((h) => {
const art = byId.get(h.articleId);
if (!art) return null;
return {
highlight: toHighlight(h),
article: {
id: art.id,
title: art.title,
siteName: art.siteName,
originalUrl: art.originalUrl,
},
} satisfies HighlightWithArticle;
})
.filter((r): r is HighlightWithArticle => r !== null)
.sort((a, b) => (b.highlight.createdAt ?? '').localeCompare(a.highlight.createdAt ?? ''));
}, [] as HighlightWithArticle[]);
}
export function useArticleHighlights(articleId: string) {
return useScopedLiveQuery(async () => {
// scopedForModule returns the scope-filtered Collection; we narrow
// to this article in a post-filter (O(highlights per space), tiny).
// Using scopedForModule instead of a direct indexed where() keeps the
// scope check centralised — same pattern other modules use for
// per-parent lookups (e.g. notes tag subsets).
const locals = await scopedForModule<LocalHighlight, string>(
'articles',
'articleHighlights'
).toArray();
const forArticle = locals.filter((h) => h.articleId === articleId && !h.deletedAt);
const decrypted = await decryptRecords('articleHighlights', forArticle);
return decrypted.map(toHighlight).sort((a, b) => a.startOffset - b.startOffset);
}, [] as Highlight[]);
}
// ─── Bulk-Import (docs/plans/articles-bulk-import.md) ────
export function toImportJob(local: LocalArticleImportJob): ArticleImportJob {
const now = new Date().toISOString();
return {
id: local.id,
totalUrls: local.totalUrls,
status: local.status,
startedAt: local.startedAt ?? null,
finishedAt: local.finishedAt ?? null,
savedCount: local.savedCount ?? 0,
duplicateCount: local.duplicateCount ?? 0,
errorCount: local.errorCount ?? 0,
warningCount: local.warningCount ?? 0,
createdAt: local.createdAt ?? now,
updatedAt: deriveUpdatedAt(local) ?? local.createdAt ?? now,
};
}
export function toImportItem(local: LocalArticleImportItem): ArticleImportItem {
const now = new Date().toISOString();
return {
id: local.id,
jobId: local.jobId,
idx: local.idx,
url: local.url,
state: local.state,
articleId: local.articleId ?? null,
warning: local.warning ?? null,
error: local.error ?? null,
attempts: local.attempts ?? 0,
lastAttemptAt: local.lastAttemptAt ?? null,
createdAt: local.createdAt ?? now,
updatedAt: deriveUpdatedAt(local) ?? local.createdAt ?? now,
};
}
/** All bulk-import jobs in the active space, newest first. Drives the
* `/articles/import` index. */
export function useImportJobs() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalArticleImportJob, string>(
'articles',
'articleImportJobs'
).toArray();
const visible = locals.filter((j) => !j.deletedAt);
visible.sort((a, b) => (deriveUpdatedAt(b) ?? '').localeCompare(deriveUpdatedAt(a) ?? ''));
return visible.map(toImportJob);
}, [] as ArticleImportJob[]);
}
/** Single job — drives the `/articles/import/[jobId]` detail header. */
export function useImportJob(jobId: string) {
return useScopedLiveQuery(
async () => {
const local = await scopedGet<LocalArticleImportJob>('articleImportJobs', jobId);
if (!local || local.deletedAt) return null;
return toImportJob(local);
},
null as ArticleImportJob | null
);
}
/** Items for one job, in the original input order. Drives the per-row
* list on the detail view. */
export function useImportItems(jobId: string) {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalArticleImportItem, string>(
'articles',
'articleImportItems'
).toArray();
const forJob = locals.filter((i) => i.jobId === jobId && !i.deletedAt);
forJob.sort((a, b) => a.idx - b.idx);
return forJob.map(toImportItem);
}, [] as ArticleImportItem[]);
}
// ─── Pure Helpers ─────────────────────────────────────────
export function filterByStatus(articles: Article[], status: ArticleStatus): Article[] {
return articles.filter((a) => a.status === status);
}
export function searchArticles(articles: Article[], query: string): Article[] {
const lower = query.toLowerCase();
return articles.filter(
(a) =>
a.title.toLowerCase().includes(lower) ||
(a.author?.toLowerCase().includes(lower) ?? false) ||
(a.siteName?.toLowerCase().includes(lower) ?? false)
);
}

View file

@ -1,129 +0,0 @@
/**
* Articles store mutation-only service.
*
* M1 scope is intentionally thin: delete + status/favourite/progress toggles
* that exercise the encryption + event pipeline. `saveFromUrl` (the real
* ingestion path) lands in M2 together with the server extract route and
* AddUrlForm. The pipeline is wired now so the Reader view and CRUD plumbing
* in M2/M3 can slot in without reshaping calls.
*/
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { scopedForModule } from '$lib/data/scope';
import { articleTable } from '../collections';
import { extractArticle, type ExtractedArticle } from '../api';
import { toArticle } from '../queries';
import type { Article, ArticleStatus, LocalArticle } from '../types';
export const articlesStore = {
async setStatus(id: string, status: ArticleStatus): Promise<void> {
const diff: Partial<LocalArticle> = {
status,
};
if (status === 'finished') {
const existing = await articleTable.get(id);
if (existing && !existing.readAt) diff.readAt = diff.updatedAt;
}
await articleTable.update(id, diff);
},
async toggleFavorite(id: string): Promise<void> {
const existing = await articleTable.get(id);
if (!existing) return;
await articleTable.update(id, {
isFavorite: !existing.isFavorite,
});
},
async setProgress(id: string, progress: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, progress));
await articleTable.update(id, {
readingProgress: clamped,
});
},
async updateNote(id: string, note: string | null): Promise<void> {
const diff: Partial<LocalArticle> = {
userNote: note,
};
await encryptRecord('articles', diff as LocalArticle);
await articleTable.update(id, diff);
},
async deleteArticle(id: string): Promise<void> {
await articleTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
/**
* Look up an already-saved article by URL in the current space. Used
* by the dedupe path in saveFromUrl and by AddUrlForm to offer
* "already saved — open it" instead of duplicating the row.
* Returns a decrypted snapshot, or null.
*/
async findByUrl(url: string): Promise<Article | null> {
const match = await scopedForModule<LocalArticle, string>('articles', 'articles')
.filter((r) => r.originalUrl === url && !r.deletedAt)
.first();
if (!match) return null;
const [decrypted] = await decryptRecords('articles', [match]);
return decrypted ? toArticle(decrypted) : null;
},
/**
* Persist an extracted payload as a saved article. Returns the snapshot
* directly so callers can navigate to `/articles/<id>` without waiting
* for the liveQuery to tick.
*
* AddUrlForm passes a pre-extracted payload (after it already called
* extractArticle once to render the preview); the direct path in
* saveFromUrl lets the store do the extract itself.
*/
async saveFromExtracted(extracted: ExtractedArticle): Promise<Article> {
const now = new Date().toISOString();
const newLocal: LocalArticle = {
id: crypto.randomUUID(),
originalUrl: extracted.originalUrl,
title: extracted.title,
excerpt: extracted.excerpt ?? null,
content: extracted.content,
htmlContent: extracted.htmlContent ?? null,
author: extracted.author ?? null,
siteName: extracted.siteName ?? null,
imageUrl: null,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
publishedAt: null,
status: 'unread',
readingProgress: 0,
isFavorite: false,
savedAt: now,
readAt: null,
userNote: null,
extractedVersion: 1,
};
const snapshot = toArticle(newLocal);
await encryptRecord('articles', newLocal);
await articleTable.add(newLocal);
emitDomainEvent('ArticleSaved', 'articles', 'articles', newLocal.id, {
articleId: newLocal.id,
title: newLocal.title,
});
return snapshot;
},
/**
* Full save path: dedupe extract persist. Returns the existing
* article when the URL is already saved in the current space (the
* caller can then navigate to it instead of creating a duplicate).
*/
async saveFromUrl(url: string): Promise<{ article: Article; duplicate: boolean }> {
const existing = await this.findByUrl(url);
if (existing) return { article: existing, duplicate: true };
const extracted = await extractArticle(url);
const article = await this.saveFromExtracted(extracted);
return { article, duplicate: false };
},
};

View file

@ -1,63 +0,0 @@
/**
* Highlights store mutation-only service for `articleHighlights`.
*
* Every write routes through encryptRecord so text + note + context
* snippets ship encrypted. Structural fields (articleId, startOffset,
* endOffset, color) stay plaintext for the reader's range-scan query.
*/
import { encryptRecord } from '$lib/data/crypto';
import { articleHighlightTable } from '../collections';
import { toHighlight } from '../queries';
import type { Highlight, HighlightColor, LocalHighlight } from '../types';
export interface AddHighlightInput {
articleId: string;
text: string;
color?: HighlightColor;
note?: string | null;
startOffset: number;
endOffset: number;
contextBefore?: string | null;
contextAfter?: string | null;
}
export const highlightsStore = {
async addHighlight(input: AddHighlightInput): Promise<Highlight> {
const newLocal: LocalHighlight = {
id: crypto.randomUUID(),
articleId: input.articleId,
text: input.text,
note: input.note ?? null,
color: input.color ?? 'yellow',
startOffset: input.startOffset,
endOffset: input.endOffset,
contextBefore: input.contextBefore ?? null,
contextAfter: input.contextAfter ?? null,
};
const snapshot = toHighlight(newLocal);
await encryptRecord('articleHighlights', newLocal);
await articleHighlightTable.add(newLocal);
return snapshot;
},
async setColor(id: string, color: HighlightColor): Promise<void> {
await articleHighlightTable.update(id, {
color,
});
},
async setNote(id: string, note: string | null): Promise<void> {
const diff: Partial<LocalHighlight> = {
note,
};
await encryptRecord('articleHighlights', diff as LocalHighlight);
await articleHighlightTable.update(id, diff);
},
async deleteHighlight(id: string): Promise<void> {
await articleHighlightTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -1,160 +0,0 @@
/**
* Articles Bulk-Import store (mutations only).
*
* Creates and steers `articleImportJobs` + `articleImportItems`. The
* server-side worker (apps/api/src/modules/articles/import-worker.ts)
* picks up `state='pending'` items, extracts them, drops Pickup rows
* the client-side `consume-pickup.ts` consumer translates into encrypted
* `articles` rows.
*
* Read-side queries live in `queries.ts` (a `useImportJob(id)` /
* `useImportItems(jobId)` pair will land alongside the UI in Phase 5).
*
* Plan: docs/plans/articles-bulk-import.md.
*/
import { emitDomainEvent } from '$lib/data/events';
import { articleImportJobTable, articleImportItemTable } from '../collections';
import { parseUrls, type ParsedUrls } from '../parse-urls';
import type {
ArticleImportItemState,
LocalArticleImportItem,
LocalArticleImportJob,
} from '../types';
// Re-export so call sites that already imported from `stores/imports`
// (BulkImportForm, tools.ts) keep working unchanged.
export { parseUrls, type ParsedUrls };
/**
* Hard cap on the URL count per job. The worker can chew through any
* number of items, but at very high counts the UI becomes unwieldy
* (JobDetailView is a flat list, no virtualisation yet) and the
* worst-case wall-clock duration climbs into the multi-hour range
* (50 URLs 510 min at concurrency 3, scales linearly). 200 is a
* pragmatic ceiling real reading-list dumps from Pocket exports
* average 50150 items.
*/
export const MAX_URLS_PER_JOB = 200;
export const articleImportsStore = {
/**
* Create a job with N items, all in state='pending'. Returns the
* job id so the caller can navigate to `/articles/import/[jobId]`.
*
* No URL validation here `parseUrls` is the canonical entry, and
* the UI calls it for live feedback before submit. We accept a
* pre-cleaned string array so this method stays trivially testable.
*/
async createJob(urls: readonly string[]): Promise<string> {
if (urls.length === 0) {
throw new Error('createJob: empty url list');
}
if (urls.length > MAX_URLS_PER_JOB) {
throw new Error(
`createJob: too many URLs (${urls.length}). Max ${MAX_URLS_PER_JOB} pro Job — splitte den Import in mehrere Jobs.`
);
}
const jobId = crypto.randomUUID();
const job: LocalArticleImportJob = {
id: jobId,
totalUrls: urls.length,
status: 'queued',
startedAt: null,
finishedAt: null,
savedCount: 0,
duplicateCount: 0,
errorCount: 0,
warningCount: 0,
};
const items: LocalArticleImportItem[] = urls.map((url, idx) => ({
id: crypto.randomUUID(),
jobId,
idx,
url,
state: 'pending' as ArticleImportItemState,
articleId: null,
warning: null,
error: null,
attempts: 0,
lastAttemptAt: null,
}));
// Items first so a server-worker tick that races the job-write
// won't see a job with totalUrls=N but only N-1 items reachable.
// (Conservative ordering — the worker filters jobs to running/
// queued before scanning items, but the bulkAdd is cheap.)
await articleImportItemTable.bulkAdd(items);
await articleImportJobTable.add(job);
emitDomainEvent('ArticleImportStarted', 'articles', 'articleImportJobs', jobId, {
jobId,
totalUrls: urls.length,
});
return jobId;
},
/** Pause a running job. Server-worker observes `status='paused'` and
* stops claiming new items. Already-extracting items finish their
* roundtrip; pickup/encrypt cycle for them runs normally. */
async pauseJob(jobId: string): Promise<void> {
await articleImportJobTable.update(jobId, { status: 'paused' });
},
/** Resume a paused job. */
async resumeJob(jobId: string): Promise<void> {
await articleImportJobTable.update(jobId, { status: 'running' });
},
/** Cancel a job. Server-worker flips every still-pending item to
* state='cancelled' on the next tick. */
async cancelJob(jobId: string): Promise<void> {
await articleImportJobTable.update(jobId, { status: 'cancelled' });
},
/**
* Retry the failed items of a job flip them back to 'pending' so
* the worker picks them up again. Resets attempts so the per-item
* 3-attempt budget restarts cleanly. Counter delta is left to the
* worker (it derives counters from current item states each tick).
*/
async retryFailed(jobId: string): Promise<number> {
const failed = await articleImportItemTable
.where('[jobId+state]')
.equals([jobId, 'error'])
.toArray();
for (const it of failed) {
await articleImportItemTable.update(it.id, {
state: 'pending' as ArticleImportItemState,
error: null,
attempts: 0,
});
}
// If the job was 'done' because everything was terminal, re-arm it.
if (failed.length > 0) {
const job = await articleImportJobTable.get(jobId);
if (job?.status === 'done') {
await articleImportJobTable.update(jobId, {
status: 'running',
finishedAt: null,
});
}
}
return failed.length;
},
/** Soft-delete the job + all its items. Article rows that already
* landed are NOT touched the user's reading list is independent
* from the import job's history. */
async deleteJob(jobId: string): Promise<void> {
const now = new Date().toISOString();
const items = await articleImportItemTable.where('jobId').equals(jobId).toArray();
for (const it of items) {
await articleImportItemTable.update(it.id, { deletedAt: now });
}
await articleImportJobTable.update(jobId, { deletedAt: now });
},
};

View file

@ -1,120 +0,0 @@
/**
* Tests for the pure `parseUrls` URL-list parser. The store's mutation
* methods (createJob, pauseJob, ) are integration-shaped (need Dexie
* + the scope hook) and live under the integration suite; this file
* only covers the parser, which is the deterministic part.
*
* Plan: docs/plans/articles-bulk-import.md.
*/
import { describe, it, expect } from 'vitest';
import { parseUrls } from '../parse-urls';
describe('parseUrls', () => {
it('returns empty arrays for an empty input', () => {
expect(parseUrls('')).toEqual({ valid: [], invalid: [], duplicates: [] });
expect(parseUrls(' \n\t ')).toEqual({ valid: [], invalid: [], duplicates: [] });
});
it('parses a single newline-separated list', () => {
const r = parseUrls('https://example.com/a\nhttps://example.com/b\nhttps://example.com/c');
expect(r.valid).toEqual([
'https://example.com/a',
'https://example.com/b',
'https://example.com/c',
]);
expect(r.invalid).toEqual([]);
expect(r.duplicates).toEqual([]);
});
it('accepts whitespace + comma + tabs as separators', () => {
const r = parseUrls('https://a.com https://b.com,\thttps://c.com\nhttps://d.com');
expect(r.valid).toEqual([
'https://a.com/',
'https://b.com/',
'https://c.com/',
'https://d.com/',
]);
});
it('accepts http and https, rejects everything else', () => {
const r = parseUrls(
[
'http://insecure.example',
'https://secure.example',
'ftp://files.example',
'javascript:alert(1)',
'mailto:foo@bar.com',
'file:///etc/passwd',
].join('\n')
);
expect(r.valid).toEqual(['http://insecure.example/', 'https://secure.example/']);
expect(r.invalid).toHaveLength(4);
expect(r.invalid).toContain('javascript:alert(1)');
expect(r.invalid).toContain('mailto:foo@bar.com');
});
it('rejects scheme-less domains (URL accepts them as opaque)', () => {
const r = parseUrls('example.com\ngoogle.com\nhttps://valid.com');
expect(r.valid).toEqual(['https://valid.com/']);
expect(r.invalid).toEqual(['example.com', 'google.com']);
});
it('flags duplicate URLs as duplicates, keeps the first occurrence', () => {
const r = parseUrls(
'https://example.com/a\nhttps://example.com/b\nhttps://example.com/a\nhttps://example.com/b'
);
expect(r.valid).toEqual(['https://example.com/a', 'https://example.com/b']);
expect(r.duplicates).toEqual(['https://example.com/a', 'https://example.com/b']);
});
it('canonicalises URLs (trailing slash on root, identical query order) so dupes are caught', () => {
const r = parseUrls('https://example.com\nhttps://example.com/');
expect(r.valid).toEqual(['https://example.com/']);
expect(r.duplicates).toEqual(['https://example.com/']);
});
it('preserves first-occurrence order across mixed valid/invalid/dup tokens', () => {
const r = parseUrls(
[
'https://first.com',
'not-a-url',
'https://second.com',
'https://first.com', // duplicate of first
'https://third.com',
].join('\n')
);
expect(r.valid).toEqual(['https://first.com/', 'https://second.com/', 'https://third.com/']);
expect(r.invalid).toEqual(['not-a-url']);
expect(r.duplicates).toEqual(['https://first.com/']);
});
it('handles realistic paste with title prefixes (extracts URL-shaped tokens only)', () => {
// User pasted from a chat where each line had a title before the URL
// — our parser splits on whitespace, so this leaves bare URL tokens
// + title-noise as "invalid". That's the correct behaviour for a
// strict parser; the UI surfaces both counters so the user sees it.
const r = parseUrls(
'Awesome article: https://nytimes.com/article-1\nAnother one: https://wsj.com/x'
);
expect(r.valid).toEqual(['https://nytimes.com/article-1', 'https://wsj.com/x']);
expect(r.invalid).toContain('Awesome');
expect(r.invalid).toContain('article:');
});
it('keeps query strings + fragments in canonical form', () => {
const r = parseUrls(
'https://example.com/a?x=1&y=2#section\nhttps://example.com/a?x=1&y=2#section'
);
expect(r.valid).toEqual(['https://example.com/a?x=1&y=2#section']);
expect(r.duplicates).toEqual(['https://example.com/a?x=1&y=2#section']);
});
it('handles a 50-URL input without choking', () => {
const urls = Array.from({ length: 50 }, (_, i) => `https://example.com/article-${i}`);
const r = parseUrls(urls.join('\n'));
expect(r.valid).toHaveLength(50);
expect(r.invalid).toEqual([]);
expect(r.duplicates).toEqual([]);
});
});

View file

@ -1,23 +0,0 @@
/**
* Articles Tags junction ops into the global tag system.
*
* Mirrors notes/stores/tags.svelte.ts, calendar/stores/tags.svelte.ts,
* contacts/stores/tags.svelte.ts tag names/colors live in globalTags
* (appId: 'tags'), articles just holds the junction rows.
*/
import { db } from '$lib/data/database';
import { createTagLinkOps } from '@mana/shared-stores';
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
} from '@mana/shared-stores';
export const articleTagOps = createTagLinkOps({
table: () => db.table('articleTags'),
entityIdField: 'articleId',
});

View file

@ -1,28 +0,0 @@
/**
* Cross-tab context for the articles module.
*
* ArticlesTabShell provides this to let deeply-nested section components
* (HomeSectionFrisch's "Alle ungelesenen →" button etc.) switch the
* active tab without navigating away from the current URL critical
* when the articles module is rendered inside a Workbench card where a
* `goto(...)` would kick the user out of the card entirely.
*
* Consumers: call `getArticlesTabContext()` and, if non-null, use
* `.switchTo(tab)` in place of a `goto(/articles/...)`. Falling through
* to goto when no context exists is the explicit escape hatch for when
* the component is rendered standalone (e.g. old tests).
*/
import { getContext } from 'svelte';
export type ArticlesTabId = 'list' | 'highlights' | 'favorites' | 'stats';
export interface ArticlesTabContext {
switchTo(tab: ArticlesTabId): void;
}
export const ARTICLES_TAB_CONTEXT = Symbol('articles-tab-context');
export function getArticlesTabContext(): ArticlesTabContext | null {
return getContext<ArticlesTabContext | undefined>(ARTICLES_TAB_CONTEXT) ?? null;
}

View file

@ -1,356 +0,0 @@
/**
* Articles AI Tools LLM-accessible operations for the articles module.
*
* Catalog entries live in `@mana/shared-ai/src/tools/schemas.ts` and drive
* the policy layer + server planner automatically; this file contributes
* the execute-side glue.
*
* list_articles auto Read-only listing for agent context.
* save_article propose URL Readability encrypted save.
* Legacy `save_news_article` kept as
* alias in `modules/news/tools.ts`.
* archive_article propose Flips status 'archived'.
* tag_article propose Creates (or reuses) a global tag by
* name and links it to the article.
* add_article_highlight propose Persists a highlight anchored to the
* first verbatim occurrence of `text`
* in the article's plain content. Fails
* gracefully if the snippet isn't found.
*/
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule, scopedGet } from '$lib/data/scope';
import { tagMutations, useAllTags } from '@mana/shared-stores';
import type { ModuleTool } from '$lib/data/tools/types';
import { articlesStore } from './stores/articles.svelte';
import { articleImportsStore, parseUrls } from './stores/imports.svelte';
import { highlightsStore } from './stores/highlights.svelte';
import { articleTagOps } from './stores/tags.svelte';
import { toArticle } from './queries';
import type { HighlightColor, LocalArticle, ArticleStatus } from './types';
const DEFAULT_LIST_LIMIT = 30;
const MAX_LIST_LIMIT = 100;
const MIN_HIGHLIGHT_TEXT = 10;
const MAX_HIGHLIGHT_TEXT = 500;
export const articlesTools: ModuleTool[] = [
{
name: 'list_articles',
module: 'articles',
description:
'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.',
parameters: [
{
name: 'status',
type: 'string',
description:
'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).',
required: false,
enum: ['unread', 'reading', 'finished', 'archived', 'all'],
},
{
name: 'limit',
type: 'number',
description: `Maximale Anzahl (Standard ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT})`,
required: false,
},
{
name: 'query',
type: 'string',
description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle',
required: false,
},
],
async execute(params) {
const limit = Math.min(
Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1),
MAX_LIST_LIMIT
);
const statusFilter = typeof params.status === 'string' ? params.status : '';
const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : '';
const locals = await scopedForModule<LocalArticle, string>('articles', 'articles').toArray();
const visible = locals.filter((a) => {
if (a.deletedAt) return false;
if (statusFilter === 'all') return true;
if (statusFilter === '' || !statusFilter) return a.status !== 'archived';
return a.status === statusFilter;
});
const decrypted = await decryptRecords('articles', visible);
const matches = query
? decrypted.filter(
(a) =>
a.title.toLowerCase().includes(query) ||
(a.author?.toLowerCase().includes(query) ?? false) ||
(a.siteName?.toLowerCase().includes(query) ?? false)
)
: decrypted;
const rows = matches
.sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? ''))
.slice(0, limit)
.map((a) => ({
id: a.id,
title: a.title,
siteName: a.siteName,
status: a.status,
readingTimeMinutes: a.readingTimeMinutes,
url: a.originalUrl,
savedAt: a.savedAt,
}));
return {
success: true,
message: `${rows.length} Artikel gefunden`,
data: { articles: rows, total: matches.length },
};
},
},
{
name: 'save_article',
module: 'articles',
description:
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
parameters: [
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
{
name: 'title',
type: 'string',
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
required: false,
},
{
name: 'reason',
type: 'string',
description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist',
required: false,
},
],
async execute(params) {
const url = String(params.url ?? '').trim();
if (!url) return { success: false, message: 'URL fehlt' };
const { article, duplicate } = await articlesStore.saveFromUrl(url);
return {
success: true,
message: duplicate
? `Artikel bereits gespeichert: ${article.title}`
: `Artikel gespeichert: ${article.title}`,
data: { articleId: article.id, title: article.title, duplicate },
};
},
},
{
name: 'archive_article',
module: 'articles',
description: 'Verschiebt einen Artikel ins Archiv.',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
],
async execute(params) {
const id = String(params.articleId ?? '').trim();
if (!id) return { success: false, message: 'articleId fehlt' };
const existing = await scopedGet<LocalArticle>('articles', id);
if (!existing || existing.deletedAt) {
return { success: false, message: `Kein Artikel mit id ${id}` };
}
await articlesStore.setStatus(id, 'archived' satisfies ArticleStatus);
return { success: true, message: 'Artikel archiviert', data: { articleId: id } };
},
},
{
name: 'tag_article',
module: 'articles',
description:
'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
{
name: 'tagName',
type: 'string',
description: 'Tag-Name (z.B. "KI", "lesen bald")',
required: true,
},
],
async execute(params) {
const id = String(params.articleId ?? '').trim();
const rawName = String(params.tagName ?? '').trim();
if (!id) return { success: false, message: 'articleId fehlt' };
if (!rawName) return { success: false, message: 'tagName fehlt' };
const name = rawName.slice(0, 60);
const existing = await scopedGet<LocalArticle>('articles', id);
if (!existing || existing.deletedAt) {
return { success: false, message: `Kein Artikel mit id ${id}` };
}
// useAllTags().value works even outside a Svelte reactive scope —
// it returns the current in-memory snapshot. Match by lower-case
// name so "KI" and "ki" dedupe.
const pool = useAllTags().value;
const needle = name.toLowerCase();
let tag = pool.find((t) => t.name.toLowerCase() === needle);
if (!tag) {
tag = await tagMutations.createTag({ name });
}
await articleTagOps.addTag(id, tag.id);
return {
success: true,
message: `Tag „${tag.name}" gesetzt`,
data: {
articleId: id,
tagId: tag.id,
tagName: tag.name,
created: !pool.some((t) => t.id === tag!.id),
},
};
},
},
{
name: 'add_article_highlight',
module: 'articles',
description:
'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen.',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
{
name: 'text',
type: 'string',
description: 'Wörtliche Textstelle die markiert werden soll (10500 Zeichen)',
required: true,
},
{
name: 'color',
type: 'string',
description: 'Highlight-Farbe',
required: false,
enum: ['yellow', 'green', 'blue', 'pink'],
},
{
name: 'note',
type: 'string',
description: 'Optionale Notiz zum Highlight',
required: false,
},
],
async execute(params) {
const id = String(params.articleId ?? '').trim();
const text = String(params.text ?? '').trim();
const color = (params.color as HighlightColor | undefined) ?? 'yellow';
const note = typeof params.note === 'string' ? params.note.trim() || null : null;
if (!id) return { success: false, message: 'articleId fehlt' };
if (text.length < MIN_HIGHLIGHT_TEXT) {
return { success: false, message: `Text zu kurz (min ${MIN_HIGHLIGHT_TEXT} Zeichen)` };
}
if (text.length > MAX_HIGHLIGHT_TEXT) {
return { success: false, message: `Text zu lang (max ${MAX_HIGHLIGHT_TEXT} Zeichen)` };
}
const existing = await scopedGet<LocalArticle>('articles', id);
if (!existing || existing.deletedAt) {
return { success: false, message: `Kein Artikel mit id ${id}` };
}
const [decrypted] = await decryptRecords('articles', [existing]);
if (!decrypted) return { success: false, message: 'Entschlüsselung fehlgeschlagen' };
const article = toArticle(decrypted);
// Snap to the first verbatim occurrence of the snippet in the
// Readability-extracted plain content. If the AI is hallucinating
// (or the article was re-extracted and the text shifted) we bail
// instead of persisting an orphan highlight.
const startOffset = article.content.indexOf(text);
if (startOffset < 0) {
return { success: false, message: 'Textstelle nicht im Artikel gefunden' };
}
const endOffset = startOffset + text.length;
const contextBefore =
article.content.slice(Math.max(0, startOffset - 40), startOffset) || null;
const contextAfter = article.content.slice(endOffset, endOffset + 40) || null;
const highlight = await highlightsStore.addHighlight({
articleId: id,
text,
color,
note,
startOffset,
endOffset,
contextBefore,
contextAfter,
});
return {
success: true,
message: 'Highlight gesetzt',
data: { highlightId: highlight.id, articleId: id },
};
},
},
// ─── Bulk-Import (docs/plans/articles-bulk-import.md) ───
{
name: 'import_articles_from_urls',
module: 'articles',
description:
'Erstellt einen Bulk-Import-Job für mehrere URLs. Server extrahiert sie nacheinander im Hintergrund. Auto-policy: kein Approval pro Artikel, der Job ist ein einziger Task.',
parameters: [
{
name: 'urls',
type: 'array',
description: 'Liste der Artikel-URLs (max 50)',
required: true,
},
],
execute: async (params: Record<string, unknown>) => {
const rawUrls = params.urls;
if (!Array.isArray(rawUrls) || rawUrls.length === 0) {
return { success: false, message: 'urls muss ein nicht-leeres Array sein' };
}
if (rawUrls.length > 50) {
return {
success: false,
message: 'Maximal 50 URLs pro Job. Splitte in mehrere Aufrufe.',
};
}
const blob = rawUrls.filter((u): u is string => typeof u === 'string').join('\n');
const parsed = parseUrls(blob);
if (parsed.valid.length === 0) {
return {
success: false,
message: `Keine gültigen URLs (alle ${rawUrls.length} verworfen)`,
};
}
const jobId = await articleImportsStore.createJob(parsed.valid);
return {
success: true,
message: `Bulk-Import gestartet (${parsed.valid.length} URLs${parsed.duplicates.length ? `, ${parsed.duplicates.length} Duplikate übersprungen` : ''}${parsed.invalid.length ? `, ${parsed.invalid.length} ungültig` : ''})`,
data: {
jobId,
accepted: parsed.valid.length,
duplicates: parsed.duplicates.length,
invalid: parsed.invalid.length,
},
};
},
},
];

View file

@ -1,253 +0,0 @@
/**
* Articles module Pocket-style read-it-later.
*
* Six Dexie tables:
*
* articles saved URLs + extracted Readability content
* (encrypted: title, excerpt, content, htmlContent,
* author, userNote). Reading state + dedupe key
* stay plaintext for indexing.
* articleHighlights per-selection rows with plain-text offsets.
* Encrypted: text, note, context snippets.
* articleTags pure junction into globalTags. No user-typed
* content lives here tag names/colors are in
* the global tag system (appId: 'tags').
*
* articleImportJobs Bulk-Import job header. Plaintext: counters,
* status, lease metadata. See
* docs/plans/articles-bulk-import.md.
* articleImportItems One row per URL in a bulk job. URL stays
* plaintext (server-worker reads it without
* master-key access same rationale as
* articles.originalUrl). State machine:
* pending extracting extracted
* (saved | duplicate | consent-wall | error |
* cancelled).
* articleExtractPickup Server-write inbox: the worker drops the
* extracted payload here, the client picks it
* up, runs encryptRecord + articleTable.add,
* then deletes the row. Plaintext by necessity
* (server has no master key); empty in steady
* state.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Discriminators ──────────────────────────────────────
export type ArticleStatus = 'unread' | 'reading' | 'finished' | 'archived';
export type HighlightColor = 'yellow' | 'green' | 'blue' | 'pink';
// ─── Local Records (Dexie) ───────────────────────────────
export interface LocalArticle extends BaseRecord {
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string | null;
imageUrl: string | null;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
status: ArticleStatus;
/** 0..1 scroll position so the reader can restore where the user stopped. */
readingProgress: number;
isFavorite: boolean;
savedAt: string;
readAt: string | null;
userNote: string | null;
/** Bumped when the article is re-extracted so highlight re-anchoring
* can decide whether to trust cached offsets. */
extractedVersion: number;
}
export interface LocalHighlight extends BaseRecord {
articleId: string;
text: string;
note: string | null;
color: HighlightColor;
/** Plain-text char offsets into `LocalArticle.content`. The reader maps
* these back to DOM ranges over the rendered htmlContent. */
startOffset: number;
endOffset: number;
/** Short fragments (~50 chars) around the selection used to
* re-anchor the highlight if the article gets re-extracted and
* the offsets shift. */
contextBefore: string | null;
contextAfter: string | null;
}
/**
* Junction row linking one article to one global tag. Same shape as
* noteTags / eventTags / contactTags / placeTags zero user-typed
* content, so the row stays out of the encryption registry and lives
* on the plaintext allowlist. Tag name/color/group come from globalTags
* via @mana/shared-stores helpers.
*/
export interface LocalArticleTag extends BaseRecord {
articleId: string;
tagId: string;
}
// ─── Public DTOs (rendered by views) ─────────────────────
export interface Article {
id: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string | null;
imageUrl: string | null;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
status: ArticleStatus;
readingProgress: number;
isFavorite: boolean;
savedAt: string;
readAt: string | null;
userNote: string | null;
extractedVersion: number;
createdAt: string;
updatedAt: string;
}
export interface Highlight {
id: string;
articleId: string;
text: string;
note: string | null;
color: HighlightColor;
startOffset: number;
endOffset: number;
contextBefore: string | null;
contextAfter: string | null;
createdAt: string;
updatedAt: string;
}
// ─── Bulk Import (docs/plans/articles-bulk-import.md) ─────
/**
* Job status drives the index list filter and the JobDetailView's
* action bar. `running` is the only state where the worker actively
* pulls items; `paused` lets the user stop progress without losing the
* remaining queue, `cancelled` is a hard stop with all pending items
* flipped to terminal `cancelled`.
*/
export type ArticleImportJobStatus = 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
/**
* Item state machine. Server-side transitions: pending extracting
* extracted (worker has dropped a pickup row). Client-side transitions:
* extracted saved | duplicate | consent-wall (pickup-consumer
* applied the result). Both sides may transition to error (worker after
* 3 retries, client if encryptRecord/add fails). cancelled is terminal
* and only set when the parent job is cancelled before the item ran.
*/
export type ArticleImportItemState =
| 'pending'
| 'extracting'
| 'extracted'
| 'saved'
| 'duplicate'
| 'consent-wall'
| 'error'
| 'cancelled';
export interface LocalArticleImportJob extends BaseRecord {
totalUrls: number;
status: ArticleImportJobStatus;
startedAt: string | null;
finishedAt: string | null;
/** Counters mirror the per-item terminal states. Cache for fast list
* rendering truth lives in the item rows. Worker stamps these on
* each transition. */
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
// NOTE: `leasedBy` + `leasedUntil` were defined on the original
// schema as a soft-lease handshake but the worker uses
// pg_try_advisory_xact_lock instead, so they were never written.
// Removed in Dexie v58 — see database.ts.
}
export interface LocalArticleImportItem extends BaseRecord {
jobId: string;
/** Original position in the user-provided URL list. Drives display order. */
idx: number;
/** Plaintext server worker reads it without master-key access. Same
* rationale as articles.originalUrl / newsArticles.originalUrl. */
url: string;
state: ArticleImportItemState;
/** Pointer into `articles` table once the article is persisted. */
articleId: string | null;
warning: 'probable_consent_wall' | null;
/** Plaintext technical error message ("502 Bad Gateway", "timeout"). */
error: string | null;
attempts: number;
lastAttemptAt: string | null;
}
/**
* Server client handoff. Lives only between worker-write and
* pickup-consumer-read. Empty in steady state.
*/
export interface LocalArticleExtractPickup extends BaseRecord {
itemId: string;
/** The server's ExtractedArticle JSON, plaintext. Mirrors the shape
* in articles/api.ts but lives here as a structural type so the
* database layer doesn't import the API client. */
payload: {
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string;
author: string | null;
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
warning?: 'probable_consent_wall';
};
}
// Public DTOs used by views (livequery converters strip the BaseRecord
// internals + map state to display-friendly shapes).
export interface ArticleImportJob {
id: string;
totalUrls: number;
status: ArticleImportJobStatus;
startedAt: string | null;
finishedAt: string | null;
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
createdAt: string;
updatedAt: string;
}
export interface ArticleImportItem {
id: string;
jobId: string;
idx: number;
url: string;
state: ArticleImportItemState;
articleId: string | null;
warning: 'probable_consent_wall' | null;
error: string | null;
attempts: number;
lastAttemptAt: string | null;
createdAt: string;
updatedAt: string;
}

View file

@ -1,547 +0,0 @@
<!--
DetailView — article reader + action bar.
Composes the ReaderView typography shell with an action bar (status,
favourite, archive, delete, external link) and a size/theme-picker
that sits sticky at the top.
Reading progress is persisted per scroll event (throttled in the
Reader). Re-opening the article restores the last scroll position.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { TagField } from '@mana/shared-ui';
import { useArticle, useArticleTagIds } from '../queries';
import { articlesStore } from '../stores/articles.svelte';
import { articleTagOps, useAllTags } from '../stores/tags.svelte';
import ReaderView from '../components/ReaderView.svelte';
import HighlightLayer from '../components/HighlightLayer.svelte';
import { _ } from 'svelte-i18n';
interface Props {
id: string;
}
let { id }: Props = $props();
// Re-create the live query when [id] changes. Without $derived.by the
// subscription binds to the initial id only, so navigating directly from
// one article's detail view to another's (same mount) would keep showing
// the old one.
const article$ = $derived.by(() => useArticle(id));
const article = $derived(article$.value);
// Tags: globally-available tag pool + the ids linked to *this* article.
// TagField takes the full pool + selected ids; on change we fan-out
// through articleTagOps.setTags which handles add/remove diff internally.
const allTags$ = useAllTags();
const tagIds$ = $derived.by(() => useArticleTagIds(id));
// Typography state — per-session only for now. Persisting into userSettings
// comes later; M2 just gets the UX loop working.
let fontSize = $state(1);
// Default reader theme follows the global app theme so opening an
// article from a dark-mode Mana doesn't flash a white reader. The
// swatch buttons still let the user override per-article (e.g. sepia
// for late-evening reading regardless of the app's theme).
let theme = $state<'light' | 'dark' | 'sepia'>('light');
let fontFamily = $state<'serif' | 'sans'>('serif');
onMount(() => {
if (typeof document === 'undefined') return;
if (document.documentElement.classList.contains('dark')) {
theme = 'dark';
}
});
// Refs handed off to HighlightLayer: `shell` is the positioning anchor
// for the floating menu, `readerScroller` is where text lives + where
// selection events fire.
let shell: HTMLDivElement | undefined = $state();
let readerScroller = $state<HTMLDivElement | null>(null);
async function toggleRead() {
if (!article) return;
await articlesStore.setStatus(
article.id,
article.status === 'finished' ? 'unread' : 'finished'
);
}
async function toggleFavorite() {
if (!article) return;
await articlesStore.toggleFavorite(article.id);
}
async function archive() {
if (!article) return;
await articlesStore.setStatus(article.id, 'archived');
goto('/articles');
}
async function deleteArticle() {
if (!article) return;
if (!confirm($_('articles.detail_view.confirm_delete'))) return;
await articlesStore.deleteArticle(article.id);
goto('/articles');
}
async function onProgress(progress: number) {
if (!article) return;
// First meaningful scroll flips unread → reading; reader handles the
// rest of the lifecycle (mark-finished is an explicit user action).
if (article.status === 'unread' && progress > 0.05) {
await articlesStore.setStatus(article.id, 'reading');
}
await articlesStore.setProgress(article.id, progress);
}
async function onTagsChange(ids: string[]) {
if (!article) return;
await articleTagOps.setTags(article.id, ids);
}
</script>
<svelte:head>
<title
>{$_('articles.detail_view.page_title_html', {
values: { title: article?.title ?? $_('articles.detail_view.untitled_fallback') },
})}</title
>
</svelte:head>
<div class="detail-shell detail-{theme}" bind:this={shell}>
{#if article$.loading}
<p class="placeholder">{$_('articles.detail_view.loading')}</p>
{:else if !article}
<div class="placeholder">
<p>{$_('articles.detail_view.not_found')}</p>
<button type="button" class="topbtn" onclick={() => goto('/articles')}>
{$_('articles.detail_view.back_to_list')}
</button>
</div>
{:else}
<div class="meta-bar">
<h1 class="title">{article.title}</h1>
<div class="meta-row">
{#if article.siteName}<span>{article.siteName}</span>{/if}
{#if article.author}<span>· {article.author}</span>{/if}
{#if article.readingTimeMinutes}<span
{$_('articles.detail_view.meta_reading_minutes', {
values: { n: article.readingTimeMinutes },
})}</span
>{/if}
{#if article.wordCount}<span
{$_('articles.detail_view.meta_word_count', { values: { n: article.wordCount } })}</span
>{/if}
</div>
<div class="tags-row">
<TagField
tags={allTags$.value}
selectedIds={tagIds$.value}
onChange={onTagsChange}
addLabel={$_('articles.detail_view.tag_add_label')}
placeholder={$_('articles.detail_view.tag_placeholder')}
/>
</div>
</div>
<ReaderView
html={article.htmlContent}
plainFallback={article.content}
{theme}
{fontSize}
{fontFamily}
initialProgress={article.readingProgress}
onprogress={onProgress}
onscroller={(el) => (readerScroller = el)}
/>
<HighlightLayer
articleId={article.id}
scroller={readerScroller}
container={shell ?? null}
htmlVersion={article.htmlContent}
/>
<footer class="floating-bar" aria-label={$_('articles.detail_view.toolbar_aria')}>
<div class="bar-group nav-group">
<button
type="button"
class="bar-btn"
onclick={() => goto('/articles')}
aria-label={$_('articles.detail_view.back_aria')}
data-tip={$_('articles.detail_view.back_tip')}
>
</button>
</div>
<span class="bar-divider" aria-hidden="true"></span>
<div class="bar-group type-group">
<button
type="button"
class="bar-btn"
onclick={() => (fontSize = Math.max(0.85, fontSize - 0.075))}
aria-label={$_('articles.detail_view.font_smaller_aria')}
data-tip={$_('articles.detail_view.font_smaller_tip')}
>
A
</button>
<button
type="button"
class="bar-btn"
onclick={() => (fontSize = Math.min(1.35, fontSize + 0.075))}
aria-label={$_('articles.detail_view.font_larger_aria')}
data-tip={$_('articles.detail_view.font_larger_tip')}
>
A+
</button>
<button
type="button"
class="bar-btn"
class:active={fontFamily === 'serif'}
onclick={() => (fontFamily = 'serif')}
data-tip={$_('articles.detail_view.font_serif_tip')}
>
{$_('articles.detail_view.font_serif_label')}
</button>
<button
type="button"
class="bar-btn"
class:active={fontFamily === 'sans'}
onclick={() => (fontFamily = 'sans')}
data-tip={$_('articles.detail_view.font_sans_tip')}
>
{$_('articles.detail_view.font_sans_label')}
</button>
<button
type="button"
class="bar-btn swatch swatch-light"
class:active={theme === 'light'}
onclick={() => (theme = 'light')}
aria-label={$_('articles.detail_view.theme_light_aria')}
data-tip={$_('articles.detail_view.theme_light_tip')}
></button>
<button
type="button"
class="bar-btn swatch swatch-sepia"
class:active={theme === 'sepia'}
onclick={() => (theme = 'sepia')}
aria-label={$_('articles.detail_view.theme_sepia_aria')}
data-tip={$_('articles.detail_view.theme_sepia_tip')}
></button>
<button
type="button"
class="bar-btn swatch swatch-dark"
class:active={theme === 'dark'}
onclick={() => (theme = 'dark')}
aria-label={$_('articles.detail_view.theme_dark_aria')}
data-tip={$_('articles.detail_view.theme_dark_tip')}
></button>
</div>
<span class="bar-divider" aria-hidden="true"></span>
<div class="bar-group action-group">
<button
type="button"
class="bar-btn"
class:active={article.status === 'finished'}
onclick={toggleRead}
aria-label={article.status === 'finished'
? $_('articles.detail_view.mark_unread_label')
: $_('articles.detail_view.mark_read_label')}
data-tip={article.status === 'finished'
? $_('articles.detail_view.mark_unread_label')
: $_('articles.detail_view.mark_read_label')}
>
{article.status === 'finished' ? '✓' : '○'}
</button>
<button
type="button"
class="bar-btn"
class:active={article.isFavorite}
onclick={toggleFavorite}
aria-label={article.isFavorite
? $_('articles.detail_view.fav_remove')
: $_('articles.detail_view.fav_mark')}
data-tip={article.isFavorite
? $_('articles.detail_view.fav_remove')
: $_('articles.detail_view.fav_mark')}
>
{article.isFavorite ? '★' : '☆'}
</button>
<button
type="button"
class="bar-btn"
onclick={archive}
aria-label={$_('articles.detail_view.archive_label')}
data-tip={$_('articles.detail_view.archive_label')}
>
</button>
<a
class="bar-btn"
href={article.originalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={$_('articles.detail_view.open_original')}
data-tip={$_('articles.detail_view.open_original')}
>
</a>
<button
type="button"
class="bar-btn danger"
onclick={deleteArticle}
aria-label={$_('articles.detail_view.delete_label')}
data-tip={$_('articles.detail_view.delete_label')}
>
🗑
</button>
</div>
</footer>
{/if}
</div>
<style>
.detail-shell {
/* Break out of the (app) layout's padded container so the reader */
/* fills the whole viewport edge-to-edge. The horizontal escape is */
/* the `100vw` + negative-margin-X trick that cancels the centered */
/* `max-w-7xl mx-auto px-3 sm:px-6 lg:px-8` wrapper. The vertical */
/* escape uses equally-negative margins to consume <main>'s pt-2 */
/* AND its dynamic padding-bottom (which was reserving space for the */
/* bottom chrome). The reader theme then paints behind the floating */
/* PillNav too — far better than a theme-background island floating */
/* in a page-background sea. */
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
margin-top: calc(-1 * (0.5rem + 0.5rem)); /* <main pt-2> + inner py-2 */
margin-bottom: calc(-1 * (var(--bottom-chrome-height, 0px) + 8px + 0.5rem));
min-height: 100dvh;
display: flex;
flex-direction: column;
/* Positioning anchor for the floating HighlightMenu: its `top`/`left` */
/* coordinates are computed relative to this box. */
position: relative;
}
.detail-light {
background: #ffffff;
color: #1e293b;
}
.detail-sepia {
background: #f4ecd8;
color: #433422;
}
.detail-dark {
background: #0f172a;
color: #e2e8f0;
}
.meta-bar {
max-width: 700px;
margin: 4rem auto 0;
padding: 0 clamp(1rem, 5vw, 3rem);
width: 100%;
}
.title {
font-size: 1.8rem;
line-height: 1.25;
margin: 0 0 0.4rem 0;
}
.meta-row {
font-size: 0.85rem;
opacity: 0.75;
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.tags-row {
margin-top: 0.75rem;
}
.placeholder {
text-align: center;
margin: 3rem auto;
opacity: 0.7;
}
/* ─── Floating unified toolbar ────────────────────────────
*
* One bar at the bottom replaces what used to be a top bar (back +
* typography) and a bottom bar (article actions). Three groups
* divided by vertical rules: nav | typography | actions.
*
* `position: fixed` + center-X transform produces the floating-pill
* look; it stays put while the reader scrolls. `bottom: 1rem` leaves
* enough gap from the viewport edge to feel like a pill, not a docked
* toolbar. On narrow screens the groups wrap onto multiple rows via
* flex-wrap — still readable, just taller.
*/
.floating-bar {
position: fixed;
/* Clear Mana's own bottom-stack (PillNavigation + QuickInputBar + */
/* TagStrip). The layout publishes its total height as */
/* `--bottom-chrome-height` on <main>, which cascades down into our */
/* detail-shell even though we're position: fixed (inheritance is */
/* DOM-based, not layout-based). Fallback 0 keeps the bar sensible */
/* if this page ever renders outside the app shell (e.g. a test). */
bottom: calc(var(--bottom-chrome-height, 0px) + 1rem);
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.65rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 3%, Canvas);
border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
box-shadow:
0 8px 24px -8px color-mix(in srgb, currentColor 35%, transparent),
0 2px 6px -2px color-mix(in srgb, currentColor 20%, transparent);
backdrop-filter: blur(10px);
max-width: calc(100vw - 2rem);
flex-wrap: wrap;
justify-content: center;
}
/* Reader-theme surface overrides — Canvas above is the browser-neutral
* default; each theme pins a proper opaque backdrop so text on/around
* the bar stays legible. */
.detail-light .floating-bar {
background: color-mix(in srgb, #ffffff 92%, transparent);
}
.detail-sepia .floating-bar {
background: color-mix(in srgb, #f4ecd8 92%, transparent);
}
.detail-dark .floating-bar {
background: color-mix(in srgb, #0f172a 88%, transparent);
}
.bar-group {
display: flex;
gap: 0.25rem;
align-items: center;
}
.bar-divider {
width: 1px;
height: 1.3rem;
background: color-mix(in srgb, currentColor 20%, transparent);
margin: 0 0.15rem;
}
.bar-btn {
font: inherit;
font-size: 0.82rem;
min-width: 2rem;
height: 2rem;
padding: 0 0.55rem;
background: transparent;
color: inherit;
border: 1px solid transparent;
border-radius: 999px;
cursor: pointer;
text-decoration: none;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
.bar-btn:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
/* Custom tooltip: small label bubble above the button on hover. The
* native `title` tooltip has a ~1s delay and inherits the OS style,
* which feels sluggish for a reader-toolbar where the user is
* scanning icons. `data-tip` is set declaratively on each button so
* swapping copy per state (read / unread, favorite / unmark) stays
* a Svelte attribute reactivity concern, not a CSS one. */
.bar-btn[data-tip]::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 0.4rem);
left: 50%;
transform: translateX(-50%);
padding: 0.3rem 0.55rem;
border-radius: 0.4rem;
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
color: #f1f5f9;
background: #0f172a;
box-shadow: 0 4px 10px -2px rgba(0, 0, 0, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
/* Keep the tooltip above the bar's own backdrop. */
z-index: 1;
}
.bar-btn[data-tip]:hover::after,
.bar-btn[data-tip]:focus-visible::after {
opacity: 1;
transition-delay: 120ms;
}
/* Light-mode readers get an inverted bubble so the tooltip doesn't
* look like just a darker blob — pops off the light page. */
.detail-light .bar-btn[data-tip]::after {
color: #f1f5f9;
background: #1e293b;
}
.detail-sepia .bar-btn[data-tip]::after {
color: #f4ecd8;
background: #433422;
}
.detail-dark .bar-btn[data-tip]::after {
color: #0f172a;
background: #e2e8f0;
}
.bar-btn.active {
background: color-mix(in srgb, #f97316 18%, transparent);
color: #ea580c;
}
.detail-dark .bar-btn.active {
color: #fdba74;
}
.bar-btn.danger:hover {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.swatch {
width: 1.5rem;
height: 1.5rem;
min-width: 1.5rem;
padding: 0;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
}
.swatch:hover {
border-color: color-mix(in srgb, currentColor 55%, transparent);
}
.swatch.active {
background: currentColor;
outline: 2px solid color-mix(in srgb, #f97316 80%, transparent);
outline-offset: 1px;
}
.swatch-light {
background: #ffffff;
}
.swatch-sepia {
background: #f4ecd8;
}
.swatch-dark {
background: #0f172a;
}
/* Override `.swatch.active { background: currentColor }` so the color
* chip stays the theme-preview color even when selected. */
.swatch-light.active {
background: #ffffff;
}
.swatch-sepia.active {
background: #f4ecd8;
}
.swatch-dark.active {
background: #0f172a;
}
</style>

View file

@ -1,257 +0,0 @@
<!--
HighlightsView — Sammelansicht über alle Highlights.
Gruppiert die chronologisch sortierten Highlights pro Artikel
(gleiche Reihenfolge, die useAllHighlights liefert) und rendert sie
mit Farb-Akzent + optionaler Notiz. "Export" kopiert die Sammlung
als Markdown in die Zwischenablage; "Download" speichert sie als
.md-Datei.
Klick auf ein Highlight oder auf den Artikel-Header springt zurück
in den Reader.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllHighlights, type HighlightWithArticle } from '../queries';
import { renderHighlightsMarkdown } from '../lib/markdown-export';
const rows$ = useAllHighlights();
const rows = $derived(rows$.value);
interface Group {
articleId: string;
article: HighlightWithArticle['article'];
highlights: HighlightWithArticle['highlight'][];
}
const groups = $derived.by<Group[]>(() => {
const out: Group[] = [];
let current: Group | null = null;
for (const row of rows) {
if (!current || current.articleId !== row.article.id) {
current = {
articleId: row.article.id,
article: row.article,
highlights: [row.highlight],
};
out.push(current);
} else {
current.highlights.push(row.highlight);
}
}
return out;
});
let exportLabel = $state('Als Markdown kopieren');
async function copyMarkdown() {
const md = renderHighlightsMarkdown(rows);
try {
await navigator.clipboard.writeText(md);
exportLabel = 'Kopiert ✓';
setTimeout(() => (exportLabel = 'Als Markdown kopieren'), 1500);
} catch {
exportLabel = 'Fehler — bitte manuell';
}
}
function downloadMarkdown() {
const md = renderHighlightsMarkdown(rows);
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mana-highlights-${new Date().toISOString().slice(0, 10)}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<svelte:head>
<title>Highlights — Artikel — Mana</title>
</svelte:head>
<div class="highlights-view">
{#if rows.length > 0}
<div class="actions">
<button type="button" class="ghost" onclick={copyMarkdown}>{exportLabel}</button>
<button type="button" class="ghost" onclick={downloadMarkdown}>Als .md herunterladen</button>
</div>
{/if}
{#if rows$.loading}
<p class="muted center">Lädt…</p>
{:else if groups.length === 0}
<div class="empty">
<p class="empty-headline">Noch keine Highlights.</p>
<p class="empty-sub">
Markier eine Textstelle in einem gespeicherten Artikel — sie erscheint hier automatisch.
</p>
</div>
{:else}
<div class="groups">
{#each groups as group (group.articleId)}
<section class="group">
<header class="group-header">
<button
type="button"
class="article-link"
onclick={() => goto(`/articles/${group.articleId}`)}
title="Artikel öffnen"
>
<span class="title">{group.article.title}</span>
{#if group.article.siteName}
<span class="site">{group.article.siteName}</span>
{/if}
</button>
</header>
<ul class="hl-list">
{#each group.highlights as h (h.id)}
<li class="hl hl-{h.color}">
<button
type="button"
class="hl-text"
onclick={() => goto(`/articles/${group.articleId}`)}
title="Im Artikel öffnen"
>
{h.text}"
</button>
{#if h.note}
<p class="hl-note">{h.note}</p>
{/if}
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
</div>
<style>
.highlights-view {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.ghost {
font: inherit;
padding: 0.45rem 0.85rem;
border-radius: 0.5rem;
cursor: pointer;
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
}
.ghost:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.empty {
margin-top: 2.5rem;
padding: 2rem;
text-align: center;
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.15));
border-radius: 0.75rem;
}
.empty-headline {
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.empty-sub {
margin: 0 0 1.25rem 0;
color: var(--color-text-muted, #64748b);
}
.groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.group {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.article-link {
font: inherit;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
text-align: left;
padding: 0.2rem 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.article-link:hover .title {
color: #f97316;
}
.title {
font-weight: 600;
font-size: 1.05rem;
}
.site {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
}
.hl-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hl {
padding: 0.5rem 0.75rem;
border-radius: 0.45rem;
border-left: 3px solid transparent;
}
.hl-yellow {
background: color-mix(in srgb, #fde68a 60%, transparent);
border-left-color: #f59e0b;
}
.hl-green {
background: color-mix(in srgb, #bbf7d0 60%, transparent);
border-left-color: #10b981;
}
.hl-blue {
background: color-mix(in srgb, #bfdbfe 60%, transparent);
border-left-color: #3b82f6;
}
.hl-pink {
background: color-mix(in srgb, #fbcfe8 60%, transparent);
border-left-color: #ec4899;
}
.hl-text {
font: inherit;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
padding: 0;
line-height: 1.5;
}
.hl-note {
margin: 0.4rem 0 0 0;
font-size: 0.88rem;
color: var(--color-text-muted, #334155);
font-style: italic;
}
</style>

View file

@ -1,95 +0,0 @@
<!--
Stats-Tab: Zahlen und Quellen-Aufstellung plus Link ins Archiv.
Verwendet die gleichen Section-Components die früher auf der
Home-Overview gruppiert waren.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllArticles, useStats } from '../queries';
import HomeSectionStats from '../components/HomeSectionStats.svelte';
import HomeSectionSources from '../components/HomeSectionSources.svelte';
import { getArticlesTabContext } from '../tab-context';
const articles$ = useAllArticles();
const stats$ = useStats();
const articles = $derived(articles$.value);
const stats = $derived(stats$.value);
const tabCtx = getArticlesTabContext();
function openArchive() {
if (tabCtx) {
tabCtx.switchTo('list');
} else {
goto('/articles/list?filter=archived');
}
}
</script>
<div class="stats-view">
{#if articles$.loading}
<p class="muted">Lädt…</p>
{:else if articles.length === 0}
<p class="muted">Noch keine Artikel gespeichert — Statistiken erscheinen sobald du anfängst.</p>
{:else}
<HomeSectionStats
savedThisWeek={stats.savedThisWeek}
finishedThisWeek={stats.finishedThisWeek}
{articles}
/>
<section class="highlights-line">
<strong>{stats.totalHighlights}</strong>
<span>markierte Textstellen insgesamt</span>
</section>
<HomeSectionSources sources={stats.topSites} />
{#if stats.archived > 0}
<button type="button" class="archive-link" onclick={openArchive}>
{stats.archived} archivierte Artikel →
</button>
{/if}
{/if}
</div>
<style>
.stats-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.highlights-line {
display: flex;
gap: 0.5rem;
align-items: baseline;
padding: 0.85rem 1rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
}
.highlights-line strong {
font-size: 1.25rem;
font-weight: 600;
}
.highlights-line span {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.archive-link {
align-self: flex-start;
font: inherit;
font-size: 0.9rem;
background: transparent;
border: none;
color: var(--color-text-muted, #64748b);
cursor: pointer;
padding: 0.4rem 0.1rem;
}
.archive-link:hover {
color: inherit;
text-decoration: underline;
}
</style>

View file

@ -1,78 +0,0 @@
<script lang="ts">
/**
* ArticlesUnreadWidget — dashboard tile for the articles module.
*
* Shows up to three unread articles + a one-line stats strip (saved
* this week / total unread). Mirrors the NewsUnreadWidget pattern:
* self-contained liveQuery, no props, renders its own tile chrome.
*/
import { useAllArticles, useStats } from '../queries';
const articles$ = useAllArticles();
const stats$ = useStats();
const articles = $derived(articles$.value);
const stats = $derived(stats$.value);
const topUnread = $derived(
articles.filter((a) => a.status === 'unread' || a.status === 'reading').slice(0, 3)
);
</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>
Artikel
</h3>
<a href="/articles" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
</div>
{#if articles$.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if articles.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Noch keine Artikel gespeichert.</p>
<a
href="/articles/add"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Erste URL speichern
</a>
</div>
{:else if topUnread.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Alles gelesen — stark.</p>
<a href="/articles" class="mt-3 inline-block text-xs text-primary hover:underline">
Leseliste öffnen
</a>
</div>
{:else}
<div class="space-y-1.5">
{#each topUnread as article (article.id)}
<a
href="/articles/{article.id}"
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
<div class="mt-0.5 flex gap-1.5 text-xs text-muted-foreground">
{#if article.siteName}
<span class="font-medium">{article.siteName}</span>
{/if}
{#if article.readingTimeMinutes}
<span>·</span>
<span>{article.readingTimeMinutes} min</span>
{/if}
</div>
</a>
{/each}
</div>
<div class="mt-3 border-t border-border/50 pt-2 text-xs text-muted-foreground">
{stats.unread} ungelesen · {stats.savedThisWeek} diese Woche gespeichert
</div>
{/if}
</div>

View file

@ -10,10 +10,8 @@
workbench card and full page keeps results.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import type { ViewProps } from '$lib/app-registry';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
const {}: ViewProps = $props();
@ -23,8 +21,6 @@
let query = $state('');
let siteUrl = $state('');
let searchQuery = $state('');
let savingUrl = $state<string | null>(null);
let saveError = $state<string | null>(null);
let copyLabel = $state('Kopieren');
let feedsOpen = $state(true);
@ -54,18 +50,6 @@
feedsOpen = false;
}
async function onSave(articleUrl: string) {
savingUrl = articleUrl;
saveError = null;
try {
const { article } = await articlesStore.saveFromUrl(articleUrl);
goto(`/articles/${article.id}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
savingUrl = null;
}
}
async function onCopy() {
try {
await navigator.clipboard.writeText(store.buildAiContext());
@ -174,7 +158,6 @@
<span>Treffer ({store.session.results.length})</span>
<button type="button" class="ctx" onclick={onCopy}>KI-Kontext: {copyLabel}</button>
</div>
{#if saveError}<div class="error">{saveError}</div>{/if}
<ul class="results">
{#each store.session.results as a (a.url)}
<li>
@ -183,14 +166,6 @@
<span>{formatDate(a.publishedAt)}</span>
<span>·</span>
<span>Score {a.score}</span>
<button
type="button"
class="save"
disabled={savingUrl === a.url}
onclick={() => onSave(a.url)}
>
{savingUrl === a.url ? '…' : 'Speichern'}
</button>
</div>
</li>
{/each}
@ -376,19 +351,6 @@
font-size: 0.7rem;
color: hsl(var(--color-muted-foreground));
}
.save {
margin-left: auto;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
padding: 0.1rem 0.4rem;
font-size: 0.7rem;
cursor: pointer;
color: hsl(var(--color-foreground));
}
.save:disabled {
opacity: 0.5;
}
.error {
background: hsl(var(--color-destructive) / 0.1);
border: 1px solid hsl(var(--color-destructive) / 0.4);

View file

@ -1,19 +1,15 @@
<!--
ExportMenu — drop-down next to the Generate/Checkpoint buttons in the
DetailView. Four M10 actions:
DetailView. Three actions:
- Markdown kopieren
- .md herunterladen
- Drucken / PDF (uses the browser's native print dialog)
- Als Artikel speichern → hand-off to the articles module
The heavy lifting lives in utils/export.ts + the stores; this
component is just the menu surface + confirmation toasts.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { draftsStore } from '../stores/drafts.svelte';
import {
draftToMarkdown,
draftToPlainText,
@ -34,7 +30,6 @@
let open = $state(false);
let feedback = $state<string | null>(null);
let feedbackTimer: ReturnType<typeof setTimeout> | null = null;
let busy = $state(false);
function flash(msg: string) {
feedback = msg;
@ -72,39 +67,6 @@
open = false;
if (typeof window !== 'undefined') window.print();
}
async function saveAsArticle() {
if (busy) return;
busy = true;
try {
const content = currentVersion?.content ?? '';
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
// `internal://` scheme avoids colliding with real URLs in the
// articles module's dedupe path while still giving the row a
// unique originalUrl — the format `internal://writing/<id>`
// doubles as a back-reference to the source draft.
const article = await articlesStore.saveFromExtracted({
originalUrl: `internal://writing/${draft.id}`,
title: draft.title || draft.briefing.topic || $_('writing.detail_view.untitled_fallback'),
excerpt: content.slice(0, 240).trim() || null,
content,
htmlContent: content, // no HTML body yet — the articles reader handles plain text fine
author: null,
siteName: $_('writing.export_menu.site_name'),
wordCount,
readingTimeMinutes: Math.max(1, Math.round(wordCount / 200)),
});
await draftsStore.recordPublish(draft.id, 'articles', article.id);
flash($_('writing.export_menu.toast_saved_article'));
open = false;
// Give the toast a moment before navigating away.
setTimeout(() => goto(`/articles/${article.id}`), 600);
} catch (err) {
flash(err instanceof Error ? err.message : String(err));
} finally {
busy = false;
}
}
</script>
<div class="menu">
@ -120,22 +82,18 @@
</button>
{#if open}
<div class="dropdown" role="menu">
<button type="button" role="menuitem" onclick={copyMd} disabled={busy}>
<button type="button" role="menuitem" onclick={copyMd}>
{$_('writing.export_menu.copy_md')}
</button>
<button type="button" role="menuitem" onclick={copyPlain} disabled={busy}>
<button type="button" role="menuitem" onclick={copyPlain}>
{$_('writing.export_menu.copy_text')}
</button>
<button type="button" role="menuitem" onclick={downloadMd} disabled={busy}>
<button type="button" role="menuitem" onclick={downloadMd}>
{$_('writing.export_menu.download_md')}
</button>
<button type="button" role="menuitem" onclick={printDraft} disabled={busy}>
<button type="button" role="menuitem" onclick={printDraft}>
{$_('writing.export_menu.print_pdf')}
</button>
<hr />
<button type="button" role="menuitem" onclick={saveAsArticle} disabled={busy}>
{$_('writing.export_menu.save_as_article')}
</button>
</div>
{/if}
{#if feedback}
@ -202,11 +160,6 @@
opacity: 0.5;
cursor: not-allowed;
}
.dropdown hr {
margin: 0.2rem 0.1rem;
border: none;
border-top: 1px solid hsl(var(--color-border));
}
.toast {
font-size: 0.8rem;
color: hsl(var(--color-primary));

View file

@ -20,7 +20,6 @@
} = $props();
const KIND_ICON: Record<DraftReference['kind'], string> = {
article: '📄',
note: '📝',
library: '📚',
kontext: '🗂',
@ -30,7 +29,6 @@
};
const KIND_LABEL: Record<DraftReference['kind'], string> = {
article: 'Artikel',
note: 'Notiz',
library: 'Library',
kontext: 'Kontext',

View file

@ -2,9 +2,8 @@
ReferencePicker — inline "Quellen" section inside the briefing form.
Shows the currently-attached references as ReferenceChip pills (with
live-resolved display labels) and a "+ Quelle" dropdown for adding
new ones. Seven kinds:
new ones. Six kinds:
- article → searchable list of saved articles
- note → searchable list of notes
- library → searchable list of library entries
- url → freeform URL input + optional context note
@ -17,7 +16,6 @@
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useAllArticles } from '$lib/modules/articles/queries';
import { useAllNotes, useSpaceContextNote } from '$lib/modules/notes/queries';
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
import { useAllMeImages } from '$lib/modules/profile/queries';
@ -26,7 +24,6 @@
import type { DraftReference, DraftReferenceKind } from '../types';
const SUPPORTED_KINDS: DraftReferenceKind[] = [
'article',
'note',
'library',
'url',
@ -47,7 +44,6 @@
onchange: (next: DraftReference[]) => void;
} = $props();
const articles$ = useAllArticles();
const notes$ = useAllNotes();
const library$ = useAllLibraryEntries();
const kontext$ = useSpaceContextNote();
@ -55,7 +51,6 @@
const goals$ = useAllGoals();
// Lookup maps so chips can resolve their display label from targetId.
const articlesById = $derived(new Map((articles$.value ?? []).map((a) => [a.id, a])));
const notesById = $derived(new Map((notes$.value ?? []).map((n) => [n.id, n])));
const libraryById = $derived(new Map((library$.value ?? []).map((e) => [e.id, e])));
const meImagesById = $derived(new Map((meImages$.value ?? []).map((m) => [m.id, m])));
@ -66,10 +61,6 @@
if (ref.kind === 'url') return ref.url ?? $_('writing.reference_picker.label_url_default');
if (ref.kind === 'kontext') return $_('writing.reference_picker.label_kontext');
if (!ref.targetId) return $_('writing.reference_picker.label_unknown');
if (ref.kind === 'article') {
const a = articlesById.get(ref.targetId);
return a ? a.title : $_('writing.reference_picker.label_article_missing');
}
if (ref.kind === 'note') {
const n = notesById.get(ref.targetId);
return n
@ -96,15 +87,7 @@
return ref.targetId;
}
type PickerMode =
| 'closed'
| 'article'
| 'note'
| 'library'
| 'url'
| 'kontext'
| 'goal'
| 'me-image';
type PickerMode = 'closed' | 'note' | 'library' | 'url' | 'kontext' | 'goal' | 'me-image';
let mode = $state<PickerMode>('closed');
let searchQuery = $state('');
let urlInput = $state('');
@ -141,17 +124,6 @@
urlNote = '';
}
const filteredArticles = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
const all = articles$.value ?? [];
if (!q) return all.slice(0, 20);
return all
.filter(
(a) => a.title.toLowerCase().includes(q) || (a.siteName?.toLowerCase().includes(q) ?? false)
)
.slice(0, 20);
});
const filteredNotes = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
const all = notes$.value ?? [];
@ -253,7 +225,7 @@
</p>
{/if}
{#if mode === 'article' || mode === 'note' || mode === 'library' || mode === 'goal' || mode === 'me-image'}
{#if mode === 'note' || mode === 'library' || mode === 'goal' || mode === 'me-image'}
<div class="search">
<!-- svelte-ignore a11y_autofocus -->
<input
@ -263,24 +235,7 @@
autofocus
/>
<div class="results">
{#if mode === 'article'}
{#if filteredArticles.length === 0}
<p class="muted small">{$_('writing.reference_picker.no_results')}</p>
{:else}
{#each filteredArticles as a (a.id)}
<button
type="button"
class="result"
onclick={() => addRef({ kind: 'article', targetId: a.id, note: null })}
>
<strong>{a.title}</strong>
{#if a.siteName}
<span class="meta">{a.siteName}</span>
{/if}
</button>
{/each}
{/if}
{:else if mode === 'note'}
{#if mode === 'note'}
{#if filteredNotes.length === 0}
<p class="muted small">{$_('writing.reference_picker.no_results')}</p>
{:else}

View file

@ -11,7 +11,6 @@
* - generate_draft_content
* - refine_draft_selection
* - set_draft_status
* - save_draft_as_article
*
* All writes delegate to the existing stores so the encryption + events
* pipeline runs once, no matter whether the call came from the UI,
@ -23,7 +22,6 @@ import { deriveUpdatedAt } from '$lib/data/sync';
import { draftsStore } from './stores/drafts.svelte';
import { generationsStore } from './stores/generations.svelte';
import { draftTable, draftVersionTable } from './collections';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
import { toDraft, toDraftVersion } from './queries';
import { STYLE_PRESETS } from './presets/styles';
@ -465,54 +463,4 @@ export const writingTools: ModuleTool[] = [
};
},
},
{
name: 'save_draft_as_article',
module: 'writing',
description:
'Veroeffentlicht die aktuelle Version als Read-Later-Artikel im articles-Modul und traegt das Ziel in publishedTo ein.',
parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }],
async execute(params) {
const draftId = String(params.draftId ?? '');
try {
const draftLocal = await draftTable.get(draftId);
if (!draftLocal || draftLocal.deletedAt) {
return { success: false, message: `Draft ${draftId} nicht gefunden` };
}
const [draftDec] = await decryptRecords('writingDrafts', [draftLocal]);
if (!draftDec) return { success: false, message: 'Entschlüsselung fehlgeschlagen' };
const draft = toDraft(draftDec);
let content = '';
if (draft.currentVersionId) {
const vLocal = await draftVersionTable.get(draft.currentVersionId);
if (vLocal && !vLocal.deletedAt) {
const [vDec] = await decryptRecords('writingDraftVersions', [vLocal]);
if (vDec) content = vDec.content ?? '';
}
}
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
const article = await articlesStore.saveFromExtracted({
originalUrl: `internal://writing/${draft.id}`,
title: draft.title || draft.briefing.topic || 'Unbenannt',
excerpt: content.slice(0, 240).trim() || null,
content,
htmlContent: content,
author: null,
siteName: 'Writing',
wordCount,
readingTimeMinutes: Math.max(1, Math.round(wordCount / 200)),
});
await draftsStore.recordPublish(draft.id, 'articles', article.id);
return {
success: true,
data: { draftId: draft.id, articleId: article.id },
message: `Als Artikel gespeichert (id=${article.id})`,
};
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : String(err) };
}
},
},
];

View file

@ -50,16 +50,9 @@ export type GenerationProvider = 'mana-ai' | 'mana-llm' | 'local-llm';
export type StyleSource = 'preset' | 'custom-description' | 'sample-trained' | 'self-trained';
export type DraftReferenceKind =
| 'article'
| 'note'
| 'library'
| 'kontext'
| 'goal'
| 'url'
| 'me-image';
export type DraftReferenceKind = 'note' | 'library' | 'kontext' | 'goal' | 'url' | 'me-image';
export type DraftPublishModule = 'website' | 'articles' | 'social-relay' | 'mail' | 'presi';
export type DraftPublishModule = 'website' | 'social-relay' | 'mail' | 'presi';
// ─── Sub-objects ─────────────────────────────────────────

View file

@ -103,13 +103,6 @@ describe('buildDraftPrompt', () => {
it('renders resolved references as a "Quellen" block + flags it in system', () => {
const refs: ResolvedReference[] = [
{
kind: 'article',
sourceLabel: 'Artikel: NYT — Headline',
title: 'Headline',
content: 'Body of the article.',
note: 'wichtig fürs Argument',
},
{
kind: 'note',
sourceLabel: 'Notiz: Mein Gedanke',

View file

@ -26,9 +26,6 @@ vi.mock('$lib/data/database', () => ({
},
}));
vi.mock('$lib/modules/articles/queries', () => ({
toArticle: vi.fn((local) => ({ ...local })),
}));
vi.mock('$lib/modules/notes/queries', () => ({
toNote: vi.fn((local) => ({ ...local })),
}));
@ -62,92 +59,6 @@ beforeEach(() => {
// ── Per-kind resolver tests ──────────────────────────────────────────
describe('resolveReference - article', () => {
it('returns sourceLabel + truncated content from a valid article', async () => {
mockScopedGet.mockResolvedValue({
id: 'a1',
title: 'Headline',
content: 'Body of the article.',
siteName: 'NYT',
});
mockDecryptRecords.mockResolvedValue([
{ id: 'a1', title: 'Headline', content: 'Body of the article.', siteName: 'NYT' },
]);
const ref: DraftReference = { kind: 'article', targetId: 'a1', note: null };
const result = await resolveReference(ref);
expect(result).not.toBeNull();
expect(result?.kind).toBe('article');
expect(result?.sourceLabel).toBe('Artikel: NYT — Headline');
expect(result?.content).toBe('Body of the article.');
});
it('returns null when the article is deleted', async () => {
mockScopedGet.mockResolvedValue({ id: 'a1', deletedAt: '2026-01-01T00:00:00Z' });
const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null });
expect(result).toBeNull();
});
it('returns null when targetId is missing', async () => {
const result = await resolveReference({ kind: 'article', note: null });
expect(result).toBeNull();
});
it('returns null when scopedGet returns undefined', async () => {
mockScopedGet.mockResolvedValue(undefined);
const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null });
expect(result).toBeNull();
});
it('truncates content over the per-ref char cap', async () => {
const longBody = 'x'.repeat(2000);
mockScopedGet.mockResolvedValue({ id: 'a1', title: 'Long', content: longBody });
mockDecryptRecords.mockResolvedValue([
{ id: 'a1', title: 'Long', content: longBody, siteName: null },
]);
const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null });
expect(result?.content.length).toBeLessThan(2000);
expect(result?.content).toContain('[… gekürzt …]');
});
it('falls back to excerpt when content is empty', async () => {
mockScopedGet.mockResolvedValue({
id: 'a1',
title: 'X',
content: '',
excerpt: 'Just a teaser.',
});
mockDecryptRecords.mockResolvedValue([
{ id: 'a1', title: 'X', content: '', excerpt: 'Just a teaser.', siteName: null },
]);
const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null });
expect(result?.content).toBe('Just a teaser.');
});
it('omits the siteName prefix when missing', async () => {
mockScopedGet.mockResolvedValue({ id: 'a1', title: 'X', content: 'body' });
mockDecryptRecords.mockResolvedValue([
{ id: 'a1', title: 'X', content: 'body', siteName: null },
]);
const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null });
expect(result?.sourceLabel).toBe('Artikel: X');
});
it('preserves the user note', async () => {
mockScopedGet.mockResolvedValue({ id: 'a1', title: 'X', content: 'body' });
mockDecryptRecords.mockResolvedValue([
{ id: 'a1', title: 'X', content: 'body', siteName: null },
]);
const result = await resolveReference({
kind: 'article',
targetId: 'a1',
note: 'wichtig fürs Argument',
});
expect(result?.note).toBe('wichtig fürs Argument');
});
});
describe('resolveReference - note', () => {
it('returns title + content for a valid note', async () => {
mockScopedGet.mockResolvedValue({ id: 'n1', title: 'My Note', content: 'note body' });
@ -382,8 +293,8 @@ describe('resolveReferences', () => {
mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows);
const refs: DraftReference[] = [
{ kind: 'article', targetId: 'a1', note: null },
{ kind: 'article', targetId: 'missing', note: null },
{ kind: 'note', targetId: 'a1', note: null },
{ kind: 'note', targetId: 'missing', note: null },
];
const out = await resolveReferences(refs);
expect(out).toHaveLength(1);
@ -395,9 +306,9 @@ describe('resolveReferences', () => {
mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows);
const refs: DraftReference[] = [
{ kind: 'article', targetId: 'a1', note: null },
{ kind: 'article', targetId: 'a2', note: null },
{ kind: 'article', targetId: 'a3', note: null },
{ kind: 'note', targetId: 'a1', note: null },
{ kind: 'note', targetId: 'a2', note: null },
{ kind: 'note', targetId: 'a3', note: null },
];
const out = await resolveReferences(refs);
expect(out).toHaveLength(3);
@ -411,7 +322,7 @@ describe('resolveReferences', () => {
mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows);
const refs: DraftReference[] = Array.from({ length: 8 }, (_, i) => ({
kind: 'article' as const,
kind: 'note' as const,
targetId: `a${i}`,
note: null,
}));
@ -435,7 +346,7 @@ describe('resolveReferences', () => {
mockScopedGet.mockResolvedValue({ id: 'a', title: 'huge', content: huge });
mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows);
const out = await resolveReferences([{ kind: 'article', targetId: 'a1', note: null }]);
const out = await resolveReferences([{ kind: 'note', targetId: 'a1', note: null }]);
expect(out).toHaveLength(1);
});
});

View file

@ -18,10 +18,8 @@
import { scopedGet, scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import { toArticle } from '$lib/modules/articles/queries';
import { toNote } from '$lib/modules/notes/queries';
import { toLibraryEntry } from '$lib/modules/library/queries';
import type { LocalArticle } from '$lib/modules/articles/types';
import type { LocalNote } from '$lib/modules/notes/types';
import type { LocalLibraryEntry } from '$lib/modules/library/types';
import type { LocalMeImage } from '$lib/modules/profile/types';
@ -51,24 +49,6 @@ function truncate(text: string, max = MAX_CHARS_PER_REF): string {
return trimmed.slice(0, max) + TRUNCATION_MARKER;
}
async function resolveArticle(
id: string
): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
const local = await scopedGet<LocalArticle>('articles', id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('articles', [local]);
if (!decrypted) return null;
const article = toArticle(decrypted);
const siteName = article.siteName ? `${article.siteName}` : '';
return {
sourceLabel: `Artikel: ${siteName}${article.title}`,
title: article.title,
// `||` (not `??`) so empty-string content falls through to excerpt;
// articles with extraction failures often have content === ''.
content: truncate(article.content || article.excerpt || ''),
};
}
async function resolveNote(id: string): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
const local = await scopedGet<LocalNote>('notes', id);
if (!local || local.deletedAt) return null;
@ -194,11 +174,6 @@ async function resolveMeImage(
export async function resolveReference(ref: DraftReference): Promise<ResolvedReference | null> {
switch (ref.kind) {
case 'article': {
if (!ref.targetId) return null;
const base = await resolveArticle(ref.targetId);
return base ? { ...base, kind: 'article', note: ref.note ?? null } : null;
}
case 'note': {
if (!ref.targetId) return null;
const base = await resolveNote(ref.targetId);

View file

@ -393,12 +393,7 @@
<span class="published-label">{$_('writing.detail_view.published_label')}</span>
{#each draft.publishedTo as target (`${target.module}:${target.targetId}`)}
<span class="published-chip" title={formatDateTime(new Date(target.publishedAt))}>
{#if target.module === 'articles'}
{$_('writing.detail_view.published_articles')}
<a href={`/articles/${target.targetId}`}
>{$_('writing.detail_view.published_articles_link')}</a
>
{:else if target.module === 'website'}
{#if target.module === 'website'}
{$_('writing.detail_view.published_website')}
{:else if target.module === 'presi'}
{$_('writing.detail_view.published_presi')}
@ -666,10 +661,6 @@
border: 1px solid color-mix(in srgb, #22c55e 30%, transparent);
font-size: 0.75rem;
}
.published-chip a {
color: inherit;
text-decoration: underline;
}
.status-picker {
display: inline-flex;
gap: 0.25rem;

View file

@ -25,7 +25,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
| 'articles-unread' // Articles: saved read-it-later articles
| 'body-stats' // Body: latest weight + active workout summary
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
| 'broadcasts' // Broadcast: YTD counts + last sent + next scheduled
@ -283,14 +282,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'period',
},
{
type: 'articles-unread',
nameKey: 'dashboard.widgets.articles_unread.title',
descriptionKey: 'dashboard.widgets.articles_unread.description',
icon: '📚',
defaultSize: 'small',
allowMultiple: false,
},
{
type: 'body-stats',
nameKey: 'dashboard.widgets.body_stats.title',

View file

@ -1,13 +0,0 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Artikel - Mana</title>
</svelte:head>
<RoutePage appId="articles">
<!-- /articles-Root → Leseliste als Default-Tab -->
<ArticlesTabShell initialTab="list" />
</RoutePage>

View file

@ -1,12 +0,0 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Favoriten — Artikel — Mana</title>
</svelte:head>
<RoutePage appId="articles" title="Artikel · Favoriten">
<ArticlesTabShell initialTab="favorites" />
</RoutePage>

View file

@ -1,12 +0,0 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Highlights — Artikel — Mana</title>
</svelte:head>
<RoutePage appId="articles" title="Artikel · Highlights">
<ArticlesTabShell initialTab="highlights" />
</RoutePage>

View file

@ -1,12 +0,0 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Leseliste — Artikel — Mana</title>
</svelte:head>
<RoutePage appId="articles" title="Artikel · Leseliste">
<ArticlesTabShell initialTab="list" />
</RoutePage>

View file

@ -1,12 +0,0 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Stats — Artikel — Mana</title>
</svelte:head>
<RoutePage appId="articles" title="Artikel · Stats">
<ArticlesTabShell initialTab="stats" />
</RoutePage>

View file

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

View file

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

View file

@ -1,14 +0,0 @@
<script lang="ts">
import BulkImportForm from '$lib/modules/articles/components/BulkImportForm.svelte';
import JobsList from '$lib/modules/articles/components/JobsList.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Artikel-Import — Mana</title>
</svelte:head>
<RoutePage appId="articles" backHref="/articles">
<BulkImportForm />
<JobsList />
</RoutePage>

View file

@ -1,17 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import JobDetailView from '$lib/modules/articles/components/JobDetailView.svelte';
import { RoutePage } from '$lib/components/shell';
const jobId = $derived($page.params.jobId ?? '');
</script>
<svelte:head>
<title>Artikel-Import — Mana</title>
</svelte:head>
<RoutePage appId="articles" backHref="/articles/import">
{#if jobId}
<JobDetailView {jobId} />
{/if}
</RoutePage>

View file

@ -1,309 +0,0 @@
<!--
/articles/settings — collection of "how to save faster" tips.
Three surfaces today:
1. Browser-HTML-Bookmarklet (v2) — recommended. Reads the rendered
HTML from the user's browser tab + postMessages it to a new Mana
tab which runs Readability on that HTML. Works on cookie-walled
sites (Golem, Spiegel, Zeit, Heise) and soft paywalls because it
uses the user's already-authenticated session.
2. URL-Bookmarklet (v1) — legacy. Opens /articles/add?url=<page>,
the server does an anonymous fetch. Fails on GDPR-walled sites
(see AddUrlForm's "probable_consent_wall" warning). Kept because
it's a single click for sites where it works, and cross-browser
stable.
3. Share-Target — installed PWA appears in the Android / Chromium
share sheet. Uses the same URL path as v1.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { RoutePage } from '$lib/components/shell';
// `origin` at render time — server-side rendering has no window, so
// we read it client-side after mount. The bookmarklets embed the
// origin so the JavaScript URLs work from any other origin's
// bookmark bar.
let origin = $state('');
onMount(() => {
origin = window.location.origin;
});
// Bookmarklet v1 — quick path for sites without consent walls.
// Single call: open new tab with ?url=<current page>; server fetches.
const bookmarkletV1 = $derived(
origin
? `javascript:void(window.open('${origin}/articles/add?url='+encodeURIComponent(location.href)+'&title='+encodeURIComponent(document.title),'_blank'))`
: ''
);
// Bookmarklet v2 — browser-HTML path. Reads the rendered DOM from
// the user's current tab (with all cookies + consent applied), opens
// /articles/add?source=bookmarklet in a new tab, and postMessages
// the HTML over. The new tab's AddUrlForm is the authoritative
// receiver: it POSTs the HTML to /api/v1/articles/extract/html over
// its own same-origin authenticated channel. No cross-origin CORS,
// no token sharing, no form-submission CSP issues. Includes a 15s
// safety timeout in case the new tab never comes up.
//
// The snippet is intentionally compact — browsers truncate bookmark
// URLs at different lengths (Chrome/Firefox handle ~64 KiB fine,
// Safari is stricter) so readable JS compresses well here.
const bookmarkletV2 = $derived(
origin
? `javascript:(()=>{var h=document.documentElement.outerHTML,u=location.href,t=document.title,w=window.open('${origin}/articles/add?source=bookmarklet','_blank'),d=0;if(!w){alert('Mana: Popup blockiert');return}var m=function(e){if(e.source!==w)return;if(e.data&&e.data.type==='mana-ready'){w.postMessage({type:'mana-html',url:u,html:h,title:t},'*');window.removeEventListener('message',m);d=1}};window.addEventListener('message',m);setTimeout(function(){if(!d){window.removeEventListener('message',m);alert('Mana antwortet nicht — Tab geöffnet?')}},15000)})()`
: ''
);
let copyV1Label = $state('Snippet kopieren');
let copyV2Label = $state('Snippet kopieren');
async function copySnippet(value: string, which: 'v1' | 'v2') {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
if (which === 'v1') {
copyV1Label = 'Kopiert ✓';
setTimeout(() => (copyV1Label = 'Snippet kopieren'), 1500);
} else {
copyV2Label = 'Kopiert ✓';
setTimeout(() => (copyV2Label = 'Snippet kopieren'), 1500);
}
} catch {
if (which === 'v1') copyV1Label = 'Fehler — bitte manuell kopieren';
else copyV2Label = 'Fehler — bitte manuell kopieren';
}
}
</script>
<svelte:head>
<title>Artikel-Einstellungen — Mana</title>
</svelte:head>
<RoutePage appId="articles" backHref="/articles">
<div class="settings-shell">
<header class="header">
<h1>Artikel-Einstellungen</h1>
<p class="subtitle">Schnellwege, um Artikel aus dem Browser in die Leseliste zu bekommen.</p>
</header>
<section class="card card-recommended">
<div class="badge">Empfohlen</div>
<h2>Browser-HTML-Bookmarklet</h2>
<p>
Dieses Bookmarklet nimmt den <strong>schon gerenderten HTML-Inhalt</strong> aus deinem Browser-Tab
(inkl. aller Cookies, die du gesetzt hast) und schickt ihn an Mana. Damit klappen auch Seiten
mit Cookie-Wänden (Golem, Spiegel, Zeit, Heise …) und weichen Paywalls.
</p>
<div class="bookmarklet-row">
{#if bookmarkletV2}
<a class="bookmarklet" href={bookmarkletV2} onclick={(e) => e.preventDefault()}>
+ In Mana speichern (HTML)
</a>
{:else}
<span class="muted">Bookmarklet wird geladen…</span>
{/if}
<button
type="button"
class="copy-btn"
onclick={() => copySnippet(bookmarkletV2, 'v2')}
disabled={!bookmarkletV2}
>
{copyV2Label}
</button>
</div>
<details class="snippet-details">
<summary>Quellcode anzeigen</summary>
<pre class="snippet">{bookmarkletV2}</pre>
</details>
<p class="hint">
Öffnet einen neuen Tab mit Mana, der Mana-Tab bekommt das HTML per
<code>postMessage</code> von deinem Artikel-Tab. Braucht erlaubte Popups für diese Domain (Browser
fragt beim ersten Mal).
</p>
</section>
<section class="card">
<h2>URL-Bookmarklet (klassisch)</h2>
<p>
Schickt nur die URL an Mana, der Server lädt + extrahiert dann selbst. Schnell auf einfachen
Blogs / Wikis; scheitert auf Seiten hinter DSGVO-Zustimmungs-Dialogen.
</p>
<div class="bookmarklet-row">
{#if bookmarkletV1}
<a
class="bookmarklet bookmarklet-secondary"
href={bookmarkletV1}
onclick={(e) => e.preventDefault()}
>
+ In Mana speichern (URL)
</a>
{:else}
<span class="muted">Bookmarklet wird geladen…</span>
{/if}
<button
type="button"
class="copy-btn"
onclick={() => copySnippet(bookmarkletV1, 'v1')}
disabled={!bookmarkletV1}
>
{copyV1Label}
</button>
</div>
<details class="snippet-details">
<summary>Quellcode anzeigen</summary>
<pre class="snippet">{bookmarkletV1}</pre>
</details>
<p class="hint">
Funktioniert in jedem Desktop-Browser. In Safari: Lesezeichen mit beliebiger URL anlegen und
die URL dann durch das Snippet ersetzen.
</p>
</section>
<section class="card">
<h2>Share-Target (Android / Chromium)</h2>
<p>
Wenn du Mana als App installierst (Browser-Menü „Zum Startbildschirm hinzufügen"), taucht
„Mana" in deinem OS-Share-Sheet auf. Teilen aus dem Browser oder einer anderen App → Mana
auswählen → Artikel wird direkt in der Leseliste vorgeschlagen.
</p>
<p class="hint">
Benutzt dieselbe URL-Route wie das klassische Bookmarklet oben — für cookie-gewalled Seiten
lieber das HTML-Bookmarklet verwenden. iOS-Safari unterstützt die Web-Share-Target-API
derzeit nicht.
</p>
</section>
</div>
</RoutePage>
<style>
.settings-shell {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
}
.subtitle {
color: var(--color-text-muted, #64748b);
margin: 0;
font-size: 0.95rem;
}
.card {
padding: 1.1rem 1.2rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.75rem;
background: var(--color-surface, transparent);
position: relative;
}
.card-recommended {
border-color: color-mix(in srgb, #f97316 50%, transparent);
background: color-mix(in srgb, #f97316 4%, transparent);
}
.badge {
position: absolute;
top: -0.55rem;
left: 1rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: #f97316;
color: white;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.bookmarklet-secondary {
background: transparent;
color: #f97316;
border: 1px solid #f97316;
}
.bookmarklet-secondary:hover {
background: color-mix(in srgb, #f97316 10%, transparent);
}
.card h2 {
margin: 0 0 0.5rem 0;
font-size: 1.15rem;
}
.card p {
margin: 0 0 0.75rem 0;
line-height: 1.55;
}
.hint {
color: var(--color-text-muted, #64748b);
font-size: 0.85rem;
}
code {
padding: 0.1rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in srgb, currentColor 8%, transparent);
font-size: 0.92em;
}
.bookmarklet-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.75rem 0;
flex-wrap: wrap;
}
.bookmarklet {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
background: #f97316;
color: white;
font-weight: 500;
text-decoration: none;
user-select: none;
cursor: grab;
display: inline-flex;
align-items: center;
}
.bookmarklet:hover {
background: #ea580c;
}
.copy-btn {
padding: 0.5rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.copy-btn:hover:not(:disabled) {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: var(--color-text-muted, #64748b);
}
.snippet-details {
margin-top: 0.5rem;
}
.snippet-details summary {
cursor: pointer;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.snippet {
margin: 0.5rem 0 0 0;
padding: 0.6rem 0.75rem;
border-radius: 0.45rem;
background: color-mix(in srgb, currentColor 6%, transparent);
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.78rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View file

@ -6,9 +6,7 @@
Ephemeral session — lives in sessionStorage, never touches Dexie.
-->
<script lang="ts">
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 { RoutePage } from '$lib/components/shell';
let mode = $state<'query' | 'site'>('query');
@ -16,8 +14,6 @@
let siteUrl = $state('');
let searchQuery = $state('');
let copyLabel = $state('Als KI-Kontext kopieren');
let savingUrl = $state<string | null>(null);
let saveError = $state<string | null>(null);
const store = researchSessionStore;
@ -56,18 +52,6 @@
}
}
async function onSave(articleUrl: string) {
savingUrl = articleUrl;
saveError = null;
try {
const { article } = await articlesStore.saveFromUrl(articleUrl);
goto(`/articles/${article.id}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
savingUrl = null;
}
}
function formatDate(iso: string | null): string {
if (!iso) return '';
try {
@ -189,9 +173,6 @@
<h2>Ergebnisse ({store.session.results.length})</h2>
<button type="button" class="secondary" onclick={onCopy}>{copyLabel}</button>
</div>
{#if saveError}
<div class="error">{saveError}</div>
{/if}
<ul class="result-list">
{#each store.session.results as article (article.url)}
<li class="result">
@ -208,14 +189,6 @@
{#if article.excerpt}
<p class="r-excerpt">{article.excerpt}</p>
{/if}
<button
type="button"
class="save"
disabled={savingUrl === article.url}
onclick={() => onSave(article.url)}
>
{savingUrl === article.url ? 'Speichere…' : 'In Leseliste speichern'}
</button>
</li>
{/each}
</ul>
@ -372,20 +345,6 @@
color: hsl(var(--color-foreground));
font-size: 0.9rem;
}
.save {
align-self: flex-start;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.35rem;
padding: 0.25rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
color: hsl(var(--color-foreground));
}
.save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
background: hsl(var(--color-destructive) / 0.1);
border: 1px solid hsl(var(--color-destructive) / 0.4);

View file

@ -847,23 +847,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'articles',
name: 'Artikel',
description: {
de: 'Später lesen — offline',
en: 'Read later — offline',
},
longDescription: {
de: 'Speichere Web-Artikel und lies sie offline im Reader — mit Highlights, Tags und Notizen. Ein Zuhause für alles, das du später in Ruhe lesen willst.',
en: 'Save web articles and read them offline in a distraction-free reader — with highlights, tags and notes. A home for everything you want to read properly later.',
},
icon: APP_ICONS.articles,
color: '#f97316',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'writing',
name: 'Schreiben',