feat(articles): finale polish — Help-Eintrag + intentional-console + 5-Locale i18n (#16,#10,#17)

#16 — Help-content entry for articles
   The articles module had no entry in `app-registry/help-content.ts`
   so the (?) icon in ModuleShell rendered an empty body. Added
   description + 9 features + 4 tips covering Reader-View, Highlights,
   Bookmarklet, Share-Target, and the new bulk-import flow with the
   "Erneut speichern" rescue path for cookie-walled hits.

#10 — Console statements marked intentional
   The 5 console.log/warn/error calls in import-worker.ts (boot,
   tick errors, GC summary, stale-recovery sweep) were ESLint
   warnings. They're intentional operational logs — same pattern as
   services/mana-ai/src/cron/tick.ts. Added file-level
   `/* eslint-disable no-console */` with a comment explaining the
   pattern + that structured signal lives in Prometheus counters.

#17 — Full 5-locale i18n for the bulk-import UI
   New `articles.import` namespace with 50 keys covering the
   BulkImportForm, JobsList, JobDetailView, and AddUrlForm bulk-link.
   All five locales translated by hand:
     - de.json (canonical, mirrors the original hardcoded German)
     - en.json (English)
     - fr.json (French — bookmarklet → "bookmarklet HTML du navigateur")
     - it.json (Italian — bookmarklet → "bookmarklet HTML del browser")
     - es.json (Spanish — bookmarklet → "bookmarklet HTML del navegador")
   Plural-aware `consent_hint_body` uses ICU plural format
   (`{n, plural, one {…} other {…}}`) so single-vs-multiple article
   counts read naturally in each language.
   The consent-hint sentence is split into 3 keys (body/link/after-link)
   so the link text appears mid-sentence rather than tacked on after.
   Components converted to `$_('articles.import.*')` everywhere — no
   remaining hardcoded strings in the bulk-import UI.

i18n parity validator: 76 namespaces × 5 locales — 6477 canonical
keys, all aligned. validate:i18n-hardcoded baseline unchanged for
articles files (broadcasts/notes/timeline failures are user-WIP).

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-06 13:39:50 +02:00
parent fc1cde9d22
commit cb9d79d2c9
11 changed files with 443 additions and 67 deletions

View file

@ -22,6 +22,12 @@
* 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,

View file

@ -1016,4 +1016,25 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
'Eigene API-Keys unter "🔑 API-Keys" — überschreiben den Server-Key und kosten dich keine Credits',
],
},
articles: {
description:
'Pocket-Style Read-it-Later — speichere Web-Artikel zum späteren Lesen. URLs werden serverseitig per Readability extrahiert, landen verschlüsselt in deiner IndexedDB und sind danach offline lesbar im Reader-View. Mit Highlights, Tags und Reading-Progress.',
features: [
'URL einfügen → Server extrahiert via Readability → verschlüsselt gespeichert',
'Reader-View mit Serif/Sans-Auswahl, Light/Sepia/Dark, Schriftgröße',
'Highlights mit 4 Farben + optionalen Notizen pro Selektion',
'Tags aus dem globalen Tag-Pool',
'Reading-Progress wird automatisch beim Scrollen gespeichert',
'Bookmarklet (URL + HTML-Variante für Cookie-gewallte Seiten)',
'Share-Target auf Android/Chromium PWA',
'Bulk-Import: Mehrere URLs auf einmal über /articles/import — Server arbeitet im Hintergrund, Tab-Close-resistent, Multi-Device-sichtbar',
'AI-Tools: Artikel speichern, archivieren, taggen, Highlight setzen, Bulk-Import starten',
],
tips: [
'Cookie-Wand erkannt? → /articles/settings → "Browser-HTML-Bookmarklet" benutzt deine bestehende Browser-Session',
'Mehrere URLs gleichzeitig? → /articles/import — eine pro Zeile oder durch Komma getrennt, max 200 pro Job',
'Im Bulk-Import-Detail zeigt jede Cookie-Wand-Zeile einen "Erneut speichern"-Link der direkt zum Bookmarklet-Flow springt',
'Teaser stellt sich raus dass der Server nur den Cookie-Banner extrahiert hat? → einfach mit dem HTML-Bookmarklet überschreiben — die Article-ID bleibt',
],
},
};

View file

@ -34,5 +34,67 @@
"open_original": "Original-Seite öffnen",
"delete_label": "Artikel löschen",
"confirm_delete": "Artikel wirklich löschen?"
},
"import": {
"bulk_link": "Mehrere URLs auf einmal? → Bulk-Import",
"form_title": "Mehrere Artikel importieren",
"form_subtitle": "Eine URL pro Zeile (oder durch Leerzeichen / Komma getrennt). Mana extrahiert sie nacheinander im Hintergrund.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} gültig",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} doppelt (übersprungen)",
"count_invalid": "{n} ungültig",
"invalid_details_summary": "Ungültige Zeilen anzeigen ({n})",
"error_no_urls": "Mindestens eine gültige URL einfügen.",
"error_overlimit": "Zu viele URLs ({n}). Maximal {max} pro Job — splitte den Import.",
"error_failed": "Job konnte nicht erstellt werden.",
"submit_label": "{n} URLs importieren",
"submit_busy": "Erstelle Job…",
"hint": "Im Hintergrund — du kannst den Tab schließen und später zurückkommen. Bei 50 URLs dauert es grob 510 Minuten. Den Fortschritt siehst du auf der Detailseite.",
"jobs_heading": "Bisherige Imports",
"filter_all": "Alle ({n})",
"filter_active": "Aktiv ({n})",
"filter_done": "Fertig ({n})",
"filter_errors": "Mit Fehlern ({n})",
"empty_filter": "Keine Jobs in dieser Ansicht.",
"status_queued": "Wartet",
"status_running": "Läuft",
"status_paused": "Pausiert",
"status_done": "Fertig",
"status_cancelled": "Abgebrochen",
"jobs_meta_errors": "{n} Fehler",
"jobs_meta_dups": "{n} Duplikate",
"jobs_meta_warnings": "{n} Warnungen",
"detail_title": "Import-Job",
"detail_not_found": "Job nicht gefunden.",
"detail_progress_aria": "Fortschritt",
"detail_counter_total": "{done} / {total} verarbeitet",
"detail_counter_saved": "{n} gespeichert",
"detail_counter_dups": "{n} Duplikate",
"detail_counter_warns": "{n} mit Cookie-Wand",
"detail_counter_errors": "{n} Fehler",
"action_pause": "Pause",
"action_resume": "Fortsetzen",
"action_cancel": "Abbrechen",
"action_retry": "Fehler wiederholen",
"action_delete": "Löschen",
"confirm_cancel": "Job wirklich abbrechen? Bisherige Artikel bleiben gespeichert.",
"confirm_delete": "Job-Historie löschen? Artikel bleiben.",
"consent_hint_strong": "Cookie-Wand erkannt",
"consent_hint_body": "{n, plural, one {# Artikel hat} other {# Artikel haben}} nur den Cookie-Zustimmungs-Dialog gespeichert (der Server sieht keine Cookies). Lösung:",
"consent_hint_link": "Browser-HTML-Bookmarklet",
"consent_hint_after_link": "aus dem Tab in dem du dem Cookie zugestimmt hast benutzen — überschreibt den Teaser durch den echten Artikel.",
"item_pending": "Wartet",
"item_extracting": "Extrahiert…",
"item_extracted": "Server fertig",
"item_saved": "✓ Gespeichert",
"item_duplicate": "· Duplikat",
"item_consent_wall": "⚠ Cookie-Wand",
"item_error": "✗ Fehler",
"item_cancelled": "Abgebrochen",
"item_action_view_teaser": "Teaser ansehen",
"item_action_rescue": "Erneut speichern",
"item_action_rescue_tip": "Mit Bookmarklet erneut speichern — überschreibt den Teaser durch den echten Artikel",
"item_action_open": "Öffnen"
}
}

View file

@ -34,5 +34,67 @@
"open_original": "Open original page",
"delete_label": "Delete article",
"confirm_delete": "Really delete article?"
},
"import": {
"bulk_link": "Multiple URLs at once? → Bulk import",
"form_title": "Import multiple articles",
"form_subtitle": "One URL per line (or separated by spaces / commas). Mana extracts them one after the other in the background.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} valid",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} duplicate (skipped)",
"count_invalid": "{n} invalid",
"invalid_details_summary": "Show invalid lines ({n})",
"error_no_urls": "Add at least one valid URL.",
"error_overlimit": "Too many URLs ({n}). Maximum {max} per job — split the import.",
"error_failed": "Could not create job.",
"submit_label": "Import {n} URLs",
"submit_busy": "Creating job…",
"hint": "Runs in the background — you can close the tab and come back later. 50 URLs take roughly 510 minutes. Progress is on the detail page.",
"jobs_heading": "Past imports",
"filter_all": "All ({n})",
"filter_active": "Active ({n})",
"filter_done": "Done ({n})",
"filter_errors": "With errors ({n})",
"empty_filter": "No jobs in this view.",
"status_queued": "Queued",
"status_running": "Running",
"status_paused": "Paused",
"status_done": "Done",
"status_cancelled": "Cancelled",
"jobs_meta_errors": "{n} errors",
"jobs_meta_dups": "{n} duplicates",
"jobs_meta_warnings": "{n} warnings",
"detail_title": "Import job",
"detail_not_found": "Job not found.",
"detail_progress_aria": "Progress",
"detail_counter_total": "{done} / {total} processed",
"detail_counter_saved": "{n} saved",
"detail_counter_dups": "{n} duplicates",
"detail_counter_warns": "{n} with cookie wall",
"detail_counter_errors": "{n} errors",
"action_pause": "Pause",
"action_resume": "Resume",
"action_cancel": "Cancel",
"action_retry": "Retry errors",
"action_delete": "Delete",
"confirm_cancel": "Really cancel job? Already-saved articles stay.",
"confirm_delete": "Delete job history? Articles stay.",
"consent_hint_strong": "Cookie wall detected",
"consent_hint_body": "{n, plural, one {# article has} other {# articles have}} only saved the cookie consent dialog (the server sees no cookies). Fix:",
"consent_hint_link": "Browser HTML bookmarklet",
"consent_hint_after_link": "from the tab where you already accepted — overwrites the teaser with the real article.",
"item_pending": "Waiting",
"item_extracting": "Extracting…",
"item_extracted": "Server done",
"item_saved": "✓ Saved",
"item_duplicate": "· Duplicate",
"item_consent_wall": "⚠ Cookie wall",
"item_error": "✗ Error",
"item_cancelled": "Cancelled",
"item_action_view_teaser": "View teaser",
"item_action_rescue": "Save again",
"item_action_rescue_tip": "Save again with the bookmarklet — overwrites the teaser with the real article",
"item_action_open": "Open"
}
}

View file

@ -34,5 +34,67 @@
"open_original": "Abrir página original",
"delete_label": "Eliminar artículo",
"confirm_delete": "¿Eliminar realmente el artículo?"
},
"import": {
"bulk_link": "¿Varias URLs a la vez? → Importación masiva",
"form_title": "Importar varios artículos",
"form_subtitle": "Una URL por línea (o separadas por espacios / comas). Mana las extrae una tras otra en segundo plano.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} válidas",
"count_overlimit_suffix": " / máx {max}",
"count_dup": "{n} duplicadas (omitidas)",
"count_invalid": "{n} inválidas",
"invalid_details_summary": "Mostrar líneas inválidas ({n})",
"error_no_urls": "Añade al menos una URL válida.",
"error_overlimit": "Demasiadas URLs ({n}). Máximo {max} por job — divide la importación.",
"error_failed": "No se pudo crear el job.",
"submit_label": "Importar {n} URLs",
"submit_busy": "Creando job…",
"hint": "Se ejecuta en segundo plano — puedes cerrar la pestaña y volver más tarde. 50 URLs tardan unos 510 minutos. El progreso está en la página de detalle.",
"jobs_heading": "Importaciones anteriores",
"filter_all": "Todos ({n})",
"filter_active": "Activos ({n})",
"filter_done": "Completados ({n})",
"filter_errors": "Con errores ({n})",
"empty_filter": "Ningún job en esta vista.",
"status_queued": "En cola",
"status_running": "En ejecución",
"status_paused": "Pausado",
"status_done": "Completado",
"status_cancelled": "Cancelado",
"jobs_meta_errors": "{n} errores",
"jobs_meta_dups": "{n} duplicados",
"jobs_meta_warnings": "{n} advertencias",
"detail_title": "Job de importación",
"detail_not_found": "Job no encontrado.",
"detail_progress_aria": "Progreso",
"detail_counter_total": "{done} / {total} procesados",
"detail_counter_saved": "{n} guardados",
"detail_counter_dups": "{n} duplicados",
"detail_counter_warns": "{n} con muro de cookies",
"detail_counter_errors": "{n} errores",
"action_pause": "Pausar",
"action_resume": "Reanudar",
"action_cancel": "Cancelar",
"action_retry": "Reintentar errores",
"action_delete": "Eliminar",
"confirm_cancel": "¿Cancelar realmente el job? Los artículos ya guardados permanecen.",
"confirm_delete": "¿Eliminar el historial del job? Los artículos permanecen.",
"consent_hint_strong": "Muro de cookies detectado",
"consent_hint_body": "{n, plural, one {# artículo solo ha} other {# artículos solo han}} guardado el diálogo de consentimiento de cookies (el servidor no ve cookies). Solución:",
"consent_hint_link": "bookmarklet HTML del navegador",
"consent_hint_after_link": "desde la pestaña donde ya aceptaste — sobrescribe el teaser con el artículo real.",
"item_pending": "En espera",
"item_extracting": "Extrayendo…",
"item_extracted": "Servidor listo",
"item_saved": "✓ Guardado",
"item_duplicate": "· Duplicado",
"item_consent_wall": "⚠ Muro de cookies",
"item_error": "✗ Error",
"item_cancelled": "Cancelado",
"item_action_view_teaser": "Ver teaser",
"item_action_rescue": "Guardar de nuevo",
"item_action_rescue_tip": "Guardar de nuevo con el bookmarklet — sobrescribe el teaser con el artículo real",
"item_action_open": "Abrir"
}
}

View file

@ -34,5 +34,67 @@
"open_original": "Ouvrir la page d'origine",
"delete_label": "Supprimer l'article",
"confirm_delete": "Vraiment supprimer l'article ?"
},
"import": {
"bulk_link": "Plusieurs URLs à la fois ? → Import groupé",
"form_title": "Importer plusieurs articles",
"form_subtitle": "Une URL par ligne (ou séparées par des espaces / virgules). Mana les extrait l'une après l'autre en arrière-plan.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} valides",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} doublons (ignorés)",
"count_invalid": "{n} invalides",
"invalid_details_summary": "Afficher les lignes invalides ({n})",
"error_no_urls": "Ajoute au moins une URL valide.",
"error_overlimit": "Trop d'URLs ({n}). Maximum {max} par job — divise l'import.",
"error_failed": "Impossible de créer le job.",
"submit_label": "Importer {n} URLs",
"submit_busy": "Création du job…",
"hint": "S'exécute en arrière-plan — tu peux fermer l'onglet et revenir plus tard. 50 URLs prennent environ 510 minutes. La progression est visible sur la page de détail.",
"jobs_heading": "Imports passés",
"filter_all": "Tous ({n})",
"filter_active": "Actifs ({n})",
"filter_done": "Terminés ({n})",
"filter_errors": "Avec erreurs ({n})",
"empty_filter": "Aucun job dans cette vue.",
"status_queued": "En attente",
"status_running": "En cours",
"status_paused": "En pause",
"status_done": "Terminé",
"status_cancelled": "Annulé",
"jobs_meta_errors": "{n} erreurs",
"jobs_meta_dups": "{n} doublons",
"jobs_meta_warnings": "{n} avertissements",
"detail_title": "Job d'import",
"detail_not_found": "Job introuvable.",
"detail_progress_aria": "Progression",
"detail_counter_total": "{done} / {total} traités",
"detail_counter_saved": "{n} enregistrés",
"detail_counter_dups": "{n} doublons",
"detail_counter_warns": "{n} avec mur de cookies",
"detail_counter_errors": "{n} erreurs",
"action_pause": "Pause",
"action_resume": "Reprendre",
"action_cancel": "Annuler",
"action_retry": "Réessayer les erreurs",
"action_delete": "Supprimer",
"confirm_cancel": "Vraiment annuler le job ? Les articles déjà enregistrés restent.",
"confirm_delete": "Supprimer l'historique du job ? Les articles restent.",
"consent_hint_strong": "Mur de cookies détecté",
"consent_hint_body": "{n, plural, one {# article n'a enregistré} other {# articles n'ont enregistré}} que la boîte de dialogue de consentement (le serveur ne voit aucun cookie). Solution :",
"consent_hint_link": "bookmarklet HTML du navigateur",
"consent_hint_after_link": "depuis l'onglet où tu as déjà accepté — remplace le teaser par l'article réel.",
"item_pending": "En attente",
"item_extracting": "Extraction…",
"item_extracted": "Serveur terminé",
"item_saved": "✓ Enregistré",
"item_duplicate": "· Doublon",
"item_consent_wall": "⚠ Mur de cookies",
"item_error": "✗ Erreur",
"item_cancelled": "Annulé",
"item_action_view_teaser": "Voir le teaser",
"item_action_rescue": "Réenregistrer",
"item_action_rescue_tip": "Réenregistrer avec le bookmarklet — remplace le teaser par l'article réel",
"item_action_open": "Ouvrir"
}
}

View file

@ -34,5 +34,67 @@
"open_original": "Apri pagina originale",
"delete_label": "Elimina articolo",
"confirm_delete": "Eliminare davvero l'articolo?"
},
"import": {
"bulk_link": "Più URL contemporaneamente? → Import multiplo",
"form_title": "Importa più articoli",
"form_subtitle": "Una URL per riga (o separate da spazi / virgole). Mana le estrae una dopo l'altra in background.",
"form_placeholder": "https://example.com/article-1\nhttps://example.com/article-2\n…",
"count_valid": "{n} valide",
"count_overlimit_suffix": " / max {max}",
"count_dup": "{n} duplicate (ignorate)",
"count_invalid": "{n} non valide",
"invalid_details_summary": "Mostra righe non valide ({n})",
"error_no_urls": "Aggiungi almeno una URL valida.",
"error_overlimit": "Troppe URL ({n}). Massimo {max} per job — dividi l'import.",
"error_failed": "Impossibile creare il job.",
"submit_label": "Importa {n} URL",
"submit_busy": "Creazione del job…",
"hint": "Funziona in background — puoi chiudere la scheda e tornare più tardi. 50 URL richiedono circa 510 minuti. Lo stato di avanzamento è nella pagina di dettaglio.",
"jobs_heading": "Import precedenti",
"filter_all": "Tutti ({n})",
"filter_active": "Attivi ({n})",
"filter_done": "Completati ({n})",
"filter_errors": "Con errori ({n})",
"empty_filter": "Nessun job in questa vista.",
"status_queued": "In attesa",
"status_running": "In esecuzione",
"status_paused": "In pausa",
"status_done": "Completato",
"status_cancelled": "Annullato",
"jobs_meta_errors": "{n} errori",
"jobs_meta_dups": "{n} duplicati",
"jobs_meta_warnings": "{n} avvisi",
"detail_title": "Job di import",
"detail_not_found": "Job non trovato.",
"detail_progress_aria": "Avanzamento",
"detail_counter_total": "{done} / {total} elaborati",
"detail_counter_saved": "{n} salvati",
"detail_counter_dups": "{n} duplicati",
"detail_counter_warns": "{n} con cookie wall",
"detail_counter_errors": "{n} errori",
"action_pause": "Pausa",
"action_resume": "Riprendi",
"action_cancel": "Annulla",
"action_retry": "Riprova errori",
"action_delete": "Elimina",
"confirm_cancel": "Annullare davvero il job? Gli articoli già salvati rimangono.",
"confirm_delete": "Eliminare la cronologia del job? Gli articoli rimangono.",
"consent_hint_strong": "Cookie wall rilevato",
"consent_hint_body": "{n, plural, one {# articolo ha} other {# articoli hanno}} salvato solo la finestra di consenso ai cookie (il server non vede cookie). Soluzione:",
"consent_hint_link": "bookmarklet HTML del browser",
"consent_hint_after_link": "dalla scheda dove hai già accettato — sovrascrive il teaser con l'articolo reale.",
"item_pending": "In attesa",
"item_extracting": "Estrazione…",
"item_extracted": "Server pronto",
"item_saved": "✓ Salvato",
"item_duplicate": "· Duplicato",
"item_consent_wall": "⚠ Cookie wall",
"item_error": "✗ Errore",
"item_cancelled": "Annullato",
"item_action_view_teaser": "Vedi teaser",
"item_action_rescue": "Salva di nuovo",
"item_action_rescue_tip": "Salva di nuovo con il bookmarklet — sovrascrive il teaser con l'articolo reale",
"item_action_open": "Apri"
}
}

View file

@ -18,6 +18,7 @@
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';
@ -258,7 +259,7 @@
</div>
<p class="bulk-link">
Mehrere URLs auf einmal? <a href="/articles/import">Bulk-Import →</a>
<a href="/articles/import">{$_('articles.import.bulk_link')}</a>
</p>
{#if (loading || saving) && !error && !preview && !duplicate}

View file

@ -7,6 +7,7 @@
-->
<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('');
@ -19,11 +20,13 @@
async function handleSubmit() {
if (busy) return;
if (parsed.valid.length === 0) {
error = 'Mindestens eine gültige URL einfügen.';
error = $_('articles.import.error_no_urls');
return;
}
if (overLimit) {
error = `Maximal ${MAX_URLS_PER_JOB} URLs pro Job. Splitte den Import in mehrere Jobs.`;
error = $_('articles.import.error_overlimit', {
values: { n: parsed.valid.length, max: MAX_URLS_PER_JOB },
});
return;
}
busy = true;
@ -32,7 +35,7 @@
const jobId = await articleImportsStore.createJob(parsed.valid);
goto(`/articles/import/${jobId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Job konnte nicht erstellt werden.';
error = e instanceof Error ? e.message : $_('articles.import.error_failed');
busy = false;
}
}
@ -40,42 +43,49 @@
<div class="bulk-shell">
<header class="header">
<h1>Mehrere Artikel importieren</h1>
<p class="subtitle">
Eine URL pro Zeile (oder durch Leerzeichen / Komma getrennt). Mana extrahiert sie nacheinander
im Hintergrund.
</p>
<h1>{$_('articles.import.form_title')}</h1>
<p class="subtitle">{$_('articles.import.form_subtitle')}</p>
</header>
<textarea
class="url-area"
bind:value={raw}
placeholder={'https://example.com/article-1\nhttps://example.com/article-2\n…'}
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}>
{parsed.valid.length} gültig{overLimit ? ` / max ${MAX_URLS_PER_JOB}` : ''}
{$_('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">{parsed.duplicates.length} doppelt (übersprungen)</span>
<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">{parsed.invalid.length} ungültig</span>
<span class="counter counter-invalid">
{$_('articles.import.count_invalid', { values: { n: parsed.invalid.length } })}
</span>
{/if}
</div>
{#if overLimit}
<p class="error" role="alert">
Zu viele URLs ({parsed.valid.length}). Maximal {MAX_URLS_PER_JOB} pro Job — splitte den Import.
{$_('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>Ungültige Zeilen anzeigen ({parsed.invalid.length})</summary>
<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>
@ -95,14 +105,15 @@
onclick={handleSubmit}
disabled={busy || parsed.valid.length === 0 || overLimit}
>
{#if busy}Erstelle Job…{:else}{parsed.valid.length} URLs importieren{/if}
{#if busy}
{$_('articles.import.submit_busy')}
{:else}
{$_('articles.import.submit_label', { values: { n: parsed.valid.length } })}
{/if}
</button>
</div>
<p class="hint">
Im Hintergrund — du kannst den Tab schließen und später zurückkommen. Bei 50 URLs dauert es grob
510 Minuten. Den Fortschritt siehst du auf der Detailseite.
</p>
<p class="hint">{$_('articles.import.hint')}</p>
</div>
<style>

View file

@ -9,6 +9,7 @@
-->
<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';
@ -42,21 +43,21 @@
function statePill(state: ArticleImportItemState): { label: string; klass: string } {
switch (state) {
case 'pending':
return { label: 'Wartet', klass: 'pill-pending' };
return { label: $_('articles.import.item_pending'), klass: 'pill-pending' };
case 'extracting':
return { label: 'Extrahiert…', klass: 'pill-extracting' };
return { label: $_('articles.import.item_extracting'), klass: 'pill-extracting' };
case 'extracted':
return { label: 'Server fertig', klass: 'pill-extracted' };
return { label: $_('articles.import.item_extracted'), klass: 'pill-extracted' };
case 'saved':
return { label: '✓ Gespeichert', klass: 'pill-saved' };
return { label: $_('articles.import.item_saved'), klass: 'pill-saved' };
case 'duplicate':
return { label: '· Duplikat', klass: 'pill-dup' };
return { label: $_('articles.import.item_duplicate'), klass: 'pill-dup' };
case 'consent-wall':
return { label: '⚠ Cookie-Wand', klass: 'pill-warn' };
return { label: $_('articles.import.item_consent_wall'), klass: 'pill-warn' };
case 'error':
return { label: '✗ Fehler', klass: 'pill-error' };
return { label: $_('articles.import.item_error'), klass: 'pill-error' };
case 'cancelled':
return { label: 'Abgebrochen', klass: 'pill-cancelled' };
return { label: $_('articles.import.item_cancelled'), klass: 'pill-cancelled' };
}
}
@ -72,29 +73,43 @@
<div class="job-shell">
{#if !job}
<p class="empty">Job nicht gefunden.</p>
<p class="empty">{$_('articles.import.detail_not_found')}</p>
{:else}
{@const j = job}
<header class="header">
<div class="title-row">
<h1>Import-Job</h1>
<h1>{$_('articles.import.detail_title')}</h1>
<span class="status status-{j.status}">{j.status}</span>
</div>
<div class="progress-bar" aria-label="Fortschritt">
<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">
<strong>{totalDone}</strong> / {j.totalUrls} verarbeitet
{$_('articles.import.detail_counter_total', {
values: { done: totalDone, total: j.totalUrls },
})}
</span>
{#if j.savedCount > 0}<span class="counter ok">{j.savedCount} gespeichert</span>{/if}
{#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">{j.duplicateCount} Duplikate</span>
<span class="counter dup">
{$_('articles.import.detail_counter_dups', { values: { n: j.duplicateCount } })}
</span>
{/if}
{#if j.warningCount > 0}
<span class="counter warn">{j.warningCount} mit Cookie-Wand</span>
<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}
{#if j.errorCount > 0}<span class="counter err">{j.errorCount} Fehler</span>{/if}
</div>
<div class="actions">
@ -105,7 +120,7 @@
disabled={busyAction !== null}
onclick={() => withBusy('pause', () => articleImportsStore.pauseJob(jobId))}
>
Pause
{$_('articles.import.action_pause')}
</button>
{/if}
{#if j.status === 'paused'}
@ -115,7 +130,7 @@
disabled={busyAction !== null}
onclick={() => withBusy('resume', () => articleImportsStore.resumeJob(jobId))}
>
Fortsetzen
{$_('articles.import.action_resume')}
</button>
{/if}
{#if j.status === 'running' || j.status === 'queued' || j.status === 'paused'}
@ -124,11 +139,11 @@
class="danger"
disabled={busyAction !== null}
onclick={() => {
if (confirm('Job wirklich abbrechen? Bisherige Artikel bleiben gespeichert.'))
if (confirm($_('articles.import.confirm_cancel')))
void withBusy('cancel', () => articleImportsStore.cancelJob(jobId));
}}
>
Abbrechen
{$_('articles.import.action_cancel')}
</button>
{/if}
{#if j.errorCount > 0}
@ -138,7 +153,7 @@
disabled={busyAction !== null}
onclick={() => withBusy('retry', () => articleImportsStore.retryFailed(jobId))}
>
Fehler wiederholen
{$_('articles.import.action_retry')}
</button>
{/if}
{#if j.status === 'done' || j.status === 'cancelled'}
@ -147,7 +162,7 @@
class="ghost"
disabled={busyAction !== null}
onclick={() => {
if (confirm('Job-Historie löschen? Artikel bleiben.')) {
if (confirm($_('articles.import.confirm_delete'))) {
void withBusy('delete', async () => {
await articleImportsStore.deleteJob(jobId);
goto('/articles/import');
@ -155,7 +170,7 @@
}
}}
>
Löschen
{$_('articles.import.action_delete')}
</button>
{/if}
</div>
@ -163,11 +178,10 @@
{#if j.warningCount > 0}
<aside class="consent-hint" role="note">
<strong>Cookie-Wand erkannt</strong>: {j.warningCount}
{j.warningCount === 1 ? 'Artikel' : 'Artikel'} hat nur den Cookie-Zustimmungs-Dialog gespeichert
(der Server sieht keine Cookies). Mit dem
<a href="/articles/settings">Browser-HTML-Bookmarklet</a> aus dem Tab in dem du dem Cookie zugestimmt
hast überschreibst du den Teaser durch den echten Artikel.
<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}
@ -179,17 +193,21 @@
<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}">Teaser ansehen</a>
<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="Mit Bookmarklet erneut speichern — überschreibt den Teaser durch den echten Artikel"
title={$_('articles.import.item_action_rescue_tip')}
>
Erneut speichern
{$_('articles.import.item_action_rescue')}
</a>
</span>
{:else if item.articleId && (item.state === 'saved' || item.state === 'duplicate')}
<a class="action" href="/articles/{item.articleId}">Öffnen</a>
<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}

View file

@ -6,6 +6,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { useImportJobs } from '../queries';
import type { ArticleImportJob } from '../types';
@ -42,15 +43,15 @@
function statusLabel(s: ArticleImportJob['status']): string {
switch (s) {
case 'queued':
return 'Wartet';
return $_('articles.import.status_queued');
case 'running':
return 'Läuft';
return $_('articles.import.status_running');
case 'paused':
return 'Pausiert';
return $_('articles.import.status_paused');
case 'done':
return 'Fertig';
return $_('articles.import.status_done');
case 'cancelled':
return 'Abgebrochen';
return $_('articles.import.status_cancelled');
}
}
</script>
@ -58,7 +59,7 @@
{#if allJobs.length > 0}
<section class="jobs-list">
<header class="list-header">
<h2>Bisherige Imports</h2>
<h2>{$_('articles.import.jobs_heading')}</h2>
<nav class="filter-tabs" aria-label="Filter">
<button
type="button"
@ -66,7 +67,7 @@
class:tab-active={filter === 'all'}
onclick={() => (filter = 'all')}
>
Alle ({allJobs.length})
{$_('articles.import.filter_all', { values: { n: allJobs.length } })}
</button>
<button
type="button"
@ -75,7 +76,7 @@
onclick={() => (filter = 'active')}
disabled={activeCount === 0}
>
Aktiv ({activeCount})
{$_('articles.import.filter_active', { values: { n: activeCount } })}
</button>
<button
type="button"
@ -84,7 +85,7 @@
onclick={() => (filter = 'done')}
disabled={doneCount === 0}
>
Fertig ({doneCount})
{$_('articles.import.filter_done', { values: { n: doneCount } })}
</button>
<button
type="button"
@ -93,12 +94,12 @@
onclick={() => (filter = 'errors')}
disabled={errorCount === 0}
>
Mit Fehlern ({errorCount})
{$_('articles.import.filter_errors', { values: { n: errorCount } })}
</button>
</nav>
</header>
{#if visibleJobs.length === 0}
<p class="empty-filter">Keine Jobs in dieser Ansicht.</p>
<p class="empty-filter">{$_('articles.import.empty_filter')}</p>
{/if}
<ul>
{#each visibleJobs as job (job.id)}
@ -106,15 +107,23 @@
<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">{job.errorCount} Fehler</span>{/if}
{#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">{job.duplicateCount} Duplikate</span>
<span class="meta-dup">
{$_('articles.import.jobs_meta_dups', { values: { n: job.duplicateCount } })}
</span>
{/if}
{#if job.warningCount > 0}
<span class="meta-warn">{job.warningCount} Warnungen</span>
<span class="meta-warn">
{$_('articles.import.jobs_meta_warnings', { values: { n: job.warningCount } })}
</span>
{/if}
</span>
<span class="when">{new Date(job.createdAt).toLocaleString('de-DE')}</span>
<span class="when">{new Date(job.createdAt).toLocaleString()}</span>
</button>
{/each}
</ul>