mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
fc1cde9d22
commit
cb9d79d2c9
11 changed files with 443 additions and 67 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
5–10 Minuten. Den Fortschritt siehst du auf der Detailseite.
|
||||
</p>
|
||||
<p class="hint">{$_('articles.import.hint')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue