mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 00:41:26 +02:00
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
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:
parent
001548c74d
commit
0112161e78
82 changed files with 21 additions and 9057 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
'datenschutzeinstellungen',
|
||||
'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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"detail_view": {
|
||||
"page_title_html": "{title} — Mana",
|
||||
"untitled_fallback": "Artikel",
|
||||
"loading": "Lädt…",
|
||||
"not_found": "Artikel nicht gefunden.",
|
||||
"back_to_list": "Zurück zur Liste",
|
||||
"meta_word_count": "{n} Wörter",
|
||||
"meta_reading_minutes": "{n} min",
|
||||
"tag_add_label": "Tag",
|
||||
"tag_placeholder": "Tag suchen oder erstellen…",
|
||||
"toolbar_aria": "Lese-Werkzeuge",
|
||||
"back_aria": "Zurück zur Liste",
|
||||
"back_tip": "Zurück zur Leseliste",
|
||||
"font_smaller_aria": "Schrift kleiner",
|
||||
"font_smaller_tip": "Schrift kleiner",
|
||||
"font_larger_aria": "Schrift größer",
|
||||
"font_larger_tip": "Schrift größer",
|
||||
"font_serif_tip": "Serif-Schrift",
|
||||
"font_serif_label": "Serif",
|
||||
"font_sans_tip": "Sans-Serif-Schrift",
|
||||
"font_sans_label": "Sans",
|
||||
"theme_light_aria": "Heller Modus",
|
||||
"theme_light_tip": "Heller Modus",
|
||||
"theme_sepia_aria": "Sepia-Modus",
|
||||
"theme_sepia_tip": "Sepia-Modus",
|
||||
"theme_dark_aria": "Dunkler Modus",
|
||||
"theme_dark_tip": "Dunkler Modus",
|
||||
"mark_unread_label": "Als ungelesen markieren",
|
||||
"mark_read_label": "Als gelesen markieren",
|
||||
"fav_remove": "Favorit entfernen",
|
||||
"fav_mark": "Als Favorit markieren",
|
||||
"archive_label": "Artikel archivieren",
|
||||
"open_original": "Original-Seite öffnen",
|
||||
"delete_label": "Artikel löschen",
|
||||
"confirm_delete": "Artikel wirklich löschen?"
|
||||
},
|
||||
"import": {
|
||||
"bulk_link": "Mehrere URLs auf einmal? → Bulk-Import",
|
||||
"form_title": "Mehrere Artikel importieren",
|
||||
"form_subtitle": "Eine URL pro Zeile (oder durch Leerzeichen / Komma getrennt). Mana extrahiert sie nacheinander im Hintergrund.",
|
||||
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
|
||||
"count_valid": "{n} gültig",
|
||||
"count_overlimit_suffix": " / max {max}",
|
||||
"count_dup": "{n} doppelt (übersprungen)",
|
||||
"count_invalid": "{n} ungültig",
|
||||
"invalid_details_summary": "Ungültige Zeilen anzeigen ({n})",
|
||||
"error_no_urls": "Mindestens eine gültige URL einfügen.",
|
||||
"error_overlimit": "Zu viele URLs ({n}). Maximal {max} pro Job — splitte den Import.",
|
||||
"error_failed": "Job konnte nicht erstellt werden.",
|
||||
"submit_label": "{n} URLs importieren",
|
||||
"submit_busy": "Erstelle Job…",
|
||||
"hint": "Im Hintergrund — du kannst den Tab schließen und später zurückkommen. Bei 50 URLs dauert es grob 5–10 Minuten. Den Fortschritt siehst du auf der Detailseite.",
|
||||
"jobs_heading": "Bisherige Imports",
|
||||
"filter_all": "Alle ({n})",
|
||||
"filter_active": "Aktiv ({n})",
|
||||
"filter_done": "Fertig ({n})",
|
||||
"filter_errors": "Mit Fehlern ({n})",
|
||||
"empty_filter": "Keine Jobs in dieser Ansicht.",
|
||||
"status_queued": "Wartet",
|
||||
"status_running": "Läuft",
|
||||
"status_paused": "Pausiert",
|
||||
"status_done": "Fertig",
|
||||
"status_cancelled": "Abgebrochen",
|
||||
"jobs_meta_errors": "{n} Fehler",
|
||||
"jobs_meta_dups": "{n} Duplikate",
|
||||
"jobs_meta_warnings": "{n} Warnungen",
|
||||
"detail_title": "Import-Job",
|
||||
"detail_not_found": "Job nicht gefunden.",
|
||||
"detail_progress_aria": "Fortschritt",
|
||||
"detail_counter_total": "{done} / {total} verarbeitet",
|
||||
"detail_counter_saved": "{n} gespeichert",
|
||||
"detail_counter_dups": "{n} Duplikate",
|
||||
"detail_counter_warns": "{n} mit Cookie-Wand",
|
||||
"detail_counter_errors": "{n} Fehler",
|
||||
"action_pause": "Pause",
|
||||
"action_resume": "Fortsetzen",
|
||||
"action_cancel": "Abbrechen",
|
||||
"action_retry": "Fehler wiederholen",
|
||||
"action_delete": "Löschen",
|
||||
"confirm_cancel": "Job wirklich abbrechen? Bisherige Artikel bleiben gespeichert.",
|
||||
"confirm_delete": "Job-Historie löschen? Artikel bleiben.",
|
||||
"consent_hint_strong": "Cookie-Wand erkannt",
|
||||
"consent_hint_body": "{n, plural, one {# Artikel hat} other {# Artikel haben}} nur den Cookie-Zustimmungs-Dialog gespeichert (der Server sieht keine Cookies). Lösung:",
|
||||
"consent_hint_link": "Browser-HTML-Bookmarklet",
|
||||
"consent_hint_after_link": "aus dem Tab in dem du dem Cookie zugestimmt hast benutzen — überschreibt den Teaser durch den echten Artikel.",
|
||||
"item_pending": "Wartet",
|
||||
"item_extracting": "Extrahiert…",
|
||||
"item_extracted": "Server fertig",
|
||||
"item_saved": "✓ Gespeichert",
|
||||
"item_duplicate": "· Duplikat",
|
||||
"item_consent_wall": "⚠ Cookie-Wand",
|
||||
"item_error": "✗ Fehler",
|
||||
"item_cancelled": "Abgebrochen",
|
||||
"item_action_view_teaser": "Teaser ansehen",
|
||||
"item_action_rescue": "Erneut speichern",
|
||||
"item_action_rescue_tip": "Mit Bookmarklet erneut speichern — überschreibt den Teaser durch den echten Artikel",
|
||||
"item_action_open": "Öffnen"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"detail_view": {
|
||||
"page_title_html": "{title} — Mana",
|
||||
"untitled_fallback": "Article",
|
||||
"loading": "Loading…",
|
||||
"not_found": "Article not found.",
|
||||
"back_to_list": "Back to list",
|
||||
"meta_word_count": "{n} words",
|
||||
"meta_reading_minutes": "{n} min",
|
||||
"tag_add_label": "Tag",
|
||||
"tag_placeholder": "Search or create tag…",
|
||||
"toolbar_aria": "Reading tools",
|
||||
"back_aria": "Back to list",
|
||||
"back_tip": "Back to reading list",
|
||||
"font_smaller_aria": "Smaller font",
|
||||
"font_smaller_tip": "Smaller font",
|
||||
"font_larger_aria": "Larger font",
|
||||
"font_larger_tip": "Larger font",
|
||||
"font_serif_tip": "Serif font",
|
||||
"font_serif_label": "Serif",
|
||||
"font_sans_tip": "Sans-serif font",
|
||||
"font_sans_label": "Sans",
|
||||
"theme_light_aria": "Light mode",
|
||||
"theme_light_tip": "Light mode",
|
||||
"theme_sepia_aria": "Sepia mode",
|
||||
"theme_sepia_tip": "Sepia mode",
|
||||
"theme_dark_aria": "Dark mode",
|
||||
"theme_dark_tip": "Dark mode",
|
||||
"mark_unread_label": "Mark as unread",
|
||||
"mark_read_label": "Mark as read",
|
||||
"fav_remove": "Remove favorite",
|
||||
"fav_mark": "Mark as favorite",
|
||||
"archive_label": "Archive article",
|
||||
"open_original": "Open original page",
|
||||
"delete_label": "Delete article",
|
||||
"confirm_delete": "Really delete article?"
|
||||
},
|
||||
"import": {
|
||||
"bulk_link": "Multiple URLs at once? → Bulk import",
|
||||
"form_title": "Import multiple articles",
|
||||
"form_subtitle": "One URL per line (or separated by spaces / commas). Mana extracts them one after the other in the background.",
|
||||
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
|
||||
"count_valid": "{n} valid",
|
||||
"count_overlimit_suffix": " / max {max}",
|
||||
"count_dup": "{n} duplicate (skipped)",
|
||||
"count_invalid": "{n} invalid",
|
||||
"invalid_details_summary": "Show invalid lines ({n})",
|
||||
"error_no_urls": "Add at least one valid URL.",
|
||||
"error_overlimit": "Too many URLs ({n}). Maximum {max} per job — split the import.",
|
||||
"error_failed": "Could not create job.",
|
||||
"submit_label": "Import {n} URLs",
|
||||
"submit_busy": "Creating job…",
|
||||
"hint": "Runs in the background — you can close the tab and come back later. 50 URLs take roughly 5–10 minutes. Progress is on the detail page.",
|
||||
"jobs_heading": "Past imports",
|
||||
"filter_all": "All ({n})",
|
||||
"filter_active": "Active ({n})",
|
||||
"filter_done": "Done ({n})",
|
||||
"filter_errors": "With errors ({n})",
|
||||
"empty_filter": "No jobs in this view.",
|
||||
"status_queued": "Queued",
|
||||
"status_running": "Running",
|
||||
"status_paused": "Paused",
|
||||
"status_done": "Done",
|
||||
"status_cancelled": "Cancelled",
|
||||
"jobs_meta_errors": "{n} errors",
|
||||
"jobs_meta_dups": "{n} duplicates",
|
||||
"jobs_meta_warnings": "{n} warnings",
|
||||
"detail_title": "Import job",
|
||||
"detail_not_found": "Job not found.",
|
||||
"detail_progress_aria": "Progress",
|
||||
"detail_counter_total": "{done} / {total} processed",
|
||||
"detail_counter_saved": "{n} saved",
|
||||
"detail_counter_dups": "{n} duplicates",
|
||||
"detail_counter_warns": "{n} with cookie wall",
|
||||
"detail_counter_errors": "{n} errors",
|
||||
"action_pause": "Pause",
|
||||
"action_resume": "Resume",
|
||||
"action_cancel": "Cancel",
|
||||
"action_retry": "Retry errors",
|
||||
"action_delete": "Delete",
|
||||
"confirm_cancel": "Really cancel job? Already-saved articles stay.",
|
||||
"confirm_delete": "Delete job history? Articles stay.",
|
||||
"consent_hint_strong": "Cookie wall detected",
|
||||
"consent_hint_body": "{n, plural, one {# article has} other {# articles have}} only saved the cookie consent dialog (the server sees no cookies). Fix:",
|
||||
"consent_hint_link": "Browser HTML bookmarklet",
|
||||
"consent_hint_after_link": "from the tab where you already accepted — overwrites the teaser with the real article.",
|
||||
"item_pending": "Waiting",
|
||||
"item_extracting": "Extracting…",
|
||||
"item_extracted": "Server done",
|
||||
"item_saved": "✓ Saved",
|
||||
"item_duplicate": "· Duplicate",
|
||||
"item_consent_wall": "⚠ Cookie wall",
|
||||
"item_error": "✗ Error",
|
||||
"item_cancelled": "Cancelled",
|
||||
"item_action_view_teaser": "View teaser",
|
||||
"item_action_rescue": "Save again",
|
||||
"item_action_rescue_tip": "Save again with the bookmarklet — overwrites the teaser with the real article",
|
||||
"item_action_open": "Open"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"detail_view": {
|
||||
"page_title_html": "{title} — Mana",
|
||||
"untitled_fallback": "Artículo",
|
||||
"loading": "Cargando…",
|
||||
"not_found": "Artículo no encontrado.",
|
||||
"back_to_list": "Volver a la lista",
|
||||
"meta_word_count": "{n} palabras",
|
||||
"meta_reading_minutes": "{n} min",
|
||||
"tag_add_label": "Tag",
|
||||
"tag_placeholder": "Buscar o crear etiqueta…",
|
||||
"toolbar_aria": "Herramientas de lectura",
|
||||
"back_aria": "Volver a la lista",
|
||||
"back_tip": "Volver a la lista de lectura",
|
||||
"font_smaller_aria": "Fuente más pequeña",
|
||||
"font_smaller_tip": "Fuente más pequeña",
|
||||
"font_larger_aria": "Fuente más grande",
|
||||
"font_larger_tip": "Fuente más grande",
|
||||
"font_serif_tip": "Fuente serif",
|
||||
"font_serif_label": "Serif",
|
||||
"font_sans_tip": "Fuente sans-serif",
|
||||
"font_sans_label": "Sans",
|
||||
"theme_light_aria": "Modo claro",
|
||||
"theme_light_tip": "Modo claro",
|
||||
"theme_sepia_aria": "Modo sepia",
|
||||
"theme_sepia_tip": "Modo sepia",
|
||||
"theme_dark_aria": "Modo oscuro",
|
||||
"theme_dark_tip": "Modo oscuro",
|
||||
"mark_unread_label": "Marcar como no leído",
|
||||
"mark_read_label": "Marcar como leído",
|
||||
"fav_remove": "Quitar de favoritos",
|
||||
"fav_mark": "Marcar como favorito",
|
||||
"archive_label": "Archivar artículo",
|
||||
"open_original": "Abrir página original",
|
||||
"delete_label": "Eliminar artículo",
|
||||
"confirm_delete": "¿Eliminar realmente el artículo?"
|
||||
},
|
||||
"import": {
|
||||
"bulk_link": "¿Varias URLs a la vez? → Importación masiva",
|
||||
"form_title": "Importar varios artículos",
|
||||
"form_subtitle": "Una URL por línea (o separadas por espacios / comas). Mana las extrae una tras otra en segundo plano.",
|
||||
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
|
||||
"count_valid": "{n} válidas",
|
||||
"count_overlimit_suffix": " / máx {max}",
|
||||
"count_dup": "{n} duplicadas (omitidas)",
|
||||
"count_invalid": "{n} inválidas",
|
||||
"invalid_details_summary": "Mostrar líneas inválidas ({n})",
|
||||
"error_no_urls": "Añade al menos una URL válida.",
|
||||
"error_overlimit": "Demasiadas URLs ({n}). Máximo {max} por job — divide la importación.",
|
||||
"error_failed": "No se pudo crear el job.",
|
||||
"submit_label": "Importar {n} URLs",
|
||||
"submit_busy": "Creando job…",
|
||||
"hint": "Se ejecuta en segundo plano — puedes cerrar la pestaña y volver más tarde. 50 URLs tardan unos 5–10 minutos. El progreso está en la página de detalle.",
|
||||
"jobs_heading": "Importaciones anteriores",
|
||||
"filter_all": "Todos ({n})",
|
||||
"filter_active": "Activos ({n})",
|
||||
"filter_done": "Completados ({n})",
|
||||
"filter_errors": "Con errores ({n})",
|
||||
"empty_filter": "Ningún job en esta vista.",
|
||||
"status_queued": "En cola",
|
||||
"status_running": "En ejecución",
|
||||
"status_paused": "Pausado",
|
||||
"status_done": "Completado",
|
||||
"status_cancelled": "Cancelado",
|
||||
"jobs_meta_errors": "{n} errores",
|
||||
"jobs_meta_dups": "{n} duplicados",
|
||||
"jobs_meta_warnings": "{n} advertencias",
|
||||
"detail_title": "Job de importación",
|
||||
"detail_not_found": "Job no encontrado.",
|
||||
"detail_progress_aria": "Progreso",
|
||||
"detail_counter_total": "{done} / {total} procesados",
|
||||
"detail_counter_saved": "{n} guardados",
|
||||
"detail_counter_dups": "{n} duplicados",
|
||||
"detail_counter_warns": "{n} con muro de cookies",
|
||||
"detail_counter_errors": "{n} errores",
|
||||
"action_pause": "Pausar",
|
||||
"action_resume": "Reanudar",
|
||||
"action_cancel": "Cancelar",
|
||||
"action_retry": "Reintentar errores",
|
||||
"action_delete": "Eliminar",
|
||||
"confirm_cancel": "¿Cancelar realmente el job? Los artículos ya guardados permanecen.",
|
||||
"confirm_delete": "¿Eliminar el historial del job? Los artículos permanecen.",
|
||||
"consent_hint_strong": "Muro de cookies detectado",
|
||||
"consent_hint_body": "{n, plural, one {# artículo solo ha} other {# artículos solo han}} guardado el diálogo de consentimiento de cookies (el servidor no ve cookies). Solución:",
|
||||
"consent_hint_link": "bookmarklet HTML del navegador",
|
||||
"consent_hint_after_link": "desde la pestaña donde ya aceptaste — sobrescribe el teaser con el artículo real.",
|
||||
"item_pending": "En espera",
|
||||
"item_extracting": "Extrayendo…",
|
||||
"item_extracted": "Servidor listo",
|
||||
"item_saved": "✓ Guardado",
|
||||
"item_duplicate": "· Duplicado",
|
||||
"item_consent_wall": "⚠ Muro de cookies",
|
||||
"item_error": "✗ Error",
|
||||
"item_cancelled": "Cancelado",
|
||||
"item_action_view_teaser": "Ver teaser",
|
||||
"item_action_rescue": "Guardar de nuevo",
|
||||
"item_action_rescue_tip": "Guardar de nuevo con el bookmarklet — sobrescribe el teaser con el artículo real",
|
||||
"item_action_open": "Abrir"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"detail_view": {
|
||||
"page_title_html": "{title} — Mana",
|
||||
"untitled_fallback": "Article",
|
||||
"loading": "Chargement…",
|
||||
"not_found": "Article introuvable.",
|
||||
"back_to_list": "Retour à la liste",
|
||||
"meta_word_count": "{n} mots",
|
||||
"meta_reading_minutes": "{n} min",
|
||||
"tag_add_label": "Tag",
|
||||
"tag_placeholder": "Rechercher ou créer un tag…",
|
||||
"toolbar_aria": "Outils de lecture",
|
||||
"back_aria": "Retour à la liste",
|
||||
"back_tip": "Retour à la liste de lecture",
|
||||
"font_smaller_aria": "Police plus petite",
|
||||
"font_smaller_tip": "Police plus petite",
|
||||
"font_larger_aria": "Police plus grande",
|
||||
"font_larger_tip": "Police plus grande",
|
||||
"font_serif_tip": "Police serif",
|
||||
"font_serif_label": "Serif",
|
||||
"font_sans_tip": "Police sans-serif",
|
||||
"font_sans_label": "Sans",
|
||||
"theme_light_aria": "Mode clair",
|
||||
"theme_light_tip": "Mode clair",
|
||||
"theme_sepia_aria": "Mode sépia",
|
||||
"theme_sepia_tip": "Mode sépia",
|
||||
"theme_dark_aria": "Mode sombre",
|
||||
"theme_dark_tip": "Mode sombre",
|
||||
"mark_unread_label": "Marquer comme non lu",
|
||||
"mark_read_label": "Marquer comme lu",
|
||||
"fav_remove": "Retirer des favoris",
|
||||
"fav_mark": "Marquer comme favori",
|
||||
"archive_label": "Archiver l'article",
|
||||
"open_original": "Ouvrir la page d'origine",
|
||||
"delete_label": "Supprimer l'article",
|
||||
"confirm_delete": "Vraiment supprimer l'article ?"
|
||||
},
|
||||
"import": {
|
||||
"bulk_link": "Plusieurs URLs à la fois ? → Import groupé",
|
||||
"form_title": "Importer plusieurs articles",
|
||||
"form_subtitle": "Une URL par ligne (ou séparées par des espaces / virgules). Mana les extrait l'une après l'autre en arrière-plan.",
|
||||
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
|
||||
"count_valid": "{n} valides",
|
||||
"count_overlimit_suffix": " / max {max}",
|
||||
"count_dup": "{n} doublons (ignorés)",
|
||||
"count_invalid": "{n} invalides",
|
||||
"invalid_details_summary": "Afficher les lignes invalides ({n})",
|
||||
"error_no_urls": "Ajoute au moins une URL valide.",
|
||||
"error_overlimit": "Trop d'URLs ({n}). Maximum {max} par job — divise l'import.",
|
||||
"error_failed": "Impossible de créer le job.",
|
||||
"submit_label": "Importer {n} URLs",
|
||||
"submit_busy": "Création du job…",
|
||||
"hint": "S'exécute en arrière-plan — tu peux fermer l'onglet et revenir plus tard. 50 URLs prennent environ 5–10 minutes. La progression est visible sur la page de détail.",
|
||||
"jobs_heading": "Imports passés",
|
||||
"filter_all": "Tous ({n})",
|
||||
"filter_active": "Actifs ({n})",
|
||||
"filter_done": "Terminés ({n})",
|
||||
"filter_errors": "Avec erreurs ({n})",
|
||||
"empty_filter": "Aucun job dans cette vue.",
|
||||
"status_queued": "En attente",
|
||||
"status_running": "En cours",
|
||||
"status_paused": "En pause",
|
||||
"status_done": "Terminé",
|
||||
"status_cancelled": "Annulé",
|
||||
"jobs_meta_errors": "{n} erreurs",
|
||||
"jobs_meta_dups": "{n} doublons",
|
||||
"jobs_meta_warnings": "{n} avertissements",
|
||||
"detail_title": "Job d'import",
|
||||
"detail_not_found": "Job introuvable.",
|
||||
"detail_progress_aria": "Progression",
|
||||
"detail_counter_total": "{done} / {total} traités",
|
||||
"detail_counter_saved": "{n} enregistrés",
|
||||
"detail_counter_dups": "{n} doublons",
|
||||
"detail_counter_warns": "{n} avec mur de cookies",
|
||||
"detail_counter_errors": "{n} erreurs",
|
||||
"action_pause": "Pause",
|
||||
"action_resume": "Reprendre",
|
||||
"action_cancel": "Annuler",
|
||||
"action_retry": "Réessayer les erreurs",
|
||||
"action_delete": "Supprimer",
|
||||
"confirm_cancel": "Vraiment annuler le job ? Les articles déjà enregistrés restent.",
|
||||
"confirm_delete": "Supprimer l'historique du job ? Les articles restent.",
|
||||
"consent_hint_strong": "Mur de cookies détecté",
|
||||
"consent_hint_body": "{n, plural, one {# article n'a enregistré} other {# articles n'ont enregistré}} que la boîte de dialogue de consentement (le serveur ne voit aucun cookie). Solution :",
|
||||
"consent_hint_link": "bookmarklet HTML du navigateur",
|
||||
"consent_hint_after_link": "depuis l'onglet où tu as déjà accepté — remplace le teaser par l'article réel.",
|
||||
"item_pending": "En attente",
|
||||
"item_extracting": "Extraction…",
|
||||
"item_extracted": "Serveur terminé",
|
||||
"item_saved": "✓ Enregistré",
|
||||
"item_duplicate": "· Doublon",
|
||||
"item_consent_wall": "⚠ Mur de cookies",
|
||||
"item_error": "✗ Erreur",
|
||||
"item_cancelled": "Annulé",
|
||||
"item_action_view_teaser": "Voir le teaser",
|
||||
"item_action_rescue": "Réenregistrer",
|
||||
"item_action_rescue_tip": "Réenregistrer avec le bookmarklet — remplace le teaser par l'article réel",
|
||||
"item_action_open": "Ouvrir"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"detail_view": {
|
||||
"page_title_html": "{title} — Mana",
|
||||
"untitled_fallback": "Articolo",
|
||||
"loading": "Caricamento…",
|
||||
"not_found": "Articolo non trovato.",
|
||||
"back_to_list": "Torna alla lista",
|
||||
"meta_word_count": "{n} parole",
|
||||
"meta_reading_minutes": "{n} min",
|
||||
"tag_add_label": "Tag",
|
||||
"tag_placeholder": "Cerca o crea un tag…",
|
||||
"toolbar_aria": "Strumenti di lettura",
|
||||
"back_aria": "Torna alla lista",
|
||||
"back_tip": "Torna alla lista di lettura",
|
||||
"font_smaller_aria": "Font più piccolo",
|
||||
"font_smaller_tip": "Font più piccolo",
|
||||
"font_larger_aria": "Font più grande",
|
||||
"font_larger_tip": "Font più grande",
|
||||
"font_serif_tip": "Font serif",
|
||||
"font_serif_label": "Serif",
|
||||
"font_sans_tip": "Font sans-serif",
|
||||
"font_sans_label": "Sans",
|
||||
"theme_light_aria": "Modalità chiara",
|
||||
"theme_light_tip": "Modalità chiara",
|
||||
"theme_sepia_aria": "Modalità seppia",
|
||||
"theme_sepia_tip": "Modalità seppia",
|
||||
"theme_dark_aria": "Modalità scura",
|
||||
"theme_dark_tip": "Modalità scura",
|
||||
"mark_unread_label": "Segna come non letto",
|
||||
"mark_read_label": "Segna come letto",
|
||||
"fav_remove": "Rimuovi dai preferiti",
|
||||
"fav_mark": "Segna come preferito",
|
||||
"archive_label": "Archivia articolo",
|
||||
"open_original": "Apri pagina originale",
|
||||
"delete_label": "Elimina articolo",
|
||||
"confirm_delete": "Eliminare davvero l'articolo?"
|
||||
},
|
||||
"import": {
|
||||
"bulk_link": "Più URL contemporaneamente? → Import multiplo",
|
||||
"form_title": "Importa più articoli",
|
||||
"form_subtitle": "Una URL per riga (o separate da spazi / virgole). Mana le estrae una dopo l'altra in background.",
|
||||
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
|
||||
"count_valid": "{n} valide",
|
||||
"count_overlimit_suffix": " / max {max}",
|
||||
"count_dup": "{n} duplicate (ignorate)",
|
||||
"count_invalid": "{n} non valide",
|
||||
"invalid_details_summary": "Mostra righe non valide ({n})",
|
||||
"error_no_urls": "Aggiungi almeno una URL valida.",
|
||||
"error_overlimit": "Troppe URL ({n}). Massimo {max} per job — dividi l'import.",
|
||||
"error_failed": "Impossibile creare il job.",
|
||||
"submit_label": "Importa {n} URL",
|
||||
"submit_busy": "Creazione del job…",
|
||||
"hint": "Funziona in background — puoi chiudere la scheda e tornare più tardi. 50 URL richiedono circa 5–10 minuti. Lo stato di avanzamento è nella pagina di dettaglio.",
|
||||
"jobs_heading": "Import precedenti",
|
||||
"filter_all": "Tutti ({n})",
|
||||
"filter_active": "Attivi ({n})",
|
||||
"filter_done": "Completati ({n})",
|
||||
"filter_errors": "Con errori ({n})",
|
||||
"empty_filter": "Nessun job in questa vista.",
|
||||
"status_queued": "In attesa",
|
||||
"status_running": "In esecuzione",
|
||||
"status_paused": "In pausa",
|
||||
"status_done": "Completato",
|
||||
"status_cancelled": "Annullato",
|
||||
"jobs_meta_errors": "{n} errori",
|
||||
"jobs_meta_dups": "{n} duplicati",
|
||||
"jobs_meta_warnings": "{n} avvisi",
|
||||
"detail_title": "Job di import",
|
||||
"detail_not_found": "Job non trovato.",
|
||||
"detail_progress_aria": "Avanzamento",
|
||||
"detail_counter_total": "{done} / {total} elaborati",
|
||||
"detail_counter_saved": "{n} salvati",
|
||||
"detail_counter_dups": "{n} duplicati",
|
||||
"detail_counter_warns": "{n} con cookie wall",
|
||||
"detail_counter_errors": "{n} errori",
|
||||
"action_pause": "Pausa",
|
||||
"action_resume": "Riprendi",
|
||||
"action_cancel": "Annulla",
|
||||
"action_retry": "Riprova errori",
|
||||
"action_delete": "Elimina",
|
||||
"confirm_cancel": "Annullare davvero il job? Gli articoli già salvati rimangono.",
|
||||
"confirm_delete": "Eliminare la cronologia del job? Gli articoli rimangono.",
|
||||
"consent_hint_strong": "Cookie wall rilevato",
|
||||
"consent_hint_body": "{n, plural, one {# articolo ha} other {# articoli hanno}} salvato solo la finestra di consenso ai cookie (il server non vede cookie). Soluzione:",
|
||||
"consent_hint_link": "bookmarklet HTML del browser",
|
||||
"consent_hint_after_link": "dalla scheda dove hai già accettato — sovrascrive il teaser con l'articolo reale.",
|
||||
"item_pending": "In attesa",
|
||||
"item_extracting": "Estrazione…",
|
||||
"item_extracted": "Server pronto",
|
||||
"item_saved": "✓ Salvato",
|
||||
"item_duplicate": "· Duplicato",
|
||||
"item_consent_wall": "⚠ Cookie wall",
|
||||
"item_error": "✗ Errore",
|
||||
"item_cancelled": "Annullato",
|
||||
"item_action_view_teaser": "Vedi teaser",
|
||||
"item_action_rescue": "Salva di nuovo",
|
||||
"item_action_rescue_tip": "Salva di nuovo con il bookmarklet — sovrascrive il teaser con l'articolo reale",
|
||||
"item_action_open": "Apri"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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 2–5 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
/**
|
||||
* Articles Bulk-Import — store (mutations only).
|
||||
*
|
||||
* Creates and steers `articleImportJobs` + `articleImportItems`. The
|
||||
* server-side worker (apps/api/src/modules/articles/import-worker.ts)
|
||||
* picks up `state='pending'` items, extracts them, drops Pickup rows
|
||||
* the client-side `consume-pickup.ts` consumer translates into encrypted
|
||||
* `articles` rows.
|
||||
*
|
||||
* Read-side queries live in `queries.ts` (a `useImportJob(id)` /
|
||||
* `useImportItems(jobId)` pair will land alongside the UI in Phase 5).
|
||||
*
|
||||
* Plan: docs/plans/articles-bulk-import.md.
|
||||
*/
|
||||
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { articleImportJobTable, articleImportItemTable } from '../collections';
|
||||
import { parseUrls, type ParsedUrls } from '../parse-urls';
|
||||
import type {
|
||||
ArticleImportItemState,
|
||||
LocalArticleImportItem,
|
||||
LocalArticleImportJob,
|
||||
} from '../types';
|
||||
|
||||
// Re-export so call sites that already imported from `stores/imports`
|
||||
// (BulkImportForm, tools.ts) keep working unchanged.
|
||||
export { parseUrls, type ParsedUrls };
|
||||
|
||||
/**
|
||||
* Hard cap on the URL count per job. The worker can chew through any
|
||||
* number of items, but at very high counts the UI becomes unwieldy
|
||||
* (JobDetailView is a flat list, no virtualisation yet) and the
|
||||
* worst-case wall-clock duration climbs into the multi-hour range
|
||||
* (50 URLs ≈ 5–10 min at concurrency 3, scales linearly). 200 is a
|
||||
* pragmatic ceiling — real reading-list dumps from Pocket exports
|
||||
* average 50–150 items.
|
||||
*/
|
||||
export const MAX_URLS_PER_JOB = 200;
|
||||
|
||||
export const articleImportsStore = {
|
||||
/**
|
||||
* Create a job with N items, all in state='pending'. Returns the
|
||||
* job id so the caller can navigate to `/articles/import/[jobId]`.
|
||||
*
|
||||
* No URL validation here — `parseUrls` is the canonical entry, and
|
||||
* the UI calls it for live feedback before submit. We accept a
|
||||
* pre-cleaned string array so this method stays trivially testable.
|
||||
*/
|
||||
async createJob(urls: readonly string[]): Promise<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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (10–500 Zeichen)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'string',
|
||||
description: 'Highlight-Farbe',
|
||||
required: false,
|
||||
enum: ['yellow', 'green', 'blue', 'pink'],
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'string',
|
||||
description: 'Optionale Notiz zum Highlight',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const id = String(params.articleId ?? '').trim();
|
||||
const text = String(params.text ?? '').trim();
|
||||
const color = (params.color as HighlightColor | undefined) ?? 'yellow';
|
||||
const note = typeof params.note === 'string' ? params.note.trim() || null : null;
|
||||
|
||||
if (!id) return { success: false, message: 'articleId fehlt' };
|
||||
if (text.length < MIN_HIGHLIGHT_TEXT) {
|
||||
return { success: false, message: `Text zu kurz (min ${MIN_HIGHLIGHT_TEXT} Zeichen)` };
|
||||
}
|
||||
if (text.length > MAX_HIGHLIGHT_TEXT) {
|
||||
return { success: false, message: `Text zu lang (max ${MAX_HIGHLIGHT_TEXT} Zeichen)` };
|
||||
}
|
||||
|
||||
const existing = await scopedGet<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,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue