diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1c0f2b2e..6c078b5f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,7 +2,7 @@ name: Tests
on:
push:
- branches: [develop, main]
+ branches: [main]
pull_request:
branches: [develop, main]
workflow_dispatch:
diff --git a/README.md b/README.md
index 3844daa5..5ef60260 100644
--- a/README.md
+++ b/README.md
@@ -67,8 +67,9 @@ Open **http://localhost:8001** and create your account.
- **Point your phone at an ISBN barcode.** Real-time barcode scanning in the browser — no native app required.
- **Cover art from multiple sources.** Automatic search across AbeBooks, Open Library, Amazon, and Hardcover — plus manual upload or URL paste.
- **Full REST API.** OpenAPI-documented backend you can script against — build your own frontend, connect home automation, or pipe data into your own tools.
+- **Third-party integrations.** Display your stats on [Dashy](https://dashy.to/), [Home Assistant](https://www.home-assistant.io/), or [Homepage](https://gethomepage.dev/) dashboards and more. [See all integrations →](https://docs.librislog.app/api/integrations/)
- **Lightweight.** Two Docker containers, one SQLite database.
-- **Bilingual UI.** English and German with a localization framework ready for more languages.
+- **Multi-language UI.** English, German, Spanish, French, and Chinese (Simplified) — with a localization framework ready for more languages.
---
@@ -127,10 +128,10 @@ MIT
## Star History
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py
index 3d1576d0..ec5028b4 100644
--- a/backend/app/routers/statistics.py
+++ b/backend/app/routers/statistics.py
@@ -240,15 +240,41 @@ def get_pages_per_day(
start_date_utc = start_date.astimezone(timezone.utc).replace(tzinfo=None)
end_date_utc = end_date.astimezone(timezone.utc).replace(tzinfo=None)
- progress_entries = list(
+ # Books with at least one progress entry in the window — we need their full
+ # entry chains to compute correct daily averages.
+ book_ids_with_window_progress = set(
session.exec(
- select(ReadingProgress)
+ select(ReadingProgress.book_id)
.where(
ReadingProgress.user_id == current_user.id,
ReadingProgress.created_at >= start_date_utc,
- ReadingProgress.created_at <= end_date_utc,
)
- .order_by(ReadingProgress.book_id, ReadingProgress.created_at)
+ .distinct()
+ ).all()
+ )
+
+ # Load full entry chains for those books (including entries before the window
+ # so that the prev→curr delta and day_diff span are complete).
+ if book_ids_with_window_progress:
+ progress_entries = list(
+ session.exec(
+ select(ReadingProgress)
+ .where(
+ ReadingProgress.user_id == current_user.id,
+ ReadingProgress.book_id.in_(book_ids_with_window_progress),
+ )
+ .order_by(ReadingProgress.book_id, ReadingProgress.created_at)
+ ).all()
+ )
+ else:
+ progress_entries = []
+
+ # All book_ids with *any* progress entry (used to exclude books from fallback).
+ all_book_ids_with_progress = set(
+ session.exec(
+ select(ReadingProgress.book_id)
+ .where(ReadingProgress.user_id == current_user.id)
+ .distinct()
).all()
)
@@ -256,11 +282,9 @@ def get_pages_per_day(
session.exec(select(Book).where(Book.user_id == current_user.id)).all()
)
- books_with_progress = {e.book_id for e in progress_entries}
-
virtual_entries = []
for book in books:
- if book.id not in books_with_progress or not book.date_started:
+ if book.id not in all_book_ids_with_progress or not book.date_started:
continue
# Finished books without date_finished have no bounded reading
# period; skip to avoid spreading pages from date_started to
@@ -281,11 +305,13 @@ def get_pages_per_day(
fallback_books = [
b
for b in books
- if b.id not in books_with_progress
+ if b.id not in all_book_ids_with_progress
and b.reading_status == ReadingStatus.read
and b.date_started
and b.date_finished
and b.page_count
+ # Only include books whose reading period could overlap the window.
+ and _naive_utc(b.date_finished) >= start_date_utc
]
fallback_daily = _extract_book_level_daily_pages(fallback_books, tz, start_date_utc, end_date_utc)
diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py
index 69a5dbad..687ebb11 100644
--- a/backend/tests/test_data.py
+++ b/backend/tests/test_data.py
@@ -315,33 +315,6 @@ def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestCl
assert any("reading_status" in error for error in payload["errors"])
-def test_data_import_execute_deletes_temp_file_after_completion(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
- import_temp_dir = tmp_path / "import_temp"
- monkeypatch.setattr(settings, "import_temp_dir", str(import_temp_dir))
- csv_payload = "Title\nDune\n"
- parse_resp = client.post(
- "/api/data/import/parse",
- files={"file": ("books.csv", csv_payload, "text/csv")},
- )
- assert parse_resp.status_code == 200
- file_id = parse_resp.json()["file_id"]
-
- temp_file = import_temp_dir / "1" / f"{file_id}.json"
- assert temp_file.exists()
-
- execute_resp = client.post(
- "/api/data/import/execute",
- json={
- "file_id": file_id,
- "mapping": {"title": {"source": "Title", "transform": None}},
- "import_mode": "continue_on_error",
- },
- )
- assert execute_resp.status_code == 200
- events = _parse_sse(execute_resp.text)
- assert any(event.get("event") == "complete" for event in events)
- assert not temp_file.exists()
-
def test_data_import_execute_progress_uses_date_finished_for_read_books(
client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path
diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts
index 82298fb8..2023b175 100644
--- a/docs/.vitepress/config.base.ts
+++ b/docs/.vitepress/config.base.ts
@@ -52,6 +52,7 @@ export default defineConfig({
{ text: 'CLI Reference', link: '/guide/cli' },
],
},
+ { text: 'Integrations 🔗', link: '/api/integrations/' },
],
},
{
@@ -75,6 +76,16 @@ export default defineConfig({
items: [
{ text: 'Overview', link: '/api/' },
{ text: 'Headless Setup & API Keys', link: '/api/setup' },
+ {
+ text: 'Integrations',
+ link: '/api/integrations/',
+ collapsed: true,
+ items: [
+ { text: 'Dashy', link: '/api/integrations/dashy' },
+ { text: 'Home Assistant', link: '/api/integrations/homeassistant' },
+ { text: 'Homepage', link: '/api/integrations/homepage' },
+ ],
+ },
],
},
],
diff --git a/docs/about.md b/docs/about.md
index 91d168f3..0b213aa8 100644
--- a/docs/about.md
+++ b/docs/about.md
@@ -11,7 +11,7 @@ LibrisLog is a **multi-user book tracking web application** designed for readers
- **Cover Management**: Automatic cover image scraping from multiple sources with manual override
- **Data Portability**: Export/import library as JSON or CSV. Full backup and restore functionality
- **REST API**: Full API with OpenAPI documentation for programmatic access
-- **Multilingual**: English and German UI support
+- **Multilingual**: English, German, Spanish, French, and Chinese (Simplified) UI support
- **Themes**: Light, dark, and custom DaisyUI themes with persistent preferences
## Technology Stack
diff --git a/docs/api/integrations/dashy.md b/docs/api/integrations/dashy.md
new file mode 100644
index 00000000..73ee033e
--- /dev/null
+++ b/docs/api/integrations/dashy.md
@@ -0,0 +1,137 @@
+# Dashy
+
+LibrisLog can be integrated into [Dashy](https://dashy.to/), a self-hosted
+dashboard for your services, using its
+[HTML embedded widget](https://dashy.to/docs/widgets#html-embedded-widget).
+
+This widget displays your reading statistics as styled stat cards directly on
+your Dashy dashboard.
+
+## Prerequisites
+
+- A running LibrisLog instance reachable from your Dashy server
+- An [API key](/api/integrations/#api-keys) with access to the
+ statistics endpoint
+- **CORS must be configured** — add your Dashy URL to the
+ [`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable
+ of the LibrisLog backend so that the browser can fetch the API directly
+
+## Configuration
+
+Add the following to the Dashy `conf.yml` under the section or item where you
+want the widget to appear:
+
+```yaml
+widgets:
+ - type: embed
+ updateInterval: 300
+ options:
+ html: |
+
+
+ Reading
+ -
+
+
+ Read
+ -
+
+
+ Want to Read
+ -
+
+
+ Total Books
+ -
+
+
+ css: |
+ .librislog-widget {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.75rem;
+ padding: 0.5rem;
+ font-family: inherit;
+ }
+ .ll-stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: var(--background-elevated, rgba(255,255,255,0.05));
+ border: 1px solid var(--outline-color, rgba(255,255,255,0.1));
+ border-radius: 6px;
+ padding: 0.5rem;
+ text-align: center;
+ }
+ .ll-label {
+ font-size: 0.8rem;
+ opacity: 0.7;
+ color: var(--text-color, #fff);
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+ .ll-value {
+ font-size: 1.4rem;
+ font-weight: bold;
+ color: var(--primary, #00bc8c);
+ }
+ script: |
+ (async function() {
+ const apiUrl = '/api/books/stats';
+ const apiKey = '';
+
+ try {
+ const response = await fetch(apiUrl, {
+ method: 'GET',
+ headers: {
+ 'X-API-Key': apiKey,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) throw new Error('API request failed');
+
+ const data = await response.json();
+
+ document.getElementById('ll-reading').innerText = data.books_currently_reading ?? data.books_reading ?? 0;
+ document.getElementById('ll-read').innerText = data.books_read ?? 0;
+ document.getElementById('ll-wtr').innerText = data.books_want_to_read ?? 0;
+ document.getElementById('ll-total').innerText = data.total_books ?? 0;
+
+ } catch (error) {
+ console.error('LibrisLog Widget Error:', error);
+ const elements = ['ll-reading', 'll-read', 'll-wtr', 'll-total'];
+ elements.forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.innerText = '!';
+ if (el) el.style.color = 'var(--danger, #ff0033)';
+ });
+ }
+ })();
+```
+
+Replace the placeholders with your own values:
+
+| Placeholder | Example | Description |
+|---|---|---|
+| `` | `http://192.168.1.100:8000` | The base URL of your LibrisLog instance (http or https) |
+| `` | `lk_nRHsF3jxIBDa9u....` | An API key with access to the statistics endpoint |
+
+The `updateInterval` is specified in seconds. `300` equals 5 minutes.
+
+## CORS
+
+Since the widget runs inside the Dashy iframe and fetches the LibrisLog API
+directly from the browser, you must add your Dashy URL to the
+[`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable of
+the LibrisLog backend. For example:
+
+```
+CORS_ORIGINS=["https://dashy.YOUR-DOMAIN"]
+```
+
+## Result
+
+
diff --git a/docs/api/integrations/homeassistant.md b/docs/api/integrations/homeassistant.md
new file mode 100644
index 00000000..d0c267f8
--- /dev/null
+++ b/docs/api/integrations/homeassistant.md
@@ -0,0 +1,83 @@
+# Home Assistant
+
+LibrisLog can be integrated into [Home Assistant](https://www.home-assistant.io/),
+a popular open-source home automation platform, using its
+[RESTful integration](https://www.home-assistant.io/integrations/rest/).
+
+This integration creates sensors that expose your LibrisLog reading statistics
+directly in Home Assistant.
+
+## Prerequisites
+
+- A running LibrisLog instance reachable from your Home Assistant server
+- An [API key](/api/integrations/#api-keys) with access to the
+ statistics endpoint
+
+## Configuration
+
+Add the following to your Home Assistant `configuration.yaml`:
+
+```yaml
+rest:
+ - resource: /api/books/stats
+ method: GET
+ headers:
+ X-API-Key: ""
+ Content-Type: application/json
+ scan_interval: 300
+ sensor:
+ - name: "Total Books"
+ unique_id: librislog_total_books
+ value_template: "{{ value_json.total_books }}"
+ icon: mdi:bookshelf
+ unit_of_measurement: "Books"
+ - name: "Books Read"
+ unique_id: librislog_books_read
+ value_template: "{{ value_json.books_read }}"
+ icon: mdi:book-check
+ unit_of_measurement: "Books"
+ - name: "Books Currently Reading"
+ unique_id: librislog_books_currently_reading
+ value_template: "{{ value_json.books_reading }}"
+ icon: mdi:book-open-page-variant
+ unit_of_measurement: "Books"
+ - name: "Want to Read"
+ unique_id: librislog_books_want_to_read
+ value_template: "{{ value_json.books_want_to_read }}"
+ icon: mdi:bookmark-plus
+ unit_of_measurement: "Books"
+
+ - resource: /api/statistics
+ method: GET
+ headers:
+ X-API-Key: ""
+ Content-Type: application/json
+ scan_interval: 600
+ sensor:
+ - name: "Average Rating"
+ unique_id: librislog_average_rating
+ value_template: "{{ value_json.average_rating | round(2) if value_json.average_rating is not none else 'N/A' }}"
+ icon: mdi:star
+ unit_of_measurement: "★"
+ - name: "Average Page Count"
+ unique_id: librislog_average_page_count
+ value_template: "{{ value_json.avg_page_count | round(0) if value_json.avg_page_count is not none else 'N/A' }}"
+ icon: mdi:file-document-multiple
+ unit_of_measurement: "Pages"
+```
+
+Replace the placeholders with your own values:
+
+| Placeholder | Example | Description |
+|---|---|---|
+| `` | `http://192.168.1.100:8000` | The base URL of your LibrisLog instance (http or https) |
+| `` | `lk_nRHsF3jxIBDa9u....` | An API key with access to the statistics endpoint |
+
+The `scan_interval` is specified in seconds. The first resource polls every
+5 minutes, the second every 10 minutes.
+
+## Result
+
+
+
+
diff --git a/docs/api/integrations/homepage.md b/docs/api/integrations/homepage.md
new file mode 100644
index 00000000..a4feefa2
--- /dev/null
+++ b/docs/api/integrations/homepage.md
@@ -0,0 +1,60 @@
+# Homepage
+
+LibrisLog can be integrated into [Homepage](https://gethomepage.dev/), a
+modern dashboard for your self-hosted services, using its
+[custom API widget](https://gethomepage.dev/widgets/services/customapi/).
+
+This widget displays your reading statistics directly on your Homepage
+dashboard.
+
+## Prerequisites
+
+- A running LibrisLog instance reachable from your Homepage server
+- An [API key](/api/integrations/#api-keys) with access to the
+ statistics endpoint
+
+## Configuration
+
+Add the following entry to your Homepage `services.yaml`:
+
+```yaml
+- librislog:
+ icon: mdi-book-heart
+ href:
+ siteMonitor:
+ widget:
+ type: customapi
+ url: /api/books/stats
+ method: GET
+ headers:
+ X-API-Key: ""
+ refreshInterval: 300000
+ display: block
+ mappings:
+ - field: books_read
+ label: Read
+ format: number
+ - field: books_reading
+ label: Reading
+ format: number
+ - field: books_want_to_read
+ label: Want to read
+ format: number
+ - field: total_books
+ label: Total
+ format: number
+```
+
+Replace the placeholders with your own values:
+
+| Placeholder | Example | Description |
+|---|---|---|
+| `` | `http://192.168.1.100:8000` | The base URL of your LibrisLog instance (http or https) |
+| `` | `lk_nRHsF3jxIBDa9u....` | An API key with access to the statistics endpoint |
+
+The `refreshInterval` is specified in milliseconds. `300000` ms equals 5
+minutes.
+
+## Result
+
+
diff --git a/docs/api/integrations/index.md b/docs/api/integrations/index.md
new file mode 100644
index 00000000..75017507
--- /dev/null
+++ b/docs/api/integrations/index.md
@@ -0,0 +1,28 @@
+# Integrations
+
+LibrisLog exposes a full REST API that third-party applications can use to
+display your reading data, automate workflows, or build custom dashboards.
+
+All integrations below are backed by the LibrisLog API. You will need an
+API key to use them. You can create one either:
+
+
+
+- **Via the web UI** — go to your [Profile](/api/#creating-an-api-key)
+ page and click "Create API Key".
+- **Via the API** — see the
+ [Headless Setup](/api/setup#3-create-an-api-key) guide for a
+ CLI-based workflow.
+
+## Available Integrations
+
+- [Dashy](/api/integrations/dashy) — Display your LibrisLog statistics as
+ styled stat cards on a [Dashy](https://dashy.to/) dashboard using the HTML
+ embedded widget.
+- [Home Assistant](/api/integrations/homeassistant) — Expose your LibrisLog
+ reading statistics as sensors in
+ [Home Assistant](https://www.home-assistant.io/) using the RESTful
+ integration.
+- [Homepage](/api/integrations/homepage) — Display your LibrisLog statistics
+ on a [Homepage](https://gethomepage.dev/) dashboard using the custom API
+ widget.
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index 9851a3d5..b5078d83 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -58,7 +58,7 @@ All configuration is done via environment variables in a `.env` file at the proj
| Variable | Description | Default |
|----------|-------------|---------|
-| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en` or `de`) | `en` |
+| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en`, `de`, `zh`, `es`, or `fr`) | `en` |
## Import Limits
diff --git a/docs/guide/developer-setup.md b/docs/guide/developer-setup.md
index 9c545887..2f7e2bbd 100644
--- a/docs/guide/developer-setup.md
+++ b/docs/guide/developer-setup.md
@@ -20,7 +20,7 @@ When building, you can override these arguments:
|----------|-------------|---------|
| `APP_VERSION` | Application version string | `v0.0.0-dev` |
| `GIT_SHA` | Git commit hash for version display | `unknown` |
-| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en` or `de`) | `en` |
+| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en`, `de`, `zh`, `es`, or `fr`) | `en` |
Example:
diff --git a/docs/public/screenshots/integrations-dashy.png b/docs/public/screenshots/integrations-dashy.png
new file mode 100644
index 00000000..10c36b1f
Binary files /dev/null and b/docs/public/screenshots/integrations-dashy.png differ
diff --git a/docs/public/screenshots/integrations-homeassistant-1.png b/docs/public/screenshots/integrations-homeassistant-1.png
new file mode 100644
index 00000000..babced8e
Binary files /dev/null and b/docs/public/screenshots/integrations-homeassistant-1.png differ
diff --git a/docs/public/screenshots/integrations-homeassistant-2.png b/docs/public/screenshots/integrations-homeassistant-2.png
new file mode 100644
index 00000000..9b39ab50
Binary files /dev/null and b/docs/public/screenshots/integrations-homeassistant-2.png differ
diff --git a/docs/public/screenshots/integrations-homepage.png b/docs/public/screenshots/integrations-homepage.png
new file mode 100644
index 00000000..3cc13252
Binary files /dev/null and b/docs/public/screenshots/integrations-homepage.png differ
diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts
index 151d1e33..b72c01e3 100644
--- a/frontend/src/lib/i18n/index.ts
+++ b/frontend/src/lib/i18n/index.ts
@@ -1,7 +1,7 @@
import { addMessages, init, locale, register, waitLocale, _ } from 'svelte-i18n';
import { api } from '$lib/api';
-export const SUPPORTED_LOCALES = ['en', 'de'] as const;
+export const SUPPORTED_LOCALES = ['en', 'de', 'zh', 'es', 'fr'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
const DEFAULT_LOCALE: AppLocale = 'en';
@@ -12,6 +12,9 @@ const configuredDefaultLocale: AppLocale = isSupportedLocale(envLocale) ? envLoc
register('en', () => import('./locales/en.json'));
register('de', () => import('./locales/de.json'));
+register('zh', () => import('./locales/zh.json'));
+register('es', () => import('./locales/es.json'));
+register('fr', () => import('./locales/fr.json'));
addMessages('en', {});
diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json
index 61befc0b..66b80150 100644
--- a/frontend/src/lib/i18n/locales/de.json
+++ b/frontend/src/lib/i18n/locales/de.json
@@ -278,7 +278,10 @@
},
"languages": {
"en": "Englisch",
- "de": "Deutsch"
+ "de": "Deutsch",
+ "zh": "Chinesisch",
+ "es": "Spanisch",
+ "fr": "Französisch"
},
"auth": {
"login": "Anmelden",
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index f091803f..cd1162ad 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -278,7 +278,10 @@
},
"languages": {
"en": "English",
- "de": "German"
+ "de": "German",
+ "zh": "Chinese",
+ "es": "Spanish",
+ "fr": "French"
},
"auth": {
"login": "Login",
diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json
new file mode 100644
index 00000000..d51595c3
--- /dev/null
+++ b/frontend/src/lib/i18n/locales/es.json
@@ -0,0 +1,650 @@
+{
+ "app": {
+ "title": "LibrisLog",
+ "addBook": "Añadir libro",
+ "add": "Añadir",
+ "language": "Idioma"
+ },
+ "nav": {
+ "dashboard": "Panel",
+ "library": "Biblioteca",
+ "timeline": "Cronología",
+ "statistics": "Estadísticas",
+ "data": "Datos",
+ "want_to_read": "Quiero leer",
+ "currently_reading": "Leyendo",
+ "read": "Leído",
+ "did_not_finish": "Abandonado"
+ },
+ "statistics": {
+ "title": "Estadísticas",
+ "subtitle": "Información sobre tu viaje lector",
+ "avgBooksPerMonth": "Prom. libros/mes",
+ "busiestMonth": "Mes más activo",
+ "avgPageCount": "Prom. páginas/libro",
+ "mostPopularLanguage": "Idioma más frecuente",
+ "languageDistribution": "Libros por idioma",
+ "statusDistribution": "Libros por estado",
+ "pageBuckets": "Estadísticas de páginas",
+ "pagesToRead": "Páginas por leer",
+ "pagesRead": "Páginas leídas",
+ "pagesWasted": "Páginas desperdiciadas",
+ "pagesWastedFootnote": "\"Páginas desperdiciadas\" = página máxima alcanzada en libros marcados como \"No terminado\"",
+ "pagesReadPerMonth": "Páginas leídas por mes",
+ "booksFinishedPerMonth": "Libros terminados por mes",
+ "booksFinishedPerYear": "Libros terminados por año",
+ "topAuthors": "Autores destacados",
+ "rankedNumber": "#{rank}",
+ "coversForAuthor": "Portadas de {author}",
+ "booksCount": "{count} {count, plural, one {libro} other {libros}}",
+ "unknownLanguage": "Desconocido",
+ "pagesReadCalendar": "Actividad lectora (últimos 365 días)",
+ "noCalendarData": "No hay datos de lectura para el último año",
+ "pagesOver": "páginas en",
+ "daysLabel": "días",
+ "avgPerDay": "Prom. por día activo:",
+ "avgPerDayAll": "Prom. por día (365 días):",
+ "pagesPerDay": "páginas/día",
+ "loading": "Cargando estadísticas...",
+ "noData": "Aún no hay datos. ¡Empieza a leer y registrar libros para ver estadísticas!",
+ "resetZoom": "Restablecer zoom",
+ "sectionDistributions": "Distribuciones",
+ "sectionCharts": "Tendencias de lectura",
+ "sectionActivity": "Actividad",
+ "ratingStats": "Estadísticas de valoración",
+ "booksWithRating": "Libros valorados",
+ "booksWithoutRating": "Libros sin valorar",
+ "averageRating": "Valoración media",
+ "noRating": "Sin valoración",
+ "topRated": "Mejor valorados",
+ "worstRated": "Peor valorados",
+ "showMore": "Mostrar más"
+ },
+ "dashboard": {
+ "title": "Panel de lectura",
+ "subtitle": "Una visión rápida de tu viaje lector",
+ "quoteTitle": "Cita del día",
+ "quoteUnavailable": "No hay ninguna cita disponible ahora.",
+ "totalBooks": "Total en biblioteca",
+ "booksRead": "Libros leídos",
+ "booksToRead": "Libros por leer",
+ "currentlyReading": "Leyendo ahora",
+ "nextToRead": "Siguiente lectura",
+ "viewAll": "Ver todo",
+ "searchAllBooks": "Buscar todos los libros",
+ "noSearchResults": "No se encontraron libros",
+ "noCurrentlyReading": "No estás leyendo ningún libro actualmente.",
+ "noNextToRead": "Aún no hay libros en tu lista de deseos.",
+ "popularTags": "Etiquetas populares"
+ },
+ "status": {
+ "want_to_read": "Quiero leer",
+ "currently_reading": "Leyendo",
+ "read": "Leído",
+ "did_not_finish": "Abandonado"
+ },
+ "common": {
+ "search": "Buscar",
+ "searchBooks": "Buscar libros...",
+ "result": "resultado",
+ "results": "resultados",
+ "save": "Guardar",
+ "saved": "Guardado",
+ "saveFailed": "Error al guardar",
+ "edit": "Editar",
+ "cancel": "Cancelar",
+ "confirm": "¿Confirmar?",
+ "delete": "Eliminar",
+ "deleting": "Eliminando...",
+ "back": "Volver",
+ "loadMore": "Cargar más",
+ "syncing": "Sincronizando...",
+ "noBooksYet": "Aún no hay libros aquí.",
+ "addFirstBook": "Añade tu primer libro",
+ "dateAdded": "Fecha de incorporación",
+ "rating": "Valoración",
+ "ratingSaved": "Valoración guardada",
+ "desc": "Desc.",
+ "asc": "Asc.",
+ "close": "Cerrar",
+ "clearForm": "Limpiar formulario",
+ "remove": "Quitar",
+ "copy": "Copiar",
+ "copied": "Copiado",
+ "showPassword": "Mostrar contraseña",
+ "required": "Obligatorio",
+ "saving": "Guardando...",
+ "loadingEllipsis": "...",
+ "starLabel": "{star} {star, plural, one {estrella} other {estrellas}}",
+ "clickToRate": "Haz clic en una estrella para valorar",
+ "actionFailed": "{action} falló",
+ "readMore": "Leer más",
+ "readLess": "Mostrar menos",
+ "serverStarting": "El servidor está iniciándose...",
+ "serverStartingDesc": "Por favor, espera mientras el servidor termina de arrancar."
+ },
+ "book": {
+ "title": "Título",
+ "subtitle": "Subtítulo",
+ "author": "Autor",
+ "status": "Estado",
+ "isbn": "ISBN",
+ "publisher": "Editorial",
+ "year": "Año",
+ "pages": "Páginas",
+ "language": "Idioma",
+ "tags": "Etiquetas",
+ "tagsPlaceholder": "Escribe una etiqueta y pulsa Enter o coma",
+ "tagsHint": "Pulsa Enter o coma para añadir etiquetas. Backspace elimina la última.",
+ "notes": "Notas",
+ "blurb": "Descripción",
+ "about": "Acerca de",
+ "dateStarted": "Fecha de inicio",
+ "dateFinished": "Fecha de finalización",
+ "cover": "Portada",
+ "coverForAuthor": "Portada {index} de {author}",
+ "googleCovers": "Portadas de Google",
+ "autoSearchCovers": "Buscar portadas automáticamente",
+ "autoSearchInfo": "Haz clic en una portada para importarla como portada de este libro.",
+ "autoSearchNoCandidates": "No se encontraron candidatos para este ISBN.",
+ "autoSearchError": "Error al buscar portadas automáticamente.",
+ "autoSearchLoading": "Buscando fuentes de portadas...",
+ "autoSearchMetaUnknown": "Tamaño/resolución desconocidos",
+ "autoSearchMeta": "{size} - {resolution}",
+ "autoSearchSourceLabel": "Fuente: {source}",
+ "coverOf": "Portada de {title}",
+ "openDetailsHint": "Haz clic para ver detalles",
+ "readingProgress": "Progreso de lectura",
+ "currentPage": "Página",
+ "progressLog": "Registro de progreso",
+ "progressLogEmpty": "Aún no hay entradas de progreso.",
+ "setPageCountFirst": "Establece el total de páginas primero.",
+ "logDate": "Fecha",
+ "logPage": "Página",
+ "deleteEntry": "Eliminar",
+ "deleteEntryConfirm": "¿Eliminar esta entrada?",
+ "editEntry": "Editar",
+ "saveEntry": "Guardar",
+ "progressGraph": "Progreso en el tiempo",
+ "progressPromptTitle": "¿Establecer progreso de lectura?",
+ "progressPromptMessage": "¿Establecer el progreso de \"{title}\" al 100%?",
+ "progressPromptSet": "Establecer al 100%",
+ "progressPromptSkip": "Saltar"
+ },
+ "addModal": {
+ "manual": "Manual",
+ "searchImport": "Buscar e importar",
+ "adding": "Añadiendo...",
+ "failedAdd": "Error al añadir libro",
+ "importFromFile": "Importar desde archivo"
+ },
+ "import": {
+ "searchByTitleOrAuthor": "Buscar por título o autor...",
+ "enterIsbn": "Introduce el ISBN...",
+ "noResultsYet": "Aún sin resultados",
+ "noBooksFound": "No se encontraron libros",
+ "alreadyImported": "Ya importado",
+ "imported": "Importado",
+ "googleToo": "Buscar también en Google Books",
+ "googleSearching": "Buscando en Google Books...",
+ "googleAdded": "Resultados de Google Books añadidos: {count}",
+ "scan": "Escanear",
+ "scanIsbn": "Escanear código de barras ISBN",
+ "importFailed": "Importación fallida",
+ "searchFailed": "Búsqueda fallida",
+ "scannedIsbn": "ISBN escaneado: {isbn}",
+ "or": "o",
+ "sourceHardcoverSearching": "Buscando en Hardcover...",
+ "sourceHardcoverSkipped": "Hardcover omitido (sin token de API configurado)",
+ "sourceSkipped": "Google Books omitido (sin clave de API configurada)",
+ "sourceOpenLibrarySearching": "Buscando en Open Library...",
+ "sourceGoogleSearching": "Buscando en Google Books...",
+ "sourceBackendError": "Error de backend de {source} (revisa los registros del servidor)",
+ "sourceError": "Búsqueda fallida: {message}",
+ "resultCount": "{source} - {count} resultado{suffix}"
+ },
+ "scanner": {
+ "title": "Escanear código de barras ISBN",
+ "help": "Apunta la cámara al código de barras de un libro. La búsqueda comienza automáticamente tras encontrar un ISBN válido.",
+ "startError": "No se pudo iniciar el escáner. Comprueba los permisos de la cámara.",
+ "noCamera": "No se encontró ningún dispositivo de cámara.",
+ "close": "Cerrar escáner"
+ },
+ "coverPicker": {
+ "dropzone": "Arrastra una imagen aquí, o",
+ "browse": "examinar",
+ "pasteUrl": "O pega una URL de imagen...",
+ "useUrl": "Usar URL",
+ "urlInvalid": "No se pudo cargar la portada desde la URL. Verifica el enlace.",
+ "uploadFailed": "Subida fallida",
+ "previewAlt": "Vista previa de portada"
+ },
+ "toasts": {
+ "dismiss": "Descartar",
+ "newVersion": "Una nueva versión ({version}) está disponible.",
+ "reload": "Recargar"
+ },
+ "settings": {
+ "title": "Ajustes",
+ "languageTitle": "Idioma",
+ "timezone": "Zona horaria",
+ "timezoneHelp": "Mostrar fechas y horas en tu zona horaria local.",
+ "timezoneDetected": "Detectada: {tz}",
+ "timezoneSelected": "Seleccionada: {tz}",
+ "timezoneInvalid": "Selecciona una zona horaria válida de la lista.",
+ "themeTitle": "Tema",
+ "themeLight": "Claro",
+ "themeDark": "Oscuro",
+ "themeCustom": "Personalizar",
+ "themeSelect": "Selecciona un tema personalizado",
+ "timezonePlaceholder": "Buscar zona horaria...",
+ "apiDocsTitle": "Documentación de la API",
+ "apiDocsHelp": "Explora y prueba los endpoints del backend directamente desde la app.",
+ "apiDocsViewLabel": "Ver",
+ "apiDocsLoading": "Cargando documentación de la API",
+ "apiDocsFrameTitle": "Documentación de la API",
+ "apiDocsOpenNewTab": "Abrir documentación en una nueva pestaña"
+ },
+ "sort": {
+ "smart": "Orden inteligente"
+ },
+ "dateConflict": {
+ "started": {
+ "title": "Fecha de inicio ya establecida",
+ "message": "La fecha de inicio ya está establecida. ¿Quieres conservar {oldDate} o establecer {newDate} como nueva fecha de inicio?",
+ "keepOld": "Conservar {oldDate}",
+ "useNew": "Usar {newDate}"
+ },
+ "finished": {
+ "title": "Fecha de finalización ya establecida",
+ "message": "La fecha de finalización ya está establecida. ¿Quieres conservar {oldDate} o establecer {newDate} como nueva fecha de finalización?",
+ "keepOld": "Conservar {oldDate}",
+ "useNew": "Usar {newDate}"
+ },
+ "startedAfterFinished": {
+ "title": "El libro ya estaba terminado",
+ "message": "Este libro se terminó el {finishedDate}. ¿Qué deberíamos hacer?",
+ "keepFinished": "Conservar fecha de finalización",
+ "clearAndStart": "Borrar fecha de finalización y empezar hoy",
+ "keepDesc": "Conserva la fecha de finalización ({finishedDate}) y no establece una fecha de inicio.",
+ "clearDesc": "Elimina la fecha de finalización y establece hoy ({newStartDate}) como fecha de inicio."
+ }
+ },
+ "search": {
+ "resultsCount": "{count, plural, one {resultado} other {resultados}} encontrados",
+ "noResults": "No se encontraron resultados",
+ "noResultsFor": "No se encontraron resultados para \"{query}\"",
+ "tryDifferentQuery": "Prueba con un término de búsqueda diferente"
+ },
+ "languages": {
+ "en": "Inglés",
+ "de": "Alemán",
+ "zh": "Chino",
+ "es": "Español",
+ "fr": "Francés"
+ },
+ "auth": {
+ "login": "Iniciar sesión",
+ "firstname": "Nombre",
+ "lastname": "Apellido",
+ "email": "Correo electrónico",
+ "password": "Contraseña",
+ "loginFailed": "Inicio de sesión fallido",
+ "setupTitle": "Crear cuenta de administrador",
+ "setupFailed": "Configuración fallida",
+ "createAdmin": "Crear administrador",
+ "invalidEmailError": "Introduce una dirección de correo electrónico válida",
+ "passwordComplexityError": "La contraseña no cumple los requisitos de complejidad"
+ },
+ "user": {
+ "menu": "Menú de usuario",
+ "profile": "Perfil",
+ "about": "Acerca de",
+ "theme": "Tema",
+ "logout": "Cerrar sesión",
+ "apiKeys": "Claves de API",
+ "keyDescription": "Descripción (opcional)",
+ "addKey": "Añadir clave",
+ "newKeyShownOnce": "Copia esta clave ahora. Solo se muestra una vez",
+ "noDescription": "Sin descripción",
+ "newPassword": "Nueva contraseña"
+ },
+ "admin": {
+ "title": "Administración",
+ "tabs": {
+ "users": "Usuarios",
+ "backup": "Copia de seguridad y restauración"
+ },
+ "newUser": "Crear usuario",
+ "existingUsers": "Usuarios existentes",
+ "role": "Rol",
+ "create": "Crear",
+ "editing": "Editando usuario",
+ "edit": "Editar",
+ "saveChanges": "Guardar cambios",
+ "cancelEdit": "Cancelar edición",
+ "deleteConfirmTitle": "¿Realmente quieres eliminar este usuario?",
+ "deleteConfirmBody": "Esta acción no se puede deshacer.",
+ "cannotChangeOwnRole": "No puedes cambiar tu propio rol de administrador.",
+ "requiredFieldError": "Rellena todos los campos obligatorios.",
+ "selfDeleteHint": "Para eliminar tu propia cuenta, usa Perfil > Zona de peligro.",
+ "backup": {
+ "title": "Copia de seguridad",
+ "description": "Descarga una copia completa de tu biblioteca, incluyendo libros, portadas y datos.",
+ "download": "Descargar copia",
+ "success": "Copia descargada correctamente",
+ "failed": "Error al descargar la copia",
+ "inProgress": "Creando copia de seguridad..."
+ },
+ "restore": {
+ "title": "Restaurar",
+ "description": "Restaura tu biblioteca desde un archivo de copia de seguridad anterior.",
+ "warning": "Advertencia: La restauración reemplazará TODOS los datos actuales. Asegúrate de tener una copia reciente antes de continuar.",
+ "upload": "Subir y restaurar",
+ "success": "Restauración completada. Se restauraron {books} libros.",
+ "failed": "Error al restaurar",
+ "inProgress": "Restaurando copia de seguridad...",
+ "validationFailed": "No se pudo validar el archivo de copia",
+ "invalidBackup": "Estructura de copia de seguridad no válida",
+ "confirmTitle": "Confirmar restauración",
+ "confirmBody": "¿Estás seguro de que quieres restaurar desde esta copia? Esto reemplazará todos los datos actuales y no se puede deshacer.",
+ "confirmWarning": "Esta acción es irreversible. Todos los datos actuales se perderán.",
+ "confirm": "Restaurar ahora",
+ "backupDate": "Fecha de copia",
+ "backupVersion": "Versión de la app",
+ "coversCount": "Portadas"
+ }
+ },
+ "password": {
+ "requirementsTitle": "Requisitos de contraseña",
+ "minLength": "Al menos 8 caracteres",
+ "uppercase": "Al menos una mayúscula",
+ "lowercase": "Al menos una minúscula",
+ "number": "Al menos un número",
+ "special": "Al menos un carácter especial",
+ "strongEnough": "Suficientemente segura",
+ "notReady": "No cumple los requisitos"
+ },
+ "error": {
+ "isbnAlreadyExists": "Este ISBN ya está siendo usado por otro libro.",
+ "dateInFuture": "La fecha no puede ser futura.",
+ "dateStartedAfterFinished": "La fecha de inicio no puede ser posterior a la de finalización.",
+ "dateFinishedRequiredForRead": "Un libro terminado debe tener una fecha de fin. Cambia el estado si quieres eliminar la fecha de finalización.",
+ "invalidLanguageCode": "El idioma debe ser un código ISO de 2 letras (por ejemplo: EN, DE, FR).",
+ "invalidConfirmationPhrase": "La frase de confirmación no coincide.",
+ "cannotDeleteLastAdmin": "No se puede eliminar la cuenta: eres el último administrador",
+ "cannotDeleteOwnAccountHere": "No puedes eliminar tu propia cuenta aquí. Usa Perfil > Zona de peligro.",
+ "importMalformedEvent": "Se recibió un evento de servidor malformado durante la importación.",
+ "importUnsupportedContentType": "Tipo de contenido no admitido. Usa archivos CSV o JSON.",
+ "emailAlreadyRegistered": "Esta dirección de correo ya está registrada.",
+ "userNotFound": "Usuario no encontrado.",
+ "cannotChangeOwnRole": "No puedes cambiar tu propio rol de administrador.",
+ "authorRequired": "El autor es obligatorio.",
+ "pageCountRequired": "El número de páginas es obligatorio.",
+ "importTempFileCreateFailed": "No se pudo crear el archivo temporal de importación. Inténtalo de nuevo.",
+ "fileTooLarge": "El archivo es demasiado grande. Prueba con un archivo más pequeño o revisa los límites del servidor.",
+ "exportNoDatasets": "Selecciona al menos un conjunto de datos para exportar.",
+ "batchUpdateFailed": "La actualización por lotes falló debido a un error inesperado. No se guardaron cambios.",
+ "tooManyBooksSelected": "Demasiados libros seleccionados. Selecciona como máximo {max} a la vez.",
+ "importMappingNameConflict": "Ya existe una asignación con este nombre.",
+ "importMappingNotFound": "Asignación de importación no encontrada.",
+ "importFileNotFound": "Archivo de importación no encontrado. Vuelve a subir el archivo."
+ },
+ "oidc": {
+ "orContinueWith": "o continuar con",
+ "loginWithProvider": "Continuar con {provider}",
+ "profileTitle": "Inicio de sesión único",
+ "notLinked": "Tu cuenta aún no está vinculada.",
+ "linkButton": "Vincular cuenta de {provider}",
+ "unlinkButton": "Desvincular cuenta",
+ "linkedAs": "Vinculado con {provider}",
+ "linkSuccess": "Cuenta vinculada correctamente",
+ "linkStartFailed": "No se pudo iniciar la vinculación",
+ "unlinkSuccess": "Cuenta desvinculada",
+ "unlinkFailed": "No se pudo desvincular la cuenta",
+ "signingIn": "Iniciando sesión...",
+ "linkingAccount": "Vinculando cuenta..."
+ },
+ "profile": {
+ "sectionNav": "En esta página",
+ "profileSaveSuccess": "Perfil guardado",
+ "profileSaveFailed": "Error al guardar el perfil",
+ "passwordChangeSuccess": "Contraseña cambiada",
+ "passwordChangeFailed": "Error al cambiar la contraseña",
+ "dataManagement": {
+ "title": "Gestionar mis datos",
+ "description": "Exporta tu biblioteca o importa libros desde un archivo CSV/JSON.",
+ "link": "Importar / Exportar",
+ "missingCoversDescription": "Asigna rápidamente portadas faltantes con sugerencias automáticas.",
+ "missingCoversLink": "Gestionar portadas faltantes"
+ },
+ "dangerZone": {
+ "title": "Zona de peligro",
+ "subtitle": "Acciones irreversibles que eliminan permanentemente tus datos o cuenta.",
+ "resetData": {
+ "title": "Restablecer todos los datos personales",
+ "description": "Elimina todos tus libros, etiquetas y progreso de lectura, pero conserva tu cuenta y ajustes.",
+ "warning": "Esto no se puede deshacer.",
+ "placeholder": "Escribe la frase de confirmación",
+ "hint": "Escribe exactamente: DELETE ALL MY DATA",
+ "confirmationPhrase": "DELETE ALL MY DATA",
+ "button": "Restablecer todos los datos",
+ "success": "Se eliminaron {books} libros, {tags} etiquetas y {entries} entradas de progreso.",
+ "failed": "Error al restablecer datos"
+ },
+ "deleteAccount": {
+ "title": "Eliminar cuenta",
+ "description": "Elimina tu cuenta y todos los datos asociados de forma permanente.",
+ "warning": "Esto es permanente y no se puede deshacer.",
+ "placeholder": "Escribe la frase de confirmación",
+ "hint": "Escribe exactamente: DELETE MY ACCOUNT",
+ "confirmationPhrase": "DELETE MY ACCOUNT",
+ "button": "Eliminar mi cuenta",
+ "success": "Cuenta eliminada. Redirigiendo al inicio de sesión...",
+ "failed": "Error al eliminar la cuenta",
+ "lastAdminError": "No se puede eliminar la cuenta: eres el último administrador"
+ }
+ }
+ },
+ "timeline": {
+ "title": "Cronología de lectura",
+ "subtitle": "Una vista cronológica de los libros que has terminado",
+ "viewInLibrary": "Ver todo en la biblioteca",
+ "noReadBooks": "Aún no hay libros terminados en tu biblioteca.",
+ "goToLibrary": "Ir a la biblioteca"
+ },
+ "data": {
+ "title": "Gestión de datos",
+ "subtitle": "Importa y exporta los datos de tu biblioteca personal",
+ "tabs": {
+ "export": "Exportar",
+ "import": "Importar"
+ },
+ "export": {
+ "title": "Exportar",
+ "description": "Elige conjuntos de datos y formato, luego descarga un archivo ZIP.",
+ "datasets": {
+ "books": "Libros",
+ "progress": "Progreso de lectura",
+ "tags": "Etiquetas",
+ "covers": "Archivos de portada"
+ },
+ "button": "Exportar datos",
+ "exporting": "Exportando...",
+ "success": "Exportación lista. Descarga iniciada.",
+ "errors": {
+ "noDatasets": "Selecciona al menos un conjunto de datos.",
+ "failed": "Exportación fallida."
+ }
+ },
+ "import": {
+ "title": "Importar",
+ "description": "Sube un archivo CSV o JSON, asigna campos, valida y luego importa.",
+ "parse": "Analizar archivo",
+ "parsing": "Analizando...",
+ "fileSummary": "Filas: {rows}, campos: {fields}",
+ "mappingTitle": "Asignación de campos",
+ "mappingActionsTitle": "Gestionar asignaciones",
+ "mappingName": "Nombre de asignación",
+ "loadSavedMapping": "Asignaciones guardadas",
+ "noSavedMappings": "Aún no hay asignaciones guardadas. Guarda la asignación actual para reutilizarla después.",
+ "missingFieldsTitle": "Algunos campos de origen de la asignación guardada no están presentes en este archivo:",
+ "missingFieldEntry": "{target} ← {source}",
+ "selectMapping": "Seleccionar asignación guardada",
+ "loadMapping": "Cargar asignación",
+ "readonlyMapping": "solo lectura",
+ "deleteMapping": "Eliminar asignación",
+ "deleteMappingTitle": "Eliminar asignación guardada",
+ "showPreview": "Mostrar vista previa de asignación",
+ "createProgressForRead": "Crear entrada de progreso al 100% para libros importados como 'Leído'",
+ "hidePreview": "Ocultar vista previa de asignación",
+ "previewNoMappedFields": "Aún no hay campos asignados. Asigna campos de origen a campos de destino para previsualizar valores.",
+ "transformLabel": "Transformación (Python)",
+ "transformPlaceholder": "p. ej. value.upper()",
+ "previewTitle": "Vista previa",
+ "previewButton": "Generar",
+ "previewLoading": "Generando...",
+ "previewStale": "La vista previa está desactualizada",
+ "previewRow": "Fila {row}",
+ "errorRow": "Fila {row}",
+ "previewSource": "Origen",
+ "previewTransformed": "Transformado",
+ "none": "(ninguno)",
+ "requiredField": "= campo obligatorio",
+ "changeFile": "Cambiar archivo",
+ "coverUrlHint": "Espera una URL HTTP(S) a una imagen. No se admiten rutas de archivo locales ni datos base64.",
+ "transformHelp": "Parámetros disponibles y ejemplos",
+ "transformHelpValue": "El valor original del campo de origen asignado",
+ "transformHelpRow": "Todos los campos de origen como diccionario, p. ej. row['title']",
+ "transformHelpContext": "Diccionario de contexto con número de fila y total (context['row_num'], context['total_rows'])",
+ "transformHelpReturn": "Las expresiones simples se devuelven automáticamente; usa return explícito para código multilínea",
+ "transformHelpImports": "Importaciones de Python disponibles: datetime, re, json, math",
+ "transformError": "La regla de transformación para {field} no es válida: {error}",
+ "saveMapping": "Guardar asignación",
+ "refreshMappings": "Actualizar asignaciones",
+ "mappingSaved": "Asignación guardada",
+ "mappingDeleted": "Asignación eliminada",
+ "mappingMissingFields": "La asignación cargada tiene {count} campos de origen faltantes.",
+ "validationTitle": "Simulación",
+ "simulate": "Simular",
+ "validating": "Validando...",
+ "validationOk": "Validación superada.",
+ "validationNotOk": "La validación encontró problemas.",
+ "rollbackAll": "Revertir todo si hay error",
+ "continueOnError": "Continuar si hay error",
+ "importNow": "Importar ahora",
+ "importing": "Importando...",
+ "cancelled": "Importación cancelada.",
+ "confirmImportTitle": "¿Iniciar importación?",
+ "confirmDestructive": "Esto escribe datos en tu biblioteca y no se puede deshacer automáticamente.",
+ "deleteMappingConfirm": "¿Eliminar esta asignación guardada?",
+ "dropzone": "Arrastra y suelta un archivo CSV/JSON, o",
+ "browse": "examinar",
+ "fileInputLabel": "Elegir archivo CSV o JSON",
+ "showLess": "Mostrar menos",
+ "showAllIssues": "Mostrar todos los problemas ({count})",
+ "showAllFailures": "Mostrar todas las filas fallidas ({count})",
+ "completed": "Importación completada. Importados: {imported}, fallidos: {failed}",
+ "errors": {
+ "parseFailed": "Error al analizar el archivo.",
+ "saveMappingFailed": "Error al guardar la asignación.",
+ "deleteMappingFailed": "Error al eliminar la asignación.",
+ "loadMappingsFailed": "Error al cargar las asignaciones.",
+ "loadMappingFailed": "Error al cargar la asignación.",
+ "validateFailed": "Validación fallida.",
+ "previewFailed": "Error al cargar la vista previa.",
+ "executeFailed": "Importación fallida."
+ }
+ }
+ },
+ "dataHygiene": {
+ "authorRequired": "El autor no puede estar vacío.",
+ "pageCountPositive": "El número de páginas debe ser mayor que 0.",
+ "title": "Higiene de datos",
+ "description": "Encuentra y corrige libros con metadatos faltantes en tu biblioteca.",
+ "attributes": {
+ "author": "Autor",
+ "isbn": "ISBN",
+ "publisher": "Editorial",
+ "published_year": "Año",
+ "blurb": "Descripción",
+ "language": "Idioma",
+ "subtitle": "Subtítulo",
+ "page_count": "Nº de páginas",
+ "cover_url": "Portada"
+ },
+ "matchAny": "Coincidir cualquiera",
+ "matchAll": "Coincidir todos",
+ "noMissingBooks": "No se encontraron libros con atributos faltantes que coincidan con tus filtros.",
+ "total": "{count} {count, plural, one {libro} other {libros}} encontrados",
+ "loadMore": "Cargar más",
+ "loading": "Comprobando tu biblioteca...",
+ "selectAll": "Seleccionar todo",
+ "deselectAll": "Deseleccionar todo",
+ "nSelected": "{count} {count, plural, one {libro} other {libros}} seleccionados",
+ "batchEditTitle": "Edición por lotes",
+ "batchFieldLabel": "Campo a actualizar",
+ "batchFieldPlaceholder": "Selecciona un campo...",
+ "batchValueLabel": "Nuevo valor",
+ "batchValuePlaceholder": "Introduce el nuevo valor",
+ "applyBatch": "Aplicar a seleccionados",
+ "confirmTitle": "¿Actualizar {count} {count, plural, one {libro} other {libros}}?",
+ "confirmBody": "Esto establecerá \"{field}\" a \"{value}\" para los siguientes libros:",
+ "confirmApply": "Aplicar actualización",
+ "confirmCancel": "Cancelar",
+ "success": "{updated} {updated, plural, one {libro} other {libros}} actualizados. {skipped} ya tenían este valor.",
+ "updateFailed": "Error en la actualización por lotes.",
+ "loadFailed": "Error al cargar los datos.",
+ "tooManySelected": "Selecciona como máximo 500 libros a la vez.",
+ "noAttributeSelected": "Selecciona al menos un atributo para buscar.",
+ "noFieldSelected": "Selecciona un campo para actualizar.",
+ "noValueEntered": "Introduce un valor para establecer.",
+ "sectionFilters": "Filtros",
+ "sectionResults": "Resultados",
+ "showingCount": "Mostrando {shown} de {total} libros",
+ "allSet": "¡Tu biblioteca está en excelente estado! Todos los libros tienen metadatos completos.",
+ "allSetFiltered": "¡Tu biblioteca está en excelente estado! Todos los libros tienen metadatos completos para los atributos seleccionados.",
+ "tableHeaderMissing": "Faltante",
+ "remaining": "restantes",
+ "andXMore": "...y {count} más"
+ },
+ "about": {
+ "title": "Acerca de LibrisLog",
+ "description": "Una aplicación web de seguimiento de libros para gestionar tus listas de lectura, importar libros desde fuentes en línea y realizar un seguimiento de tu progreso lector, todo a través de un panel moderno.",
+ "author": "Autor",
+ "version": "Versión",
+ "technologies": "Tecnologías utilizadas",
+ "thankYou": "Agradecimientos",
+ "thankYouText": "LibrisLog no existiría sin las increíbles bibliotecas y frameworks de código abierto sobre los que se construye. Nuestro agradecimiento a todos los desarrolladores que contribuyen a estos proyectos.",
+ "frontend": "Frontend",
+ "backend": "Backend",
+ "devTools": "Herramientas de desarrollo",
+ "documentation": "Documentación"
+ },
+ "missingCovers": {
+ "title": "Gestionar portadas faltantes",
+ "header": "{count} {count, plural, one {libro} other {libros}} sin portada",
+ "bookInfo": "{title} de {author}",
+ "isbnLabel": "ISBN: {isbn}",
+ "noIsbn": "Este libro no tiene ISBN. La búsqueda automática de portadas no está disponible.",
+ "noCandidates": "No se pudo determinar ninguna portada automáticamente.",
+ "searchGoogle": "Buscar portada en Google",
+ "searchGoogleAria": "Abrir búsqueda de imágenes de Google para este libro en una nueva pestaña",
+ "manualUrlLabel": "O pega una URL de imagen de portada",
+ "manualUrlPlaceholder": "https://example.com/cover.jpg",
+ "manualUrlSave": "Guardar portada",
+ "manualUrlInvalid": "Introduce una URL HTTP(S) válida",
+ "manualUrlNotHttps": "Advertencia: la URL no es HTTPS. Prefiere HTTPS por seguridad.",
+ "skip": "Saltar",
+ "skipAria": "Saltar este libro e ir al siguiente",
+ "coverSaved": "Portada guardada",
+ "coverSaveFailed": "Error al guardar la portada",
+ "allDone": "¡Todos los libros tienen portada! Buen trabajo.",
+ "allDoneSub": "Todos los libros de tu biblioteca tienen ahora una imagen de portada.",
+ "loadingBook": "Cargando siguiente libro...",
+ "loadingCandidates": "Buscando fuentes de portadas...",
+ "keyboardHint": "Consejo: pulsa 1\u20139 para seleccionar una portada, \u2192 para saltar",
+ "candidatesError": "Búsqueda de portadas fallida. Aún puedes usar la importación manual.",
+ "retry": "Reintentar"
+ }
+}
diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json
new file mode 100644
index 00000000..cfbc08c5
--- /dev/null
+++ b/frontend/src/lib/i18n/locales/fr.json
@@ -0,0 +1,650 @@
+{
+ "app": {
+ "title": "LibrisLog",
+ "addBook": "Ajouter un livre",
+ "add": "Ajouter",
+ "language": "Langue"
+ },
+ "nav": {
+ "dashboard": "Tableau de bord",
+ "library": "Bibliothèque",
+ "timeline": "Chronologie",
+ "statistics": "Statistiques",
+ "data": "Données",
+ "want_to_read": "À lire",
+ "currently_reading": "En cours",
+ "read": "Lu",
+ "did_not_finish": "Abandonné"
+ },
+ "statistics": {
+ "title": "Statistiques",
+ "subtitle": "Aperçu de ton parcours de lecture",
+ "avgBooksPerMonth": "Moy. livres/mois",
+ "busiestMonth": "Mois le plus actif",
+ "avgPageCount": "Moy. pages/livre",
+ "mostPopularLanguage": "Langue la plus fréquente",
+ "languageDistribution": "Livres par langue",
+ "statusDistribution": "Livres par statut",
+ "pageBuckets": "Statistiques de pages",
+ "pagesToRead": "Pages à lire",
+ "pagesRead": "Pages lues",
+ "pagesWasted": "Pages gaspillées",
+ "pagesWastedFootnote": "\"Pages gaspillées\" = page maximale atteinte pour les livres marqués \"Abandonné\"",
+ "pagesReadPerMonth": "Pages lues par mois",
+ "booksFinishedPerMonth": "Livres terminés par mois",
+ "booksFinishedPerYear": "Livres terminés par an",
+ "topAuthors": "Auteurs populaires",
+ "rankedNumber": "#{rank}",
+ "coversForAuthor": "Couvertures de {author}",
+ "booksCount": "{count} {count, plural, one {livre} other {livres}}",
+ "unknownLanguage": "Inconnue",
+ "pagesReadCalendar": "Activité de lecture (365 derniers jours)",
+ "noCalendarData": "Aucune donnée de lecture pour l'année écoulée",
+ "pagesOver": "pages sur",
+ "daysLabel": "jours",
+ "avgPerDay": "Moy. par jour actif :",
+ "avgPerDayAll": "Moy. par jour (365 jours) :",
+ "pagesPerDay": "pages/jour",
+ "loading": "Chargement des statistiques...",
+ "noData": "Aucune donnée disponible. Commence à lire et à enregistrer des livres pour voir les statistiques !",
+ "resetZoom": "Réinitialiser le zoom",
+ "sectionDistributions": "Répartitions",
+ "sectionCharts": "Tendances de lecture",
+ "sectionActivity": "Activité",
+ "ratingStats": "Statistiques d'évaluation",
+ "booksWithRating": "Livres évalués",
+ "booksWithoutRating": "Livres non évalués",
+ "averageRating": "Note moyenne",
+ "noRating": "Aucune note",
+ "topRated": "Les mieux notés",
+ "worstRated": "Les moins bien notés",
+ "showMore": "Afficher plus"
+ },
+ "dashboard": {
+ "title": "Tableau de bord de lecture",
+ "subtitle": "Un aperçu rapide de ton parcours de lecture",
+ "quoteTitle": "Citation du jour",
+ "quoteUnavailable": "Aucune citation disponible pour le moment.",
+ "totalBooks": "Total dans la bibliothèque",
+ "booksRead": "Livres lus",
+ "booksToRead": "Livres à lire",
+ "currentlyReading": "En cours de lecture",
+ "nextToRead": "À lire ensuite",
+ "viewAll": "Voir tout",
+ "searchAllBooks": "Rechercher dans tous les livres",
+ "noSearchResults": "Aucun livre trouvé",
+ "noCurrentlyReading": "Tu ne lis aucun livre actuellement.",
+ "noNextToRead": "Aucun livre dans ta liste d'envies.",
+ "popularTags": "Étiquettes populaires"
+ },
+ "status": {
+ "want_to_read": "À lire",
+ "currently_reading": "En cours",
+ "read": "Lu",
+ "did_not_finish": "Abandonné"
+ },
+ "common": {
+ "search": "Rechercher",
+ "searchBooks": "Rechercher des livres...",
+ "result": "résultat",
+ "results": "résultats",
+ "save": "Enregistrer",
+ "saved": "Enregistré",
+ "saveFailed": "Échec de l'enregistrement",
+ "edit": "Modifier",
+ "cancel": "Annuler",
+ "confirm": "Confirmer ?",
+ "delete": "Supprimer",
+ "deleting": "Suppression...",
+ "back": "Retour",
+ "loadMore": "Charger plus",
+ "syncing": "Synchronisation...",
+ "noBooksYet": "Aucun livre ici pour le moment.",
+ "addFirstBook": "Ajoute ton premier livre",
+ "dateAdded": "Date d'ajout",
+ "rating": "Note",
+ "ratingSaved": "Note enregistrée",
+ "desc": "Déc.",
+ "asc": "Asc.",
+ "close": "Fermer",
+ "clearForm": "Effacer le formulaire",
+ "remove": "Retirer",
+ "copy": "Copier",
+ "copied": "Copié",
+ "showPassword": "Afficher le mot de passe",
+ "required": "Requis",
+ "saving": "Enregistrement...",
+ "loadingEllipsis": "...",
+ "starLabel": "{star} {star, plural, one {étoile} other {étoiles}}",
+ "clickToRate": "Clique sur une étoile pour noter",
+ "actionFailed": "{action} a échoué",
+ "readMore": "Lire la suite",
+ "readLess": "Afficher moins",
+ "serverStarting": "Le serveur démarre...",
+ "serverStartingDesc": "Veuillez patienter pendant le démarrage du serveur."
+ },
+ "book": {
+ "title": "Titre",
+ "subtitle": "Sous-titre",
+ "author": "Auteur",
+ "status": "Statut",
+ "isbn": "ISBN",
+ "publisher": "Éditeur",
+ "year": "Année",
+ "pages": "Pages",
+ "language": "Langue",
+ "tags": "Étiquettes",
+ "tagsPlaceholder": "Saisis une étiquette et appuie sur Entrée ou virgule",
+ "tagsHint": "Appuie sur Entrée ou virgule pour ajouter des étiquettes. Retour arrière supprime la dernière.",
+ "notes": "Notes",
+ "blurb": "Description",
+ "about": "À propos",
+ "dateStarted": "Date de début",
+ "dateFinished": "Date de fin",
+ "cover": "Couverture",
+ "coverForAuthor": "Couverture {index} de {author}",
+ "googleCovers": "Couvertures Google",
+ "autoSearchCovers": "Rechercher des couvertures",
+ "autoSearchInfo": "Clique sur une couverture pour l'importer comme couverture de ce livre.",
+ "autoSearchNoCandidates": "Aucune couverture trouvée pour cet ISBN.",
+ "autoSearchError": "Échec de la recherche automatique de couvertures.",
+ "autoSearchLoading": "Recherche de sources de couvertures...",
+ "autoSearchMetaUnknown": "Taille/résolution inconnue",
+ "autoSearchMeta": "{size} - {resolution}",
+ "autoSearchSourceLabel": "Source : {source}",
+ "coverOf": "Couverture de {title}",
+ "openDetailsHint": "Clique pour ouvrir les détails",
+ "readingProgress": "Progression de lecture",
+ "currentPage": "Page",
+ "progressLog": "Journal de progression",
+ "progressLogEmpty": "Aucune entrée de progression.",
+ "setPageCountFirst": "Définis d'abord le nombre total de pages.",
+ "logDate": "Date",
+ "logPage": "Page",
+ "deleteEntry": "Supprimer",
+ "deleteEntryConfirm": "Supprimer cette entrée ?",
+ "editEntry": "Modifier",
+ "saveEntry": "Enregistrer",
+ "progressGraph": "Progression dans le temps",
+ "progressPromptTitle": "Définir la progression ?",
+ "progressPromptMessage": "Définir la progression de \"{title}\" à 100 % ?",
+ "progressPromptSet": "Définir à 100 %",
+ "progressPromptSkip": "Passer"
+ },
+ "addModal": {
+ "manual": "Manuel",
+ "searchImport": "Rechercher et importer",
+ "adding": "Ajout...",
+ "failedAdd": "Échec de l'ajout du livre",
+ "importFromFile": "Importer depuis un fichier"
+ },
+ "import": {
+ "searchByTitleOrAuthor": "Rechercher par titre ou auteur...",
+ "enterIsbn": "Saisis l'ISBN...",
+ "noResultsYet": "Aucun résultat",
+ "noBooksFound": "Aucun livre trouvé",
+ "alreadyImported": "Déjà importé",
+ "imported": "Importé",
+ "googleToo": "Rechercher aussi sur Google Books",
+ "googleSearching": "Recherche sur Google Books...",
+ "googleAdded": "Résultats Google Books ajoutés : {count}",
+ "scan": "Scanner",
+ "scanIsbn": "Scanner le code-barres ISBN",
+ "importFailed": "Échec de l'importation",
+ "searchFailed": "Échec de la recherche",
+ "scannedIsbn": "ISBN scanné : {isbn}",
+ "or": "ou",
+ "sourceHardcoverSearching": "Recherche sur Hardcover...",
+ "sourceHardcoverSkipped": "Hardcover ignoré (aucun jeton API configuré)",
+ "sourceSkipped": "Google Books ignoré (aucune clé API configurée)",
+ "sourceOpenLibrarySearching": "Recherche sur Open Library...",
+ "sourceGoogleSearching": "Recherche sur Google Books...",
+ "sourceBackendError": "Erreur backend {source} (vérifie les journaux du serveur)",
+ "sourceError": "Échec de la recherche : {message}",
+ "resultCount": "{source} - {count} résultat{suffix}"
+ },
+ "scanner": {
+ "title": "Scanner un code-barres ISBN",
+ "help": "Dirige la caméra vers le code-barres d'un livre. La recherche démarre automatiquement après avoir trouvé un ISBN valide.",
+ "startError": "Impossible de démarrer le scanner. Vérifie les autorisations de la caméra.",
+ "noCamera": "Aucun appareil photo trouvé.",
+ "close": "Fermer le scanner"
+ },
+ "coverPicker": {
+ "dropzone": "Glisse et dépose une image ici, ou",
+ "browse": "parcourir",
+ "pasteUrl": "Ou colle une URL d'image...",
+ "useUrl": "Utiliser l'URL",
+ "urlInvalid": "Impossible de charger la couverture depuis l'URL. Vérifie le lien.",
+ "uploadFailed": "Échec du téléversement",
+ "previewAlt": "Aperçu de la couverture"
+ },
+ "toasts": {
+ "dismiss": "Ignorer",
+ "newVersion": "Une nouvelle version ({version}) est disponible.",
+ "reload": "Recharger"
+ },
+ "settings": {
+ "title": "Paramètres",
+ "languageTitle": "Langue",
+ "timezone": "Fuseau horaire",
+ "timezoneHelp": "Afficher les dates et heures dans ton fuseau horaire local.",
+ "timezoneDetected": "Détecté : {tz}",
+ "timezoneSelected": "Sélectionné : {tz}",
+ "timezoneInvalid": "Sélectionne un fuseau horaire valide dans la liste.",
+ "themeTitle": "Thème",
+ "themeLight": "Clair",
+ "themeDark": "Sombre",
+ "themeCustom": "Personnaliser",
+ "themeSelect": "Choisir un thème personnalisé",
+ "timezonePlaceholder": "Rechercher un fuseau horaire...",
+ "apiDocsTitle": "Documentation de l'API",
+ "apiDocsHelp": "Explore et teste les points d'accès du backend directement depuis l'application.",
+ "apiDocsViewLabel": "Voir",
+ "apiDocsLoading": "Chargement de la documentation",
+ "apiDocsFrameTitle": "Documentation de l'API",
+ "apiDocsOpenNewTab": "Ouvrir la documentation dans un nouvel onglet"
+ },
+ "sort": {
+ "smart": "Tri intelligent"
+ },
+ "dateConflict": {
+ "started": {
+ "title": "Date de début déjà définie",
+ "message": "La date de début est déjà définie. Veux-tu conserver {oldDate} ou définir {newDate} comme nouvelle date de début ?",
+ "keepOld": "Conserver {oldDate}",
+ "useNew": "Utiliser {newDate}"
+ },
+ "finished": {
+ "title": "Date de fin déjà définie",
+ "message": "La date de fin est déjà définie. Veux-tu conserver {oldDate} ou définir {newDate} comme nouvelle date de fin ?",
+ "keepOld": "Conserver {oldDate}",
+ "useNew": "Utiliser {newDate}"
+ },
+ "startedAfterFinished": {
+ "title": "Livre déjà terminé",
+ "message": "Ce livre a été terminé le {finishedDate}. Que devons-nous faire ?",
+ "keepFinished": "Garder la date de fin",
+ "clearAndStart": "Effacer la date de fin et commencer aujourd'hui",
+ "keepDesc": "Conserve la date de fin ({finishedDate}) et ne définit pas de date de début.",
+ "clearDesc": "Supprime la date de fin et définit aujourd'hui ({newStartDate}) comme date de début."
+ }
+ },
+ "search": {
+ "resultsCount": "{count, plural, one {résultat} other {résultats}} trouvés",
+ "noResults": "Aucun résultat trouvé",
+ "noResultsFor": "Aucun résultat trouvé pour \"{query}\"",
+ "tryDifferentQuery": "Essaie un autre terme de recherche"
+ },
+ "languages": {
+ "en": "Anglais",
+ "de": "Allemand",
+ "zh": "Chinois",
+ "es": "Espagnol",
+ "fr": "Français"
+ },
+ "auth": {
+ "login": "Connexion",
+ "firstname": "Prénom",
+ "lastname": "Nom",
+ "email": "E-mail",
+ "password": "Mot de passe",
+ "loginFailed": "Échec de la connexion",
+ "setupTitle": "Créer un compte administrateur",
+ "setupFailed": "Échec de la configuration",
+ "createAdmin": "Créer un administrateur",
+ "invalidEmailError": "Veuillez saisir une adresse e-mail valide",
+ "passwordComplexityError": "Le mot de passe ne répond pas aux exigences de complexité"
+ },
+ "user": {
+ "menu": "Menu utilisateur",
+ "profile": "Profil",
+ "about": "À propos",
+ "theme": "Thème",
+ "logout": "Déconnexion",
+ "apiKeys": "Clés API",
+ "keyDescription": "Description (facultative)",
+ "addKey": "Ajouter une clé",
+ "newKeyShownOnce": "Copie cette clé maintenant. Elle n'est affichée qu'une seule fois",
+ "noDescription": "Aucune description",
+ "newPassword": "Nouveau mot de passe"
+ },
+ "admin": {
+ "title": "Administration",
+ "tabs": {
+ "users": "Utilisateurs",
+ "backup": "Sauvegarde et restauration"
+ },
+ "newUser": "Créer un utilisateur",
+ "existingUsers": "Utilisateurs existants",
+ "role": "Rôle",
+ "create": "Créer",
+ "editing": "Modification de l'utilisateur",
+ "edit": "Modifier",
+ "saveChanges": "Enregistrer les modifications",
+ "cancelEdit": "Annuler la modification",
+ "deleteConfirmTitle": "Veux-tu vraiment supprimer cet utilisateur ?",
+ "deleteConfirmBody": "Cette action est irréversible.",
+ "cannotChangeOwnRole": "Tu ne peux pas modifier ton propre rôle d'administrateur.",
+ "requiredFieldError": "Veuillez remplir tous les champs obligatoires.",
+ "selfDeleteHint": "Pour supprimer ton propre compte, utilise Profil > Zone de danger.",
+ "backup": {
+ "title": "Sauvegarde",
+ "description": "Télécharge une sauvegarde complète de ta bibliothèque, y compris les livres, couvertures et données.",
+ "download": "Télécharger la sauvegarde",
+ "success": "Sauvegarde téléchargée avec succès",
+ "failed": "Échec du téléchargement de la sauvegarde",
+ "inProgress": "Création de la sauvegarde..."
+ },
+ "restore": {
+ "title": "Restauration",
+ "description": "Restaure ta bibliothèque à partir d'un fichier de sauvegarde précédent.",
+ "warning": "Attention : la restauration remplacera TOUTES les données actuelles. Assure-toi d'avoir une sauvegarde récente avant de continuer.",
+ "upload": "Téléverser et restaurer",
+ "success": "Restauration réussie. {books} livres restaurés.",
+ "failed": "Échec de la restauration",
+ "inProgress": "Restauration de la sauvegarde...",
+ "validationFailed": "Impossible de valider le fichier de sauvegarde",
+ "invalidBackup": "Structure de sauvegarde invalide",
+ "confirmTitle": "Confirmer la restauration",
+ "confirmBody": "Es-tu sûr de vouloir restaurer à partir de cette sauvegarde ? Cela remplacera toutes les données actuelles et ne peut pas être annulé.",
+ "confirmWarning": "Cette action est irréversible. Toutes les données actuelles seront perdues.",
+ "confirm": "Restaurer maintenant",
+ "backupDate": "Date de sauvegarde",
+ "backupVersion": "Version de l'application",
+ "coversCount": "Couvertures"
+ }
+ },
+ "password": {
+ "requirementsTitle": "Exigences du mot de passe",
+ "minLength": "Au moins 8 caractères",
+ "uppercase": "Au moins une lettre majuscule",
+ "lowercase": "Au moins une lettre minuscule",
+ "number": "Au moins un chiffre",
+ "special": "Au moins un caractère spécial",
+ "strongEnough": "Assez fort",
+ "notReady": "Pas encore prêt"
+ },
+ "error": {
+ "isbnAlreadyExists": "Cet ISBN est déjà utilisé par un autre livre.",
+ "dateInFuture": "La date ne peut pas être dans le futur.",
+ "dateStartedAfterFinished": "La date de début ne peut pas être postérieure à la date de fin.",
+ "dateFinishedRequiredForRead": "Un livre terminé doit avoir une date de fin. Modifie le statut si tu veux supprimer la date de fin.",
+ "invalidLanguageCode": "La langue doit être un code ISO à 2 lettres (par exemple : EN, DE, FR).",
+ "invalidConfirmationPhrase": "La phrase de confirmation ne correspond pas.",
+ "cannotDeleteLastAdmin": "Impossible de supprimer le compte : tu es le dernier administrateur",
+ "cannotDeleteOwnAccountHere": "Tu ne peux pas supprimer ton propre compte ici. Utilise Profil > Zone de danger.",
+ "importMalformedEvent": "Événement serveur malformé reçu lors de l'importation.",
+ "importUnsupportedContentType": "Type de contenu non pris en charge. Utilise des fichiers CSV ou JSON.",
+ "emailAlreadyRegistered": "Cette adresse e-mail est déjà enregistrée.",
+ "userNotFound": "Utilisateur introuvable.",
+ "cannotChangeOwnRole": "Tu ne peux pas modifier ton propre rôle d'administrateur.",
+ "authorRequired": "L'auteur est obligatoire.",
+ "pageCountRequired": "Le nombre de pages est obligatoire.",
+ "importTempFileCreateFailed": "Impossible de créer le fichier temporaire d'importation. Veuillez réessayer.",
+ "fileTooLarge": "Le fichier est trop volumineux. Essaie un fichier plus petit ou vérifie les limites du serveur.",
+ "exportNoDatasets": "Sélectionne au moins un ensemble de données à exporter.",
+ "batchUpdateFailed": "La mise à jour par lot a échoué en raison d'une erreur inattendue. Aucune modification n'a été enregistrée.",
+ "tooManyBooksSelected": "Trop de livres sélectionnés. Sélectionne au maximum {max} à la fois.",
+ "importMappingNameConflict": "Un mappage avec ce nom existe déjà.",
+ "importMappingNotFound": "Mappage d'importation introuvable.",
+ "importFileNotFound": "Fichier d'importation introuvable. Veuillez téléverser le fichier à nouveau."
+ },
+ "oidc": {
+ "orContinueWith": "ou continuer avec",
+ "loginWithProvider": "Continuer avec {provider}",
+ "profileTitle": "Authentification unique",
+ "notLinked": "Ton compte n'est pas encore lié.",
+ "linkButton": "Lier le compte {provider}",
+ "unlinkButton": "Délier le compte",
+ "linkedAs": "Lié avec {provider}",
+ "linkSuccess": "Compte lié avec succès",
+ "linkStartFailed": "Impossible de démarrer la liaison",
+ "unlinkSuccess": "Compte délié",
+ "unlinkFailed": "Impossible de délier le compte",
+ "signingIn": "Connexion en cours...",
+ "linkingAccount": "Liaison du compte..."
+ },
+ "profile": {
+ "sectionNav": "Sur cette page",
+ "profileSaveSuccess": "Profil enregistré",
+ "profileSaveFailed": "Échec de l'enregistrement du profil",
+ "passwordChangeSuccess": "Mot de passe modifié",
+ "passwordChangeFailed": "Échec de la modification du mot de passe",
+ "dataManagement": {
+ "title": "Gérer mes données",
+ "description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV/JSON.",
+ "link": "Importer / Exporter",
+ "missingCoversDescription": "Attribue rapidement les couvertures manquantes avec des suggestions automatiques.",
+ "missingCoversLink": "Gérer les couvertures manquantes"
+ },
+ "dangerZone": {
+ "title": "Zone de danger",
+ "subtitle": "Actions irréversibles qui suppriment définitivement tes données ou ton compte.",
+ "resetData": {
+ "title": "Réinitialiser toutes les données personnelles",
+ "description": "Supprime tous tes livres, étiquettes et progression de lecture tout en conservant ton compte et tes paramètres.",
+ "warning": "Cette action est irréversible.",
+ "placeholder": "Saisis la phrase de confirmation",
+ "hint": "Saisis exactement : DELETE ALL MY DATA",
+ "confirmationPhrase": "DELETE ALL MY DATA",
+ "button": "Réinitialiser toutes les données",
+ "success": "{books} livres, {tags} étiquettes et {entries} entrées de progression supprimés.",
+ "failed": "Échec de la réinitialisation"
+ },
+ "deleteAccount": {
+ "title": "Supprimer le compte",
+ "description": "Supprime définitivement ton compte et toutes les données associées.",
+ "warning": "Cette action est permanente et irréversible.",
+ "placeholder": "Saisis la phrase de confirmation",
+ "hint": "Saisis exactement : DELETE MY ACCOUNT",
+ "confirmationPhrase": "DELETE MY ACCOUNT",
+ "button": "Supprimer mon compte",
+ "success": "Compte supprimé. Redirection vers la connexion...",
+ "failed": "Échec de la suppression du compte",
+ "lastAdminError": "Impossible de supprimer le compte : tu es le dernier administrateur"
+ }
+ }
+ },
+ "timeline": {
+ "title": "Chronologie de lecture",
+ "subtitle": "Une vue chronologique des livres que tu as terminés",
+ "viewInLibrary": "Voir tout dans la bibliothèque",
+ "noReadBooks": "Aucun livre terminé dans ta bibliothèque.",
+ "goToLibrary": "Aller à la bibliothèque"
+ },
+ "data": {
+ "title": "Gestion des données",
+ "subtitle": "Importe et exporte les données de ta bibliothèque personnelle",
+ "tabs": {
+ "export": "Exporter",
+ "import": "Importer"
+ },
+ "export": {
+ "title": "Exporter",
+ "description": "Choisis les ensembles de données et le format, puis télécharge une archive ZIP.",
+ "datasets": {
+ "books": "Livres",
+ "progress": "Progression de lecture",
+ "tags": "Étiquettes",
+ "covers": "Fichiers de couverture"
+ },
+ "button": "Exporter les données",
+ "exporting": "Exportation...",
+ "success": "Export prêt. Téléchargement commencé.",
+ "errors": {
+ "noDatasets": "Sélectionne au moins un ensemble de données.",
+ "failed": "Échec de l'exportation."
+ }
+ },
+ "import": {
+ "title": "Importer",
+ "description": "Téléverse un fichier CSV ou JSON, mappe les champs, valide, puis importe.",
+ "parse": "Analyser le fichier",
+ "parsing": "Analyse...",
+ "fileSummary": "Lignes : {rows}, champs : {fields}",
+ "mappingTitle": "Correspondance des champs",
+ "mappingActionsTitle": "Gérer les mappages",
+ "mappingName": "Nom du mappage",
+ "loadSavedMapping": "Mappages enregistrés",
+ "noSavedMappings": "Aucun mappage enregistré. Enregistre le mappage actuel pour le réutiliser plus tard.",
+ "missingFieldsTitle": "Certains champs source du mappage enregistré ne sont pas présents dans ce fichier :",
+ "missingFieldEntry": "{target} ← {source}",
+ "selectMapping": "Sélectionner un mappage",
+ "loadMapping": "Charger le mappage",
+ "readonlyMapping": "lecture seule",
+ "deleteMapping": "Supprimer le mappage",
+ "deleteMappingTitle": "Supprimer le mappage enregistré",
+ "showPreview": "Afficher l'aperçu du mappage",
+ "createProgressForRead": "Créer une entrée de progression à 100 % pour les livres importés comme 'Lu'",
+ "hidePreview": "Masquer l'aperçu du mappage",
+ "previewNoMappedFields": "Aucun champ mappé. Assigne des champs source aux champs cible pour prévisualiser les valeurs.",
+ "transformLabel": "Transformation (Python)",
+ "transformPlaceholder": "ex. value.upper()",
+ "previewTitle": "Aperçu",
+ "previewButton": "Générer",
+ "previewLoading": "Génération...",
+ "previewStale": "L'aperçu est obsolète",
+ "previewRow": "Ligne {row}",
+ "errorRow": "Ligne {row}",
+ "previewSource": "Source",
+ "previewTransformed": "Transformé",
+ "none": "(aucun)",
+ "requiredField": "= champ obligatoire",
+ "changeFile": "Changer de fichier",
+ "coverUrlHint": "Attend une URL HTTP(S) vers une image. Les chemins de fichiers locaux et les données base64 ne sont pas pris en charge.",
+ "transformHelp": "Paramètres disponibles et exemples",
+ "transformHelpValue": "La valeur brute du champ source mappé",
+ "transformHelpRow": "Tous les champs source sous forme de dict, ex. row['title']",
+ "transformHelpContext": "Dict de contexte avec numéro de ligne et total (context['row_num'], context['total_rows'])",
+ "transformHelpReturn": "Les expressions simples sont auto-renvoyées ; utilise return explicite pour le code multiligne",
+ "transformHelpImports": "Importations Python disponibles : datetime, re, json, math",
+ "transformError": "La règle de transformation pour {field} est invalide : {error}",
+ "saveMapping": "Enregistrer le mappage",
+ "refreshMappings": "Actualiser les mappages",
+ "mappingSaved": "Mappage enregistré",
+ "mappingDeleted": "Mappage supprimé",
+ "mappingMissingFields": "Le mappage chargé a {count} champs source manquants.",
+ "validationTitle": "Simulation",
+ "simulate": "Simuler",
+ "validating": "Validation...",
+ "validationOk": "Validation réussie.",
+ "validationNotOk": "La validation a trouvé des problèmes.",
+ "rollbackAll": "Tout annuler en cas d'erreur",
+ "continueOnError": "Continuer en cas d'erreur",
+ "importNow": "Importer maintenant",
+ "importing": "Importation...",
+ "cancelled": "Importation annulée.",
+ "confirmImportTitle": "Lancer l'importation ?",
+ "confirmDestructive": "Cela écrit des données dans ta bibliothèque et ne peut pas être annulé automatiquement.",
+ "deleteMappingConfirm": "Supprimer ce mappage enregistré ?",
+ "dropzone": "Glisse et dépose un fichier CSV/JSON, ou",
+ "browse": "parcourir",
+ "fileInputLabel": "Choisir un fichier CSV ou JSON",
+ "showLess": "Afficher moins",
+ "showAllIssues": "Afficher tous les problèmes ({count})",
+ "showAllFailures": "Afficher toutes les lignes échouées ({count})",
+ "completed": "Importation terminée. Importés : {imported}, échoués : {failed}",
+ "errors": {
+ "parseFailed": "Échec de l'analyse du fichier.",
+ "saveMappingFailed": "Échec de l'enregistrement du mappage.",
+ "deleteMappingFailed": "Échec de la suppression du mappage.",
+ "loadMappingsFailed": "Échec du chargement des mappages.",
+ "loadMappingFailed": "Échec du chargement du mappage.",
+ "validateFailed": "Échec de la validation.",
+ "previewFailed": "Échec du chargement de l'aperçu.",
+ "executeFailed": "Échec de l'importation."
+ }
+ }
+ },
+ "dataHygiene": {
+ "authorRequired": "L'auteur ne peut pas être vide.",
+ "pageCountPositive": "Le nombre de pages doit être supérieur à 0.",
+ "title": "Hygiène des données",
+ "description": "Trouve et corrige les livres avec des métadonnées manquantes dans ta bibliothèque.",
+ "attributes": {
+ "author": "Auteur",
+ "isbn": "ISBN",
+ "publisher": "Éditeur",
+ "published_year": "Année",
+ "blurb": "Description",
+ "language": "Langue",
+ "subtitle": "Sous-titre",
+ "page_count": "Nombre de pages",
+ "cover_url": "Couverture"
+ },
+ "matchAny": "Correspond à l'un",
+ "matchAll": "Correspond à tous",
+ "noMissingBooks": "Aucun livre avec des attributs manquants correspondant à tes filtres.",
+ "total": "{count} {count, plural, one {livre} other {livres}} trouvés",
+ "loadMore": "Charger plus",
+ "loading": "Vérification de ta bibliothèque...",
+ "selectAll": "Tout sélectionner",
+ "deselectAll": "Tout désélectionner",
+ "nSelected": "{count} {count, plural, one {livre} other {livres}} sélectionnés",
+ "batchEditTitle": "Édition par lot",
+ "batchFieldLabel": "Champ à mettre à jour",
+ "batchFieldPlaceholder": "Sélectionne un champ...",
+ "batchValueLabel": "Nouvelle valeur",
+ "batchValuePlaceholder": "Saisis la nouvelle valeur",
+ "applyBatch": "Appliquer aux sélectionnés",
+ "confirmTitle": "Mettre à jour {count} {count, plural, one {livre} other {livres}} ?",
+ "confirmBody": "Cela définira \"{field}\" à \"{value}\" pour les livres suivants :",
+ "confirmApply": "Appliquer la mise à jour",
+ "confirmCancel": "Annuler",
+ "success": "{updated} {updated, plural, one {livre} other {livres}} mis à jour. {skipped} avaient déjà cette valeur.",
+ "updateFailed": "Échec de la mise à jour par lot.",
+ "loadFailed": "Échec du chargement des données.",
+ "tooManySelected": "Sélectionne au maximum 500 livres à la fois.",
+ "noAttributeSelected": "Sélectionne au moins un attribut à rechercher.",
+ "noFieldSelected": "Sélectionne un champ à mettre à jour.",
+ "noValueEntered": "Saisis une valeur à définir.",
+ "sectionFilters": "Filtres",
+ "sectionResults": "Résultats",
+ "showingCount": "Affichage de {shown} sur {total} livres",
+ "allSet": "Ta bibliothèque est en pleine forme ! Tous les livres ont des métadonnées complètes.",
+ "allSetFiltered": "Ta bibliothèque est en pleine forme ! Tous les livres ont des métadonnées complètes pour les attributs sélectionnés.",
+ "tableHeaderMissing": "Manquant",
+ "remaining": "restants",
+ "andXMore": "...et {count} autres"
+ },
+ "about": {
+ "title": "À propos de LibrisLog",
+ "description": "Une application web de suivi de livres pour gérer tes listes de lecture, importer des livres depuis des sources en ligne et suivre ta progression de lecture — le tout via un tableau de bord moderne.",
+ "author": "Auteur",
+ "version": "Version",
+ "technologies": "Technologies utilisées",
+ "thankYou": "Remerciements",
+ "thankYouText": "LibrisLog n'existerait pas sans les incroyables bibliothèques et frameworks open source sur lesquels il repose. Nos remerciements vont à tous les développeurs qui contribuent à ces projets.",
+ "frontend": "Frontend",
+ "backend": "Backend",
+ "devTools": "Outils de développement",
+ "documentation": "Documentation"
+ },
+ "missingCovers": {
+ "title": "Gérer les couvertures manquantes",
+ "header": "{count} {count, plural, one {Livre} other {Livres}} sans couverture",
+ "bookInfo": "{title} de {author}",
+ "isbnLabel": "ISBN : {isbn}",
+ "noIsbn": "Ce livre n'a pas d'ISBN. La recherche automatique de couverture n'est pas disponible.",
+ "noCandidates": "Aucune couverture n'a pu être déterminée automatiquement.",
+ "searchGoogle": "Rechercher sur Google",
+ "searchGoogleAria": "Ouvrir la recherche d'images Google pour ce livre dans un nouvel onglet",
+ "manualUrlLabel": "Ou colle une URL d'image de couverture",
+ "manualUrlPlaceholder": "https://example.com/cover.jpg",
+ "manualUrlSave": "Enregistrer la couverture",
+ "manualUrlInvalid": "Veuillez saisir une URL HTTP(S) valide",
+ "manualUrlNotHttps": "Attention : l'URL n'est pas en HTTPS. Préfère HTTPS pour la sécurité.",
+ "skip": "Passer",
+ "skipAria": "Passer ce livre et passer au suivant",
+ "coverSaved": "Couverture enregistrée",
+ "coverSaveFailed": "Échec de l'enregistrement de la couverture",
+ "allDone": "Tous les livres ont une couverture ! Bon travail.",
+ "allDoneSub": "Chaque livre de ta bibliothèque a maintenant une image de couverture.",
+ "loadingBook": "Chargement du livre suivant...",
+ "loadingCandidates": "Recherche de sources de couvertures...",
+ "keyboardHint": "Astuce : appuie sur 1\u20139 pour sélectionner une couverture, \u2192 pour passer",
+ "candidatesError": "Recherche de couverture échouée. Tu peux toujours utiliser l'importation manuelle.",
+ "retry": "Réessayer"
+ }
+}
diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json
new file mode 100644
index 00000000..6b3a8809
--- /dev/null
+++ b/frontend/src/lib/i18n/locales/zh.json
@@ -0,0 +1,650 @@
+{
+ "app": {
+ "title": "LibrisLog",
+ "addBook": "添加图书",
+ "add": "添加",
+ "language": "语言"
+ },
+ "nav": {
+ "dashboard": "仪表盘",
+ "library": "书库",
+ "timeline": "时间线",
+ "statistics": "统计",
+ "data": "数据",
+ "want_to_read": "想读",
+ "currently_reading": "在读",
+ "read": "已读",
+ "did_not_finish": "弃读"
+ },
+ "statistics": {
+ "title": "统计",
+ "subtitle": "你的阅读旅程数据分析",
+ "avgBooksPerMonth": "平均本数/月",
+ "busiestMonth": "最活跃月份",
+ "avgPageCount": "平均页数/本",
+ "mostPopularLanguage": "阅读最多的语言",
+ "languageDistribution": "按语言分布",
+ "statusDistribution": "按状态分布",
+ "pageBuckets": "页数统计",
+ "pagesToRead": "待读页数",
+ "pagesRead": "已读页数",
+ "pagesWasted": "浪费页数",
+ "pagesWastedFootnote": "\"浪费\" = 标记为\"弃读\"的图书中达到的最大页数",
+ "pagesReadPerMonth": "每月阅读页数",
+ "booksFinishedPerMonth": "每月读完数量",
+ "booksFinishedPerYear": "每年读完数量",
+ "topAuthors": "热门作者",
+ "rankedNumber": "#{rank}",
+ "coversForAuthor": "{author} 的图书封面",
+ "booksCount": "{count} 本书",
+ "unknownLanguage": "未知",
+ "pagesReadCalendar": "阅读活动(最近365天)",
+ "noCalendarData": "过去一年没有阅读数据",
+ "pagesOver": "页,共",
+ "daysLabel": "天",
+ "avgPerDay": "活跃日平均:",
+ "avgPerDayAll": "365天日均:",
+ "pagesPerDay": "页/天",
+ "loading": "正在加载统计...",
+ "noData": "暂无数据。开始阅读并记录图书以查看统计数据!",
+ "resetZoom": "重置缩放",
+ "sectionDistributions": "分布",
+ "sectionCharts": "阅读趋势",
+ "sectionActivity": "活动",
+ "ratingStats": "评分统计",
+ "booksWithRating": "已评分图书",
+ "booksWithoutRating": "未评分图书",
+ "averageRating": "平均评分",
+ "noRating": "暂无评分",
+ "topRated": "评分最高",
+ "worstRated": "评分最低",
+ "showMore": "显示更多"
+ },
+ "dashboard": {
+ "title": "阅读仪表盘",
+ "subtitle": "你的阅读旅程概览",
+ "quoteTitle": "每日一言",
+ "quoteUnavailable": "暂无可用名言。",
+ "totalBooks": "书库总数",
+ "booksRead": "已读书籍",
+ "booksToRead": "待读书籍",
+ "currentlyReading": "正在阅读",
+ "nextToRead": "下一本想读",
+ "viewAll": "查看全部",
+ "searchAllBooks": "搜索所有图书",
+ "noSearchResults": "未找到图书",
+ "noCurrentlyReading": "你当前没有在读的图书。",
+ "noNextToRead": "你的想读列表还没有图书。",
+ "popularTags": "热门标签"
+ },
+ "status": {
+ "want_to_read": "想读",
+ "currently_reading": "在读",
+ "read": "已读",
+ "did_not_finish": "弃读"
+ },
+ "common": {
+ "search": "搜索",
+ "searchBooks": "搜索图书...",
+ "result": "个结果",
+ "results": "个结果",
+ "save": "保存",
+ "saved": "已保存",
+ "saveFailed": "保存失败",
+ "edit": "编辑",
+ "cancel": "取消",
+ "confirm": "确认?",
+ "delete": "删除",
+ "deleting": "删除中...",
+ "back": "返回",
+ "loadMore": "加载更多",
+ "syncing": "同步中...",
+ "noBooksYet": "这里还没有图书。",
+ "addFirstBook": "添加第一本书",
+ "dateAdded": "添加日期",
+ "rating": "评分",
+ "ratingSaved": "评分已保存",
+ "desc": "降序",
+ "asc": "升序",
+ "close": "关闭",
+ "clearForm": "清空表单",
+ "remove": "移除",
+ "copy": "复制",
+ "copied": "已复制",
+ "showPassword": "显示密码",
+ "required": "必填",
+ "saving": "保存中...",
+ "loadingEllipsis": "...",
+ "starLabel": "{star} 星",
+ "clickToRate": "点击星级进行评分",
+ "actionFailed": "{action} 失败",
+ "readMore": "展开",
+ "readLess": "收起",
+ "serverStarting": "服务器正在启动...",
+ "serverStartingDesc": "请等待服务器启动完成。"
+ },
+ "book": {
+ "title": "标题",
+ "subtitle": "副标题",
+ "author": "作者",
+ "status": "状态",
+ "isbn": "ISBN",
+ "publisher": "出版社",
+ "year": "年份",
+ "pages": "页数",
+ "language": "语言",
+ "tags": "标签",
+ "tagsPlaceholder": "输入标签并按 Enter 或逗号",
+ "tagsHint": "按 Enter 或逗号添加标签。Backspace 删除最后一个标签。",
+ "notes": "笔记",
+ "blurb": "简介",
+ "about": "关于本书",
+ "dateStarted": "开始日期",
+ "dateFinished": "完成日期",
+ "cover": "封面",
+ "coverForAuthor": "{author} 的图书封面 {index}",
+ "googleCovers": "Google 封面",
+ "autoSearchCovers": "自动搜索封面",
+ "autoSearchInfo": "点击封面以将其导入为本书封面。",
+ "autoSearchNoCandidates": "未找到此 ISBN 的封面候选项。",
+ "autoSearchError": "自动搜索封面失败。",
+ "autoSearchLoading": "正在搜索封面来源...",
+ "autoSearchMetaUnknown": "未知大小/分辨率",
+ "autoSearchMeta": "{size} - {resolution}",
+ "autoSearchSourceLabel": "来源:{source}",
+ "coverOf": "{title} 的封面",
+ "openDetailsHint": "点击查看详情",
+ "readingProgress": "阅读进度",
+ "currentPage": "页",
+ "progressLog": "进度记录",
+ "progressLogEmpty": "暂无进度记录。",
+ "setPageCountFirst": "请先设置总页数。",
+ "logDate": "日期",
+ "logPage": "页码",
+ "deleteEntry": "删除",
+ "deleteEntryConfirm": "删除此记录?",
+ "editEntry": "编辑",
+ "saveEntry": "保存",
+ "progressGraph": "进度变化",
+ "progressPromptTitle": "设置阅读进度?",
+ "progressPromptMessage": "将 \"{title}\" 的阅读进度设置为 100%?",
+ "progressPromptSet": "设为 100%",
+ "progressPromptSkip": "跳过"
+ },
+ "addModal": {
+ "manual": "手动添加",
+ "searchImport": "搜索并导入",
+ "adding": "添加中...",
+ "failedAdd": "添加图书失败",
+ "importFromFile": "从文件导入"
+ },
+ "import": {
+ "searchByTitleOrAuthor": "按标题或作者搜索...",
+ "enterIsbn": "输入 ISBN...",
+ "noResultsYet": "暂无结果",
+ "noBooksFound": "未找到图书",
+ "alreadyImported": "已导入",
+ "imported": "已导入",
+ "googleToo": "同时搜索 Google Books",
+ "googleSearching": "正在搜索 Google Books...",
+ "googleAdded": "Google Books 结果已添加:{count}",
+ "scan": "扫描",
+ "scanIsbn": "扫描 ISBN 条码",
+ "importFailed": "导入失败",
+ "searchFailed": "搜索失败",
+ "scannedIsbn": "已扫描 ISBN:{isbn}",
+ "or": "或",
+ "sourceHardcoverSearching": "正在搜索 Hardcover...",
+ "sourceHardcoverSkipped": "Hardcover 已跳过(未配置 API 令牌)",
+ "sourceSkipped": "Google Books 已跳过(未配置 API 密钥)",
+ "sourceOpenLibrarySearching": "正在搜索 Open Library...",
+ "sourceGoogleSearching": "正在搜索 Google Books...",
+ "sourceBackendError": "{source} 后端错误(请检查后端日志)",
+ "sourceError": "搜索失败:{message}",
+ "resultCount": "{source} - {count} 个结果"
+ },
+ "scanner": {
+ "title": "扫描 ISBN 条码",
+ "help": "将摄像头对准图书条码。识别到有效 ISBN 后自动开始搜索。",
+ "startError": "无法启动条码扫描器。请检查摄像头权限。",
+ "noCamera": "未找到摄像头设备。",
+ "close": "关闭扫描器"
+ },
+ "coverPicker": {
+ "dropzone": "拖放图片到此处,或",
+ "browse": "浏览",
+ "pasteUrl": "或粘贴图片 URL...",
+ "useUrl": "使用 URL",
+ "urlInvalid": "无法从 URL 加载封面。请检查链接。",
+ "uploadFailed": "上传失败",
+ "previewAlt": "封面预览"
+ },
+ "toasts": {
+ "dismiss": "关闭",
+ "newVersion": "新版本 ({version}) 已可用。",
+ "reload": "重新加载"
+ },
+ "settings": {
+ "title": "设置",
+ "languageTitle": "语言",
+ "timezone": "时区",
+ "timezoneHelp": "以本地时区显示日期和时间。",
+ "timezoneDetected": "检测到:{tz}",
+ "timezoneSelected": "已选择:{tz}",
+ "timezoneInvalid": "请从列表中选择有效的时区。",
+ "themeTitle": "主题",
+ "themeLight": "浅色",
+ "themeDark": "深色",
+ "themeCustom": "自定义",
+ "themeSelect": "选择自定义主题",
+ "timezonePlaceholder": "搜索时区...",
+ "apiDocsTitle": "API 文档",
+ "apiDocsHelp": "直接在应用中探索和测试后端接口。",
+ "apiDocsViewLabel": "查看",
+ "apiDocsLoading": "正在加载 API 文档",
+ "apiDocsFrameTitle": "API 文档",
+ "apiDocsOpenNewTab": "在新标签页中打开 API 文档"
+ },
+ "sort": {
+ "smart": "智能排序"
+ },
+ "dateConflict": {
+ "started": {
+ "title": "开始日期已设置",
+ "message": "开始日期已设置。你想保留 {oldDate} 还是将 {newDate} 设为新的开始日期?",
+ "keepOld": "保留 {oldDate}",
+ "useNew": "使用 {newDate}"
+ },
+ "finished": {
+ "title": "完成日期已设置",
+ "message": "完成日期已设置。你想保留 {oldDate} 还是将 {newDate} 设为新的完成日期?",
+ "keepOld": "保留 {oldDate}",
+ "useNew": "使用 {newDate}"
+ },
+ "startedAfterFinished": {
+ "title": "本书已完成",
+ "message": "本书已于 {finishedDate} 完成。你想怎么做?",
+ "keepFinished": "保留完成日期",
+ "clearAndStart": "清除完成日期并从今天开始",
+ "keepDesc": "保留完成日期({finishedDate}),不设置开始日期。",
+ "clearDesc": "删除完成日期并将今天({newStartDate})设为开始日期。"
+ }
+ },
+ "search": {
+ "resultsCount": "找到 {count} 个结果",
+ "noResults": "未找到结果",
+ "noResultsFor": "未找到 \"{query}\" 的结果",
+ "tryDifferentQuery": "尝试其他搜索词"
+ },
+ "languages": {
+ "en": "英语",
+ "de": "德语",
+ "zh": "中文",
+ "es": "西班牙语",
+ "fr": "法语"
+ },
+ "auth": {
+ "login": "登录",
+ "firstname": "名",
+ "lastname": "姓",
+ "email": "邮箱",
+ "password": "密码",
+ "loginFailed": "登录失败",
+ "setupTitle": "创建管理员账户",
+ "setupFailed": "设置失败",
+ "createAdmin": "创建管理员",
+ "invalidEmailError": "请输入有效的邮箱地址",
+ "passwordComplexityError": "密码未满足复杂度要求"
+ },
+ "user": {
+ "menu": "用户菜单",
+ "profile": "个人资料",
+ "about": "关于",
+ "theme": "主题",
+ "logout": "退出登录",
+ "apiKeys": "API 密钥",
+ "keyDescription": "描述(可选)",
+ "addKey": "添加密钥",
+ "newKeyShownOnce": "立即复制此密钥。仅显示一次",
+ "noDescription": "无描述",
+ "newPassword": "新密码"
+ },
+ "admin": {
+ "title": "管理",
+ "tabs": {
+ "users": "用户",
+ "backup": "备份与恢复"
+ },
+ "newUser": "创建用户",
+ "existingUsers": "现有用户",
+ "role": "角色",
+ "create": "创建",
+ "editing": "正在编辑用户",
+ "edit": "编辑",
+ "saveChanges": "保存更改",
+ "cancelEdit": "取消编辑",
+ "deleteConfirmTitle": "确定要删除此用户吗?",
+ "deleteConfirmBody": "此操作无法撤消。",
+ "cannotChangeOwnRole": "你不能更改自己的管理员角色。",
+ "requiredFieldError": "请填写所有必填字段。",
+ "selfDeleteHint": "要删除自己的账户,请使用“个人资料” > “危险区域”。",
+ "backup": {
+ "title": "备份",
+ "description": "下载包含所有图书、封面和数据的完整备份。",
+ "download": "下载备份",
+ "success": "备份下载成功",
+ "failed": "备份下载失败",
+ "inProgress": "正在创建备份..."
+ },
+ "restore": {
+ "title": "恢复",
+ "description": "从之前的备份文件恢复你的书库。",
+ "warning": "警告:恢复将替换所有当前数据。确保在继续之前有最近的备份。",
+ "upload": "上传并恢复",
+ "success": "恢复成功完成。已恢复 {books} 本书。",
+ "failed": "恢复失败",
+ "inProgress": "正在恢复备份...",
+ "validationFailed": "无法验证备份文件",
+ "invalidBackup": "无效的备份文件结构",
+ "confirmTitle": "确认恢复",
+ "confirmBody": "你确定要从此备份恢复吗?这将替换所有当前数据且无法撤消。",
+ "confirmWarning": "此操作不可撤消。所有当前数据将丢失。",
+ "confirm": "立即恢复",
+ "backupDate": "备份日期",
+ "backupVersion": "应用版本",
+ "coversCount": "封面"
+ }
+ },
+ "password": {
+ "requirementsTitle": "密码要求",
+ "minLength": "至少 8 个字符",
+ "uppercase": "至少一个大写字母",
+ "lowercase": "至少一个小写字母",
+ "number": "至少一个数字",
+ "special": "至少一个特殊字符",
+ "strongEnough": "强度足够",
+ "notReady": "不满足要求"
+ },
+ "error": {
+ "isbnAlreadyExists": "此 ISBN 已被另一本书使用。",
+ "dateInFuture": "日期不能为未来。",
+ "dateStartedAfterFinished": "开始日期不能晚于完成日期。",
+ "dateFinishedRequiredForRead": "已读完的书必须有结束日期。如果要移除结束日期,请更改状态。",
+ "invalidLanguageCode": "语言必须是 2 位 ISO 代码(例如:EN、DE、FR)。",
+ "invalidConfirmationPhrase": "确认短语不匹配。",
+ "cannotDeleteLastAdmin": "无法删除账户:你是最后一个管理员",
+ "cannotDeleteOwnAccountHere": "你不能在这里删除自己的账户。请使用“个人资料” > “危险区域”。",
+ "importMalformedEvent": "导入期间收到格式错误的服务器事件。",
+ "importUnsupportedContentType": "不支持的上传内容类型。请使用 CSV 或 JSON 文件。",
+ "emailAlreadyRegistered": "此邮箱地址已注册。",
+ "userNotFound": "未找到用户。",
+ "cannotChangeOwnRole": "你不能更改自己的管理员角色。",
+ "authorRequired": "作者为必填。",
+ "pageCountRequired": "页数为必填。",
+ "importTempFileCreateFailed": "无法创建临时导入文件。请重试。",
+ "fileTooLarge": "文件过大。请尝试更小的文件或检查服务器限制。",
+ "exportNoDatasets": "请至少选择一个数据集进行导出。",
+ "batchUpdateFailed": "批量更新因意外错误失败。未保存任何更改。",
+ "tooManyBooksSelected": "选择的图书过多。一次最多选择 {max} 本。",
+ "importMappingNameConflict": "同名映射已存在。",
+ "importMappingNotFound": "未找到导入映射。",
+ "importFileNotFound": "未找到导入文件。请重新上传文件。"
+ },
+ "oidc": {
+ "orContinueWith": "或继续使用",
+ "loginWithProvider": "使用 {provider} 继续",
+ "profileTitle": "单点登录",
+ "notLinked": "你的账户尚未关联。",
+ "linkButton": "关联 {provider} 账户",
+ "unlinkButton": "取消关联",
+ "linkedAs": "已关联 {provider}",
+ "linkSuccess": "账户关联成功",
+ "linkStartFailed": "无法开始账户关联",
+ "unlinkSuccess": "已取消关联",
+ "unlinkFailed": "无法取消账户关联",
+ "signingIn": "正在登录...",
+ "linkingAccount": "正在关联账户..."
+ },
+ "profile": {
+ "sectionNav": "本页内容",
+ "profileSaveSuccess": "个人资料已保存",
+ "profileSaveFailed": "保存个人资料失败",
+ "passwordChangeSuccess": "密码已更改",
+ "passwordChangeFailed": "更改密码失败",
+ "dataManagement": {
+ "title": "管理我的数据",
+ "description": "导出你的书库或从 CSV/JSON 文件导入图书。",
+ "link": "导入 / 导出",
+ "missingCoversDescription": "使用自动建议快速分配缺失封面。",
+ "missingCoversLink": "管理缺失封面"
+ },
+ "dangerZone": {
+ "title": "危险区域",
+ "subtitle": "不可撤消的操作,将永久删除你的数据或账户。",
+ "resetData": {
+ "title": "重置所有个人数据",
+ "description": "删除所有图书、标签和阅读进度,但保留账户和设置。",
+ "warning": "此操作无法撤消。",
+ "placeholder": "输入确认短语",
+ "hint": "准确输入:DELETE ALL MY DATA",
+ "confirmationPhrase": "DELETE ALL MY DATA",
+ "button": "重置所有数据",
+ "success": "已删除 {books} 本书、{tags} 个标签和 {entries} 条进度记录。",
+ "failed": "重置数据失败"
+ },
+ "deleteAccount": {
+ "title": "删除账户",
+ "description": "永久删除你的账户及所有关联数据。",
+ "warning": "此操作是永久性的,无法撤消。",
+ "placeholder": "输入确认短语",
+ "hint": "准确输入:DELETE MY ACCOUNT",
+ "confirmationPhrase": "DELETE MY ACCOUNT",
+ "button": "删除我的账户",
+ "success": "账户已删除。正在跳转到登录页面...",
+ "failed": "删除账户失败",
+ "lastAdminError": "无法删除账户:你是最后一个管理员"
+ }
+ }
+ },
+ "timeline": {
+ "title": "阅读时间线",
+ "subtitle": "你已完成阅读的图书的时间线视图",
+ "viewInLibrary": "在书库中查看全部",
+ "noReadBooks": "你的书库中还没有已读完的图书。",
+ "goToLibrary": "前往书库"
+ },
+ "data": {
+ "title": "数据管理",
+ "subtitle": "导入和导出你的个人书库数据",
+ "tabs": {
+ "export": "导出",
+ "import": "导入"
+ },
+ "export": {
+ "title": "导出",
+ "description": "选择数据集和格式,然后下载 ZIP 归档。",
+ "datasets": {
+ "books": "图书",
+ "progress": "阅读进度",
+ "tags": "标签",
+ "covers": "封面文件"
+ },
+ "button": "导出数据",
+ "exporting": "导出中...",
+ "success": "导出就绪。已开始下载。",
+ "errors": {
+ "noDatasets": "请至少选择一个数据集。",
+ "failed": "导出失败。"
+ }
+ },
+ "import": {
+ "title": "导入",
+ "description": "上传 CSV 或 JSON 文件,映射字段,验证,然后导入。",
+ "parse": "解析文件",
+ "parsing": "解析中...",
+ "fileSummary": "行数:{rows},字段:{fields}",
+ "mappingTitle": "字段映射",
+ "mappingActionsTitle": "管理映射",
+ "mappingName": "映射名称",
+ "loadSavedMapping": "已保存的映射",
+ "noSavedMappings": "暂无已保存的映射。保存当前映射以在以后重复使用。",
+ "missingFieldsTitle": "已保存映射中的部分源字段在此文件中不存在:",
+ "missingFieldEntry": "{target} ← {source}",
+ "selectMapping": "选择已保存的映射",
+ "loadMapping": "加载映射",
+ "readonlyMapping": "只读",
+ "deleteMapping": "删除映射",
+ "deleteMappingTitle": "删除已保存的映射",
+ "showPreview": "显示映射预览",
+ "createProgressForRead": "为导入为“已读”的图书创建 100% 进度记录",
+ "hidePreview": "隐藏映射预览",
+ "previewNoMappedFields": "尚未映射字段。请将源字段分配给目标字段以预览值。",
+ "transformLabel": "转换 (Python)",
+ "transformPlaceholder": "例如:value.upper()",
+ "previewTitle": "预览",
+ "previewButton": "生成",
+ "previewLoading": "生成中...",
+ "previewStale": "预览已过期",
+ "previewRow": "第 {row} 行",
+ "errorRow": "第 {row} 行",
+ "previewSource": "来源",
+ "previewTransformed": "转换后",
+ "none": "(无)",
+ "requiredField": "= 必填字段",
+ "changeFile": "更改文件",
+ "coverUrlHint": "期望指向图片的 HTTP(S) URL。不支持本地文件路径和 base64 数据。",
+ "transformHelp": "可用参数和示例",
+ "transformHelpValue": "映射的源字段的原始值",
+ "transformHelpRow": "所有源字段作为字典,例如 row['title']",
+ "transformHelpContext": "包含行号和总行数的上下文字典 (context['row_num'], context['total_rows'])",
+ "transformHelpReturn": "单个表达式自动返回;多行代码请使用显式 return",
+ "transformHelpImports": "可用的 Python 导入:datetime, re, json, math",
+ "transformError": "{field} 的转换规则无效:{error}",
+ "saveMapping": "保存映射",
+ "refreshMappings": "刷新映射",
+ "mappingSaved": "映射已保存",
+ "mappingDeleted": "映射已删除",
+ "mappingMissingFields": "加载的映射有 {count} 个缺失的源字段。",
+ "validationTitle": "模拟",
+ "simulate": "模拟",
+ "validating": "验证中...",
+ "validationOk": "验证通过。",
+ "validationNotOk": "验证发现问题。",
+ "rollbackAll": "出错时全部回滚",
+ "continueOnError": "出错时继续",
+ "importNow": "立即导入",
+ "importing": "导入中...",
+ "cancelled": "导入已取消。",
+ "confirmImportTitle": "开始导入?",
+ "confirmDestructive": "这将向你的书库写入数据,无法自动撤消。",
+ "deleteMappingConfirm": "删除此已保存的映射?",
+ "dropzone": "拖放 CSV/JSON 文件,或",
+ "browse": "浏览",
+ "fileInputLabel": "选择 CSV 或 JSON 文件",
+ "showLess": "收起",
+ "showAllIssues": "显示所有问题 ({count})",
+ "showAllFailures": "显示所有失败行 ({count})",
+ "completed": "导入完成。已导入:{imported},失败:{failed}",
+ "errors": {
+ "parseFailed": "解析文件失败。",
+ "saveMappingFailed": "保存映射失败。",
+ "deleteMappingFailed": "删除映射失败。",
+ "loadMappingsFailed": "加载映射失败。",
+ "loadMappingFailed": "加载映射失败。",
+ "validateFailed": "验证失败。",
+ "previewFailed": "加载预览失败。",
+ "executeFailed": "导入失败。"
+ }
+ }
+ },
+ "dataHygiene": {
+ "authorRequired": "作者不能为空。",
+ "pageCountPositive": "页数必须大于 0。",
+ "title": "数据清理",
+ "description": "查找并修复书库中缺少元数据的图书。",
+ "attributes": {
+ "author": "作者",
+ "isbn": "ISBN",
+ "publisher": "出版社",
+ "published_year": "年份",
+ "blurb": "简介",
+ "language": "语言",
+ "subtitle": "副标题",
+ "page_count": "页数",
+ "cover_url": "封面"
+ },
+ "matchAny": "匹配任一",
+ "matchAll": "匹配全部",
+ "noMissingBooks": "未找到符合筛选条件的缺少属性的图书。",
+ "total": "找到 {count} 本书",
+ "loadMore": "加载更多",
+ "loading": "正在检查你的书库...",
+ "selectAll": "全选",
+ "deselectAll": "取消全选",
+ "nSelected": "已选 {count} 本书",
+ "batchEditTitle": "批量编辑",
+ "batchFieldLabel": "要更新的字段",
+ "batchFieldPlaceholder": "选择一个字段...",
+ "batchValueLabel": "新值",
+ "batchValuePlaceholder": "输入新值",
+ "applyBatch": "应用于所选",
+ "confirmTitle": "更新 {count} 本书?",
+ "confirmBody": "这会将下列图书的 \"{field}\" 设置为 \"{value}\":",
+ "confirmApply": "执行更新",
+ "confirmCancel": "取消",
+ "success": "已更新 {updated} 本书。{skipped} 本已为此值。",
+ "updateFailed": "批量更新失败。",
+ "loadFailed": "加载数据失败。",
+ "tooManySelected": "一次最多选择 500 本书。",
+ "noAttributeSelected": "请至少选择一个属性进行搜索。",
+ "noFieldSelected": "请选择一个要更新的字段。",
+ "noValueEntered": "请输入要设置的值。",
+ "sectionFilters": "筛选条件",
+ "sectionResults": "结果",
+ "showingCount": "显示 {shown} 本,共 {total} 本",
+ "allSet": "你的书库状态良好!所有图书元数据完整。",
+ "allSetFiltered": "你的书库状态良好!所选属性的所有图书元数据完整。",
+ "tableHeaderMissing": "缺失",
+ "remaining": "剩余",
+ "andXMore": "...还有 {count} 个"
+ },
+ "about": {
+ "title": "关于 LibrisLog",
+ "description": "一款图书追踪 Web 应用,用于管理阅读列表、从在线来源导入图书以及追踪阅读进度——一切尽在现代仪表盘中。",
+ "author": "作者",
+ "version": "版本",
+ "technologies": "使用的技术",
+ "thankYou": "致谢",
+ "thankYouText": "LibrisLog 的诞生离不开其构建所依赖的优秀开源库和框架。我们感谢所有为这些项目做出贡献的开发者。",
+ "frontend": "前端",
+ "backend": "后端",
+ "devTools": "开发工具",
+ "documentation": "文档"
+ },
+ "missingCovers": {
+ "title": "管理缺失封面",
+ "header": "{count} 本图书缺少封面",
+ "bookInfo": "{title} - {author}",
+ "isbnLabel": "ISBN:{isbn}",
+ "noIsbn": "本书没有 ISBN。无法自动搜索封面。",
+ "noCandidates": "无法自动确定封面。",
+ "searchGoogle": "在 Google 上搜索封面",
+ "searchGoogleAria": "在新标签页中打开此书的 Google 图片搜索",
+ "manualUrlLabel": "或粘贴封面图片 URL",
+ "manualUrlPlaceholder": "https://example.com/cover.jpg",
+ "manualUrlSave": "保存封面",
+ "manualUrlInvalid": "请输入有效的 HTTP(S) URL",
+ "manualUrlNotHttps": "警告:URL 不是 HTTPS。出于安全考虑,建议使用 HTTPS。",
+ "skip": "跳过",
+ "skipAria": "跳过此书并前往下一本",
+ "coverSaved": "封面已保存",
+ "coverSaveFailed": "保存封面失败",
+ "allDone": "所有图书都有封面了!做得好。",
+ "allDoneSub": "你书库中的每本书现在都有封面。",
+ "loadingBook": "正在加载下一本书...",
+ "loadingCandidates": "正在搜索封面来源...",
+ "keyboardHint": "提示:按 1\u20139 选择封面,按 \u2192 跳过",
+ "candidatesError": "封面搜索失败。你仍可使用手动导入。",
+ "retry": "重试"
+ }
+}