diff --git a/changelog.d/3027-web-english-strings.md b/changelog.d/3027-web-english-strings.md new file mode 100644 index 000000000..e53406e29 --- /dev/null +++ b/changelog.d/3027-web-english-strings.md @@ -0,0 +1,4 @@ +### Fixed +- **Search interval hint gave the wrong advice** (#3027). It told you to reduce the wanted search interval when indexers rate limit you, which makes Bindery search more often. It now says to pick a longer interval. Translations that carried the old English text now show the corrected English until they are translated. +- **Series, Calendar and Push all to Calibre can now be translated** (#3027). The Series page heading, buttons and card labels, the Calendar month and weekday names, and the Push all to Calibre button and progress window were always shown in English. Calendar dates now follow your chosen language, and book status badges on the Series page are now capitalised to match the rest of the app. +- **Consistent ellipses** (#3027). Loading and busy labels use the same ellipsis character in English and in the German, Spanish, French, Dutch, Tagalog, Indonesian and Korean translations. Leftover English text in those translations now falls back to English properly instead. diff --git a/web/src/components/AuthorMetadataLinkModal.test.tsx b/web/src/components/AuthorMetadataLinkModal.test.tsx index 8ce3b9b72..f3bf85036 100644 --- a/web/src/components/AuthorMetadataLinkModal.test.tsx +++ b/web/src/components/AuthorMetadataLinkModal.test.tsx @@ -7,11 +7,11 @@ import type { Author } from '../api/client' const tMock = vi.hoisted(() => (key: string, options?: string | Record) => { const strings: Record = { 'authorMetadataLink.title': 'Link metadata', - 'authorMetadataLink.searchPlaceholder': 'Search by author name...', + 'authorMetadataLink.searchPlaceholder': 'Search by author name…', 'authorMetadataLink.search': 'Search', - 'authorMetadataLink.searching': 'Searching...', + 'authorMetadataLink.searching': 'Searching…', 'authorMetadataLink.link': 'Link', - 'authorMetadataLink.linking': 'Linking...', + 'authorMetadataLink.linking': 'Linking…', 'authorMetadataLink.noResults': 'No alternate metadata candidates found', 'authorMetadataLink.previouslyLinked': 'previously linked', 'common.cancel': 'Cancel', @@ -96,7 +96,7 @@ describe('AuthorMetadataLinkModal', () => { await waitFor(() => expect(api.searchAuthorLinkCandidates).toHaveBeenCalledWith(42, 'Emilia Jae')) - const input = screen.getByPlaceholderText('Search by author name...') + const input = screen.getByPlaceholderText('Search by author name…') fireEvent.change(input, { target: { value: 'Manual Query' } }) const form = input.closest('form') if (!form) throw new Error('search form not found') diff --git a/web/src/components/AuthorMetadataLinkModal.tsx b/web/src/components/AuthorMetadataLinkModal.tsx index 225c4ed68..9403052bc 100644 --- a/web/src/components/AuthorMetadataLinkModal.tsx +++ b/web/src/components/AuthorMetadataLinkModal.tsx @@ -112,7 +112,7 @@ export default function AuthorMetadataLinkModal({ author, onClose, onLinked }: P value={query} onChange={event => setQuery(event.target.value)} className="flex-1 bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-emerald-500" - placeholder={t('authorMetadataLink.searchPlaceholder', 'Search by author name...')} + placeholder={t('authorMetadataLink.searchPlaceholder', 'Search by author name…')} autoFocus /> @@ -204,7 +204,7 @@ export default function AuthorMetadataLinkModal({ author, onClose, onLinked }: P disabled={linking !== null} className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 rounded text-xs font-medium flex-shrink-0" > - {linking === candidate.foreignAuthorId ? t('authorMetadataLink.linking', 'Linking...') : t('authorMetadataLink.link', 'Link')} + {linking === candidate.foreignAuthorId ? t('authorMetadataLink.linking', 'Linking…') : t('authorMetadataLink.link', 'Link')} ))} diff --git a/web/src/components/EditAuthorModal.tsx b/web/src/components/EditAuthorModal.tsx index 98f0e845e..d236a3941 100644 --- a/web/src/components/EditAuthorModal.tsx +++ b/web/src/components/EditAuthorModal.tsx @@ -171,7 +171,7 @@ export default function EditAuthorModal({ author, onClose, onSaved }: Props) {
{loading ? ( -

{t('common.loading', 'Loading...')}

+

{t('common.loading', 'Loading…')}

) : ( <> {qualityProfiles.length > 0 && ( @@ -262,7 +262,7 @@ export default function EditAuthorModal({ author, onClose, onSaved }: Props) {
{!seriesLoaded ? ( -

{t('common.loading', 'Loading...')}

+

{t('common.loading', 'Loading…')}

) : authorSeries.length === 0 ? (

{t('editAuthorModal.monitoredSeriesEmpty', 'No series found for this author yet. Refresh the author to pull series data first.')} @@ -376,7 +376,7 @@ export default function EditAuthorModal({ author, onClose, onSaved }: Props) { disabled={loading || saving} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 rounded-md text-sm font-medium text-white" > - {saving ? t('common.saving', 'Saving...') : t('common.save', 'Save')} + {saving ? t('common.saving', 'Saving…') : t('common.save', 'Save')}

diff --git a/web/src/components/SeriesNameModal.tsx b/web/src/components/SeriesNameModal.tsx index 2c2ec4a3f..8e2011467 100644 --- a/web/src/components/SeriesNameModal.tsx +++ b/web/src/components/SeriesNameModal.tsx @@ -1,4 +1,5 @@ import { FormEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' interface Props { title: string @@ -9,6 +10,7 @@ interface Props { } export default function SeriesNameModal({ title, initialName = '', submitLabel, onClose, onSubmit }: Props) { + const { t } = useTranslation() const [name, setName] = useState(initialName) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -17,7 +19,7 @@ export default function SeriesNameModal({ title, initialName = '', submitLabel, event.preventDefault() const trimmed = name.trim() if (!trimmed) { - setError('Series name is required') + setError(t('series.nameModal.required')) return } setSaving(true) @@ -25,7 +27,7 @@ export default function SeriesNameModal({ title, initialName = '', submitLabel, try { await onSubmit(trimmed) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save series') + setError(err instanceof Error ? err.message : t('series.nameModal.failed')) } finally { setSaving(false) } @@ -46,14 +48,14 @@ export default function SeriesNameModal({ title, initialName = '', submitLabel,
setName(e.target.value)} - placeholder="Series name" + placeholder={t('series.nameModal.placeholder')} className="w-full bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-emerald-500" autoFocus /> @@ -65,14 +67,14 @@ export default function SeriesNameModal({ title, initialName = '', submitLabel, onClick={onClose} className="px-4 py-2 text-sm text-slate-600 dark:text-zinc-400 hover:text-slate-900 dark:hover:text-white" > - Cancel + {t('common.cancel')}
diff --git a/web/src/components/import/BookPicker.tsx b/web/src/components/import/BookPicker.tsx index fcc08488d..1460844b7 100644 --- a/web/src/components/import/BookPicker.tsx +++ b/web/src/components/import/BookPicker.tsx @@ -67,7 +67,7 @@ const BookPicker = forwardRef(function BookPicker({ onP )}
- {loading &&

{t('common.loading', 'Loading...')}

} + {loading &&

{t('common.loading', 'Loading…')}

} {!loading && term.trim().length >= 2 && results.length === 0 && (

{t('manualImport.noResults', 'No matching books in your library.')} diff --git a/web/src/i18n/locales/de.json b/web/src/i18n/locales/de.json index 30328442b..0b578c903 100644 --- a/web/src/i18n/locales/de.json +++ b/web/src/i18n/locales/de.json @@ -1,14 +1,13 @@ { "common": { - "loading": "Wird geladen...", + "loading": "Wird geladen…", "viewOnSource": "Auf {{source}} ansehen ↗", "links": "Links", "cancel": "Abbrechen", "delete": "Löschen", "edit": "Bearbeiten", "save": "Speichern", - "saving": "Wird gespeichert...", - "deleting": "Deleting...", + "saving": "Wird gespeichert…", "test": "Testen", "refresh": "Aktualisieren", "all": "Alle", @@ -85,7 +84,7 @@ "addAuthor": "+ Autor hinzufügen", "merge": "Zusammenführen", "mergeTip": "Zwei Autoren zusammenführen — Duplikate in eine kanonische Zeile zusammenklappen", - "searchPlaceholder": "Autoren suchen...", + "searchPlaceholder": "Autoren suchen…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "Neueste", @@ -110,7 +109,7 @@ }, "books": { "title": "Bücher", - "searchPlaceholder": "Bücher oder Autoren suchen...", + "searchPlaceholder": "Bücher oder Autoren suchen…", "sortLabel": "Sortieren:", "typeLabel": "Typ:", "sortTitleAZ": "A–Z", @@ -141,13 +140,13 @@ }, "wanted": { "title": "Gesucht", - "searchPlaceholder": "Nach Titel oder Autor suchen...", + "searchPlaceholder": "Nach Titel oder Autor suchen…", "countLabel": "{{filtered}} von {{total}}", "empty": "Keine gesuchten Bücher. Fügen Sie einen Autor hinzu, um zu beginnen.", "noMatch": "Keine Bücher entsprechen Ihrer Suche.", "selectAllPage": "Alle auf dieser Seite auswählen", "search": "Suchen", - "searching": "Suche läuft...", + "searching": "Suche läuft…", "unmonitor": "Nicht mehr überwachen", "grab": "Herunterladen", "grabbing": "Wird heruntergeladen…", @@ -193,7 +192,7 @@ "empty": "Blockliste ist leer", "emptyHint": "Fehlgeschlagene Downloads werden hier automatisch hinzugefügt, um erneutes Herunterladen zu verhindern", "deleteSelected": "Auswahl löschen ({{count}})", - "deleting": "Wird gelöscht...", + "deleting": "Wird gelöscht…", "colTitle": "Titel", "colReason": "Grund", "colDate": "Datum", @@ -373,7 +372,7 @@ "backupCreate": "Sicherung aller Bindery-Konfigurationen erstellen", "backupHint": "Enthält Autoren, Bücher, Indexer und Einstellungen", "backupButton": "Sicherung erstellen", - "backupCreating": "Wird erstellt...", + "backupCreating": "Wird erstellt…", "existingBackups": "Vorhandene Sicherungen:", "security": "Sicherheit", "logRetention": "Log Retention", @@ -388,7 +387,6 @@ }, "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often Bindery searches all wanted books against your indexers. Reduce if you have a large library and are hitting indexer API rate limits.", "searchIntervalRestart": "Takes effect after the next Bindery restart." }, "import": { @@ -428,7 +426,7 @@ "inLibrary": "In deiner Bibliothek", "open": "Öffnen", "openBook": "{{title}} öffnen", - "searching": "Suche läuft...", + "searching": "Suche läuft…", "select": "Auswählen", "selectBook": "{{title}} auswählen", "idMissing": "Dieses Ergebnis hat keine Buch-ID und kann nicht hinzugefügt werden", @@ -439,8 +437,8 @@ "noResults": "Keine Ergebnisse gefunden", "searchError": "Metadatenanbieter nicht erreichbar — {{error}}", "backToResults": "Zurück zu den Ergebnissen", - "adding": "Wird hinzugefügt...", - "searchPlaceholderAuthor": "Nach Autorenname suchen...", + "adding": "Wird hinzugefügt…", + "searchPlaceholderAuthor": "Nach Autorenname suchen…", "searchPlaceholderBook": "Titel, ISBN oder ASIN (zum Beispiel Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Autor hinzufügen", @@ -483,7 +481,7 @@ "keepLabel": "Behalten", "removeLabel": "Entfernen", "mergeButton": "Zusammenführen", - "merging": "Wird zusammengeführt...", + "merging": "Wird zusammengeführt…", "cancel": "Abbrechen" }, "pagination": { @@ -501,7 +499,6 @@ "title": "Discover", "subtitle": "Based on your library", "refresh": "Refresh", - "refreshing": "Refreshing...", "addToWanted": "Add to Wanted", "dismiss": "Dismiss", "dontSuggestAuthor": "Don't suggest {{author}}", diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 23f924949..b0cf655d8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,6 +1,6 @@ { "common": { - "loading": "Loading...", + "loading": "Loading…", "viewOnSource": "View on {{source}} ↗", "links": "Links", "gridView": "Grid view", @@ -9,8 +9,8 @@ "delete": "Delete", "edit": "Edit", "save": "Save", - "saving": "Saving...", - "deleting": "Deleting...", + "saving": "Saving…", + "deleting": "Deleting…", "test": "Test", "testing": "Testing…", "refresh": "Refresh", @@ -385,7 +385,7 @@ "refreshAllDoneWithFailures": "Refreshed {{done}} authors ({{failed}} failed)", "refreshAllFailed": "Metadata refresh failed: {{message}}", "refreshAllError": "Unknown error", - "searchPlaceholder": "Search authors...", + "searchPlaceholder": "Search authors…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortFirstNameAZ": "First name A–Z", @@ -584,7 +584,7 @@ "title": "Books", "countLabel": "{{count}} books", "legendTitle": "What do the status labels mean?", - "searchPlaceholder": "Search books or authors...", + "searchPlaceholder": "Search books or authors…", "sortLabel": "Sort:", "typeLabel": "Type:", "sortTitleAZ": "A–Z", @@ -624,13 +624,13 @@ }, "wanted": { "title": "Wanted", - "searchPlaceholder": "Search by title or author...", + "searchPlaceholder": "Search by title or author…", "countLabel": "{{filtered}} of {{total}}", "empty": "No wanted books. Add an author to start tracking.", "noMatch": "No books match your search.", "selectAllPage": "Select all on this page", "search": "Search", - "searching": "Searching...", + "searching": "Searching…", "unmonitor": "Unmonitor", "grab": "Grab", "grabbing": "Grabbing…", @@ -733,7 +733,7 @@ } }, "series": { - "searchPlaceholder": "Search series...", + "searchPlaceholder": "Search series…", "noMatch": "No series match \"{{query}}\"", "deleteConfirm": "Delete \"{{title}}\" from Series? Linked books will stay in your library.", "deleteTitle": "Remove series", @@ -748,7 +748,76 @@ "unlinked": "Not linked to Hardcover", "shortlisted": "Shortlisted" }, - "filterMissingHint": "Series showing a missing badge. With Hardcover linking on, this counts catalogue books you do not have yet, exactly as the badge does." + "filterMissingHint": "Series showing a missing badge. With Hardcover linking on, this counts catalogue books you do not have yet, exactly as the badge does.", + "title": "Series", + "count_one": "{{count}} series", + "count_other": "{{count}} series", + "addSeries": "Add Series", + "renameSeries": "Rename Series", + "rename": "Rename", + "addBook": "Add Book", + "emptyTitle": "No series found", + "emptyHint": "Series are populated automatically from your monitored authors' books", + "noBooks": "No books in this series yet", + "missingBadge": "{{count}} missing", + "bookCount_one": "{{count}} book", + "bookCount_other": "{{count}} books", + "bookFallback": "Book {{id}}", + "excluded": "Excluded", + "shortlist": { + "add": "Add to shortlist", + "remove": "Remove from shortlist", + "on": "Shortlisted", + "off": "Not shortlisted", + "hint": "Marks the series so you can find it again. Bindery does not yet check a shortlisted series for new books on its own; use Fill gaps." + }, + "genre": { + "set": "Set genre", + "active": "Genre ✓", + "setHint": "Set the same genres on every book in this series and lock them against metadata refresh", + "activeHint": "Genre override active: {{genres}}. Books added later inherit it. Click to edit or clear.", + "activeHintEmpty": "Genre override active (no genres). Books added later inherit it. Click to edit or clear.", + "prompt": "Set genres for every book in \"{{title}}\" (comma-separated). Genres are locked against metadata refresh and applied to books added later. Leave empty to remove the override.", + "removed": "Genre override removed", + "applied_one": "Genres set on {{count}} book", + "applied_other": "Genres set on {{count}} books", + "failed": "Genre apply failed" + }, + "fill": { + "button": "Fill gaps", + "queuing": "Queuing…", + "nothing": "Nothing to fill", + "queued_one": "{{count}} book queued", + "queued_other": "{{count}} books queued", + "failed": "Failed" + }, + "hardcover": { + "searching": "Searching…", + "search": "Search", + "searchTitle": "Search Hardcover series", + "linkedTo": "Linked to {{title}}", + "autoLink": "Auto link", + "manualLink": "Manual link", + "heading": "Hardcover: {{title}}", + "diffSummary": "{{present}} matched · {{missing}} missing", + "checking": "Checking Hardcover catalog…", + "loadingBooks": "Loading Hardcover books…", + "diffFailed": "Failed to load Hardcover diff", + "searchFailed": "Failed to search Hardcover", + "formatLabel": "Format to add", + "formatTitle": "Choose which format to add", + "addAll": "add all", + "add": "add", + "addTitle": "Add this missing Hardcover book and search indexers", + "moreMissing_one": "{{count}} more missing book", + "moreMissing_other": "{{count}} more missing books" + }, + "nameModal": { + "name": "Name", + "placeholder": "Series name", + "required": "Series name is required", + "failed": "Failed to save series" + } }, "importHints": { "heading": "Already have files on disk?", @@ -762,7 +831,7 @@ "empty": "Blocklist is empty", "emptyHint": "Failed downloads are automatically added here to prevent re-grabbing", "deleteSelected": "Delete selected ({{count}})", - "deleting": "Deleting...", + "deleting": "Deleting…", "colTitle": "Title", "colReason": "Reason", "colDate": "Date", @@ -902,6 +971,29 @@ "actionFailed": "That did not work: {{error}}", "lastCheckIn": "Calibre last checked in {{time}}", "neverCheckedIn": "Calibre has not checked in since Bindery started" + }, + "pushAll": { + "label": "Push all to Calibre", + "description": "Queue every imported book for the Calibre Bridge plugin. Books already delivered are not sent again, and queued books go out as soon as Calibre is reachable.", + "bridgeUnreachable": "Bridge not reachable right now. Books you push will wait in the queue until it is.", + "needPluginUrl": "Set the plugin URL first", + "queueing": "Queueing…", + "startFailed": "Push failed to start", + "working": "Working…", + "idle": "Idle", + "pushed": "Pushed", + "alreadyInCalibre": "Already in Calibre", + "failed": "Failed", + "skipped": "Skipped", + "syncFailed": "Sync failed: {{error}}", + "done": "Done. Pushed {{pushed}}, already in Calibre {{already}}, failed {{failed}}, skipped {{skipped}}.", + "errorTitle": "Title", + "errorReason": "Reason", + "skipTitle": "Not pushed", + "skipReason": "Why", + "close": "Close", + "closeRunningHint": "Close. Queued books keep going out in the background.", + "closeAndKeepGoing": "Close and keep going" } }, "indexers": { @@ -1008,7 +1100,7 @@ "unknownError": "Unknown error", "diagnose": { "button": "Diagnose", - "running": "Diagnosing...", + "running": "Diagnosing…", "failed": "Diagnose could not run: {{error}}", "heading": "Diagnosis", "copyReport": "Copy report", @@ -1288,7 +1380,7 @@ "backupHint": "Includes authors, books, indexers, and settings", "backupSecretsNote": "A backup is a full copy of the database, so it also contains every credential Bindery holds in plain text: indexer and Prowlarr API keys, download client passwords, OIDC client secrets, session signing secrets, the Bindery API key and any external service tokens. Treat a backup file like a password. Do not attach one to a bug report or sync it somewhere you would not put those credentials.", "backupButton": "Create Backup", - "backupCreating": "Creating...", + "backupCreating": "Creating…", "backupLabelPlaceholder": "Label (optional)", "existingBackups": "Existing backups:", "security": "Security", @@ -1314,7 +1406,7 @@ "defaultLibraryLocationLink": "Manage in Root Folders →", "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often the scheduled sweep searches every wanted, monitored book against your indexers. Reduce it if you have a large library and are hitting indexer rate limits. Adding a book with automatic search on, or moving a book to wanted from its own page, searches straight away without waiting for the sweep. All of it needs Enable automatic grabbing, under Metadata Profiles, to be on.", + "searchIntervalHint": "How often the scheduled sweep searches every wanted, monitored book against your indexers. Choose a longer interval if you have a large library and are hitting indexer rate limits, since a shorter one searches more often. Adding a book with automatic search on, or moving a book to wanted from its own page, searches straight away without waiting for the sweep. All of it needs Enable automatic grabbing, under Metadata Profiles, to be on.", "searchIntervalRestart": "Takes effect after the next Bindery restart.", "hardcoverSync": "Hardcover list sync", "hardcoverSyncIntervalLabel": "Hardcover list sync interval", @@ -1382,7 +1474,7 @@ "hardcoverClearOverride": "Clear override", "hardcoverList": "List", "hardcoverListPlaceholder": "Enter token to load lists", - "hardcoverListLoading": "Loading lists...", + "hardcoverListLoading": "Loading lists…", "hardcoverNoLists": "No lists found", "hardcoverEmpty": "No Hardcover import lists configured.", "hardcoverLastSync": "Last sync: {{date}}", @@ -1497,7 +1589,7 @@ "booksDivider": "Books", "partialError": "Some results could not be loaded: {{error}}", "selectAuthor": "Select {{name}}", - "searching": "Searching...", + "searching": "Searching…", "select": "Select", "selectBook": "Select {{title}}", "inLibrary": "In your library", @@ -1515,8 +1607,8 @@ "showHiddenResults_one": "Show {{count}} hidden result", "showHiddenResults_other": "Show {{count}} hidden results", "backToResults": "Back to results", - "adding": "Adding...", - "searchPlaceholderAuthor": "Search by author name...", + "adding": "Adding…", + "searchPlaceholderAuthor": "Search by author name…", "searchPlaceholderBook": "Title, ISBN, or ASIN (for example, Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Add author", @@ -1542,7 +1634,7 @@ "future": "Adds the whole catalogue and searches only for the books not released yet. New releases join the list when you refresh the author, not on their own.", "latest": "Adds the whole catalogue and searches for the newest {{latest}}." }, - "loadingDefaults": "Loading your library defaults...", + "loadingDefaults": "Loading your library defaults…", "customizeMonitoring": "Monitoring", "autoGrabLabel": "Auto-grab books on add", "autoGrabHint": "Bindery will always fetch the book catalogue. Enable this to also queue downloads immediately.", @@ -1584,12 +1676,12 @@ "actionFindBetter": "Find better metadata", "verify": "View on {{source}}", "title": "Link metadata", - "searchPlaceholder": "Search by author name...", + "searchPlaceholder": "Search by author name…", "search": "Search", - "searching": "Searching...", + "searching": "Searching…", "searchFailed": "Search failed", "link": "Link", - "linking": "Linking...", + "linking": "Linking…", "linkFailed": "Link failed", "openExisting": "Open existing author", "books": "{{count}} books", @@ -1603,7 +1695,7 @@ "keepLabel": "Keep", "removeLabel": "Remove", "mergeButton": "Merge", - "merging": "Merging...", + "merging": "Merging…", "cancel": "Cancel", "confirmBody": "Merge \"{{source}}\" into \"{{target}}\"?\n\n• {{count}} book(s) will be reparented.\n• \"{{source}}\" will be kept as an alias of \"{{target}}\".\n• This cannot be undone.", "confirmAcknowledge": "I understand this cannot be undone." @@ -1623,7 +1715,7 @@ "title": "Discover", "subtitle": "Based on your library", "refresh": "Refresh", - "refreshing": "Refreshing...", + "refreshing": "Refreshing…", "addToWanted": "Add to Wanted", "dismiss": "Dismiss", "dontSuggestAuthor": "Don't suggest {{author}}", @@ -1741,7 +1833,7 @@ "anyFormat": "Any format", "requestBook": "Request book", "requestAuthor": "Request author", - "sending": "Sending...", + "sending": "Sending…", "failed": "Could not send the request" }, "mine": { @@ -1796,7 +1888,7 @@ "format": "Format", "searchOnAdd": "Search indexers after adding", "confirmApprove": "Approve and add", - "approving": "Adding...", + "approving": "Adding…", "approveFailed": "Could not approve the request" } }, diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index d53f2c424..bac51a7ec 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -1,14 +1,13 @@ { "common": { - "loading": "Cargando...", + "loading": "Cargando…", "viewOnSource": "Ver en {{source}} ↗", "links": "Enlaces", "cancel": "Cancelar", "delete": "Eliminar", "edit": "Editar", "save": "Guardar", - "saving": "Guardando...", - "deleting": "Deleting...", + "saving": "Guardando…", "test": "Probar", "testing": "Probando…", "refresh": "Actualizar", @@ -97,7 +96,7 @@ "addAuthor": "+ Añadir autor", "merge": "Fusionar", "mergeTip": "Fusionar dos autores — colapsar duplicados en una sola entrada", - "searchPlaceholder": "Buscar autores...", + "searchPlaceholder": "Buscar autores…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "Recientes", @@ -122,7 +121,7 @@ }, "books": { "title": "Libros", - "searchPlaceholder": "Buscar libros o autores...", + "searchPlaceholder": "Buscar libros o autores…", "sortLabel": "Ordenar:", "typeLabel": "Tipo:", "sortTitleAZ": "A–Z", @@ -157,13 +156,13 @@ }, "wanted": { "title": "Buscados", - "searchPlaceholder": "Buscar por título o autor...", + "searchPlaceholder": "Buscar por título o autor…", "countLabel": "{{filtered}} de {{total}}", "empty": "No hay libros buscados. Añade un autor para empezar.", "noMatch": "Ningún libro coincide con tu búsqueda.", "selectAllPage": "Seleccionar todo en esta página", "search": "Buscar", - "searching": "Buscando...", + "searching": "Buscando…", "unmonitor": "Dejar de monitorizar", "grab": "Descargar", "grabbing": "Descargando…", @@ -209,7 +208,7 @@ "empty": "La lista de bloqueo está vacía", "emptyHint": "Las descargas fallidas se añaden aquí automáticamente para evitar reintentos", "deleteSelected": "Eliminar seleccionados ({{count}})", - "deleting": "Eliminando...", + "deleting": "Eliminando…", "colTitle": "Título", "colReason": "Motivo", "colDate": "Fecha", @@ -401,7 +400,7 @@ "backupCreate": "Crear una copia de seguridad de toda la configuración de Bindery", "backupHint": "Incluye autores, libros, indexadores y ajustes", "backupButton": "Crear copia de seguridad", - "backupCreating": "Creando...", + "backupCreating": "Creando…", "existingBackups": "Copias de seguridad existentes:", "security": "Seguridad", "logRetention": "Log Retention", @@ -416,7 +415,6 @@ }, "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often Bindery searches all wanted books against your indexers. Reduce if you have a large library and are hitting indexer API rate limits.", "searchIntervalRestart": "Takes effect after the next Bindery restart." }, "import": { @@ -442,7 +440,7 @@ "hardcoverTokenPlaceholder": "Token de API de Hardcover", "hardcoverList": "Lista", "hardcoverListPlaceholder": "Introduce el token para cargar las listas", - "hardcoverListLoading": "Cargando listas...", + "hardcoverListLoading": "Cargando listas…", "hardcoverNoLists": "No se encontraron listas", "hardcoverEmpty": "No hay listas de importación de Hardcover configuradas.", "hardcoverLastSync": "Última sincronización: {{date}}", @@ -470,7 +468,7 @@ "inLibrary": "En tu biblioteca", "open": "Abrir", "openBook": "Abrir {{title}}", - "searching": "Buscando...", + "searching": "Buscando…", "select": "Seleccionar", "selectBook": "Seleccionar {{title}}", "idMissing": "Este resultado no tiene ID de libro y no se puede añadir", @@ -481,8 +479,8 @@ "noResults": "Sin resultados", "searchError": "No se pudo contactar con el proveedor de metadatos — {{error}}", "backToResults": "Volver a los resultados", - "adding": "Añadiendo...", - "searchPlaceholderAuthor": "Buscar por nombre de autor...", + "adding": "Añadiendo…", + "searchPlaceholderAuthor": "Buscar por nombre de autor…", "searchPlaceholderBook": "Título, ISBN o ASIN (por ejemplo, Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Añadir autor", @@ -525,7 +523,7 @@ "keepLabel": "Conservar", "removeLabel": "Eliminar", "mergeButton": "Fusionar", - "merging": "Fusionando...", + "merging": "Fusionando…", "cancel": "Cancelar" }, "pagination": { @@ -543,7 +541,7 @@ "title": "Descubrir", "subtitle": "Basado en tu biblioteca", "refresh": "Actualizar", - "refreshing": "Actualizando...", + "refreshing": "Actualizando…", "addToWanted": "Añadir a Buscados", "dismiss": "Descartar", "dontSuggestAuthor": "No sugerir {{author}}", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 0b5c17391..012b6fa2a 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1,14 +1,13 @@ { "common": { - "loading": "Chargement...", + "loading": "Chargement…", "viewOnSource": "Voir sur {{source}} ↗", "links": "Liens", "cancel": "Annuler", "delete": "Supprimer", "edit": "Modifier", "save": "Enregistrer", - "saving": "Enregistrement...", - "deleting": "Deleting...", + "saving": "Enregistrement…", "test": "Tester", "refresh": "Actualiser", "all": "Tout", @@ -85,7 +84,7 @@ "addAuthor": "+ Ajouter un auteur", "merge": "Fusionner", "mergeTip": "Fusionner deux auteurs — regrouper les doublons en une seule entrée", - "searchPlaceholder": "Rechercher des auteurs...", + "searchPlaceholder": "Rechercher des auteurs…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "Récent", @@ -110,7 +109,7 @@ }, "books": { "title": "Livres", - "searchPlaceholder": "Rechercher des livres ou des auteurs...", + "searchPlaceholder": "Rechercher des livres ou des auteurs…", "sortLabel": "Trier :", "typeLabel": "Type :", "sortTitleAZ": "A–Z", @@ -141,13 +140,13 @@ }, "wanted": { "title": "Recherchés", - "searchPlaceholder": "Rechercher par titre ou auteur...", + "searchPlaceholder": "Rechercher par titre ou auteur…", "countLabel": "{{filtered}} sur {{total}}", "empty": "Aucun livre recherché. Ajoutez un auteur pour commencer.", "noMatch": "Aucun livre ne correspond à votre recherche.", "selectAllPage": "Tout sélectionner sur cette page", "search": "Rechercher", - "searching": "Recherche en cours...", + "searching": "Recherche en cours…", "unmonitor": "Arrêter la surveillance", "grab": "Télécharger", "grabbing": "Téléchargement…", @@ -193,7 +192,7 @@ "empty": "La liste de blocage est vide", "emptyHint": "Les téléchargements échoués sont automatiquement ajoutés ici pour éviter de les télécharger à nouveau", "deleteSelected": "Supprimer la sélection ({{count}})", - "deleting": "Suppression...", + "deleting": "Suppression…", "colTitle": "Titre", "colReason": "Raison", "colDate": "Date", @@ -373,7 +372,7 @@ "backupCreate": "Créer une sauvegarde de toute la configuration de Bindery", "backupHint": "Inclut les auteurs, les livres, les indexeurs et les paramètres", "backupButton": "Créer une sauvegarde", - "backupCreating": "Création en cours...", + "backupCreating": "Création en cours…", "existingBackups": "Sauvegardes existantes :", "security": "Sécurité", "logRetention": "Log Retention", @@ -388,7 +387,6 @@ }, "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often Bindery searches all wanted books against your indexers. Reduce if you have a large library and are hitting indexer API rate limits.", "searchIntervalRestart": "Takes effect after the next Bindery restart." }, "import": { @@ -430,7 +428,7 @@ "inLibrary": "Dans votre bibliothèque", "open": "Ouvrir", "openBook": "Ouvrir {{title}}", - "searching": "Recherche en cours...", + "searching": "Recherche en cours…", "select": "Sélectionner", "selectBook": "Sélectionner {{title}}", "idMissing": "Ce résultat n'a pas d'identifiant de livre et ne peut pas être ajouté", @@ -441,8 +439,8 @@ "noResults": "Aucun résultat", "searchError": "Impossible d'atteindre le fournisseur de métadonnées — {{error}}", "backToResults": "Retour aux résultats", - "adding": "Ajout en cours...", - "searchPlaceholderAuthor": "Rechercher par nom d'auteur...", + "adding": "Ajout en cours…", + "searchPlaceholderAuthor": "Rechercher par nom d'auteur…", "searchPlaceholderBook": "Titre, ISBN ou ASIN (par exemple Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Ajouter l'auteur", @@ -485,7 +483,7 @@ "keepLabel": "Conserver", "removeLabel": "Supprimer", "mergeButton": "Fusionner", - "merging": "Fusion en cours...", + "merging": "Fusion en cours…", "cancel": "Annuler" }, "pagination": { @@ -503,7 +501,6 @@ "title": "Discover", "subtitle": "Based on your library", "refresh": "Refresh", - "refreshing": "Refreshing...", "addToWanted": "Add to Wanted", "dismiss": "Dismiss", "dontSuggestAuthor": "Don't suggest {{author}}", diff --git a/web/src/i18n/locales/id.json b/web/src/i18n/locales/id.json index ae7db9847..5c2b7cd89 100644 --- a/web/src/i18n/locales/id.json +++ b/web/src/i18n/locales/id.json @@ -1,14 +1,13 @@ { "common": { - "loading": "Memuat...", + "loading": "Memuat…", "viewOnSource": "Lihat di {{source}} ↗", "links": "Tautan", "cancel": "Batal", "delete": "Hapus", "edit": "Ubah", "save": "Simpan", - "saving": "Menyimpan...", - "deleting": "Deleting...", + "saving": "Menyimpan…", "test": "Uji", "testing": "Menguji…", "refresh": "Muat ulang", @@ -97,7 +96,7 @@ "addAuthor": "+ Tambah Penulis", "merge": "Gabung", "mergeTip": "Gabungkan dua penulis — satukan duplikat menjadi satu baris kanonis", - "searchPlaceholder": "Cari penulis...", + "searchPlaceholder": "Cari penulis…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "Terbaru", @@ -122,7 +121,7 @@ }, "books": { "title": "Buku", - "searchPlaceholder": "Cari buku atau penulis...", + "searchPlaceholder": "Cari buku atau penulis…", "sortLabel": "Urutkan:", "typeLabel": "Jenis:", "sortTitleAZ": "A–Z", @@ -157,13 +156,13 @@ }, "wanted": { "title": "Dicari", - "searchPlaceholder": "Cari berdasarkan judul atau penulis...", + "searchPlaceholder": "Cari berdasarkan judul atau penulis…", "countLabel": "{{filtered}} dari {{total}}", "empty": "Tidak ada buku dicari. Tambahkan penulis untuk mulai memantau.", "noMatch": "Tidak ada buku cocok dengan pencarian Anda.", "selectAllPage": "Pilih semua di halaman ini", "search": "Cari", - "searching": "Mencari...", + "searching": "Mencari…", "unmonitor": "Berhenti pantau", "grab": "Ambil", "grabbing": "Mengambil…", @@ -209,7 +208,7 @@ "empty": "Daftar blokir kosong", "emptyHint": "Unduhan yang gagal otomatis ditambahkan di sini untuk mencegah pengambilan ulang", "deleteSelected": "Hapus yang dipilih ({{count}})", - "deleting": "Menghapus...", + "deleting": "Menghapus…", "colTitle": "Judul", "colReason": "Alasan", "colDate": "Tanggal", @@ -397,7 +396,7 @@ "backupCreate": "Buat cadangan seluruh konfigurasi Bindery", "backupHint": "Termasuk penulis, buku, indexer, dan pengaturan", "backupButton": "Buat Cadangan", - "backupCreating": "Membuat...", + "backupCreating": "Membuat…", "existingBackups": "Cadangan yang ada:", "security": "Keamanan", "logRetention": "Log Retention", @@ -412,7 +411,6 @@ }, "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often Bindery searches all wanted books against your indexers. Reduce if you have a large library and are hitting indexer API rate limits.", "searchIntervalRestart": "Takes effect after the next Bindery restart." }, "import": { @@ -438,7 +436,7 @@ "hardcoverTokenPlaceholder": "Token API Hardcover", "hardcoverList": "Daftar", "hardcoverListPlaceholder": "Masukkan token untuk memuat daftar", - "hardcoverListLoading": "Memuat daftar...", + "hardcoverListLoading": "Memuat daftar…", "hardcoverNoLists": "Tidak ada daftar ditemukan", "hardcoverEmpty": "Tidak ada daftar impor Hardcover terkonfigurasi.", "hardcoverLastSync": "Sinkronisasi terakhir: {{date}}", @@ -466,7 +464,7 @@ "inLibrary": "Di perpustakaan Anda", "open": "Buka", "openBook": "Buka {{title}}", - "searching": "Mencari...", + "searching": "Mencari…", "select": "Pilih", "selectBook": "Pilih {{title}}", "idMissing": "Hasil ini tidak memiliki ID buku dan tidak dapat ditambahkan", @@ -476,8 +474,8 @@ "noResults": "Tidak ada hasil ditemukan", "searchError": "Tidak dapat menghubungi penyedia metadata — {{error}}", "backToResults": "Kembali ke hasil", - "adding": "Menambahkan...", - "searchPlaceholderAuthor": "Cari berdasarkan nama penulis...", + "adding": "Menambahkan…", + "searchPlaceholderAuthor": "Cari berdasarkan nama penulis…", "searchPlaceholderBook": "Judul, ISBN, atau ASIN (misalnya Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Tambah penulis", @@ -519,7 +517,7 @@ "keepLabel": "Pertahankan", "removeLabel": "Hapus", "mergeButton": "Gabung", - "merging": "Menggabungkan...", + "merging": "Menggabungkan…", "cancel": "Batal" }, "pagination": { @@ -537,7 +535,7 @@ "title": "Temukan", "subtitle": "Berdasarkan perpustakaan Anda", "refresh": "Muat ulang", - "refreshing": "Memuat ulang...", + "refreshing": "Memuat ulang…", "addToWanted": "Tambah ke Dicari", "dismiss": "Tutup", "dontSuggestAuthor": "Jangan sarankan {{author}}", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index c7dbba8dd..36098d4a3 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -1,6 +1,6 @@ { "common": { - "loading": "로딩 중...", + "loading": "로딩 중…", "viewOnSource": "{{source}}에서 보기 ↗", "links": "링크", "gridView": "그리드 보기", @@ -9,8 +9,8 @@ "delete": "삭제", "edit": "편집", "save": "저장", - "saving": "저장 중...", - "deleting": "삭제 중...", + "saving": "저장 중…", + "deleting": "삭제 중…", "test": "테스트", "testing": "테스트 중…", "refresh": "새로고침", @@ -122,7 +122,7 @@ "refreshAllDoneWithFailures": "저자 {{done}}명을 새로고침했습니다 ({{failed}}명 실패)", "refreshAllFailed": "메타데이터 새로고침 실패: {{message}}", "refreshAllError": "알 수 없는 오류", - "searchPlaceholder": "저자 검색...", + "searchPlaceholder": "저자 검색…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "최근", @@ -174,7 +174,7 @@ "title": "도서", "countLabel": "도서 {{count}}권", "legendTitle": "상태 레이블의 의미는?", - "searchPlaceholder": "도서 또는 저자 검색...", + "searchPlaceholder": "도서 또는 저자 검색…", "sortLabel": "정렬:", "typeLabel": "유형:", "sortTitleAZ": "A–Z", @@ -209,13 +209,13 @@ }, "wanted": { "title": "찾는 중", - "searchPlaceholder": "제목 또는 저자로 검색...", + "searchPlaceholder": "제목 또는 저자로 검색…", "countLabel": "{{total}} 중 {{filtered}}", "empty": "찾는 도서가 없습니다. 저자를 추가하여 추적을 시작하세요.", "noMatch": "검색 결과와 일치하는 도서가 없습니다.", "selectAllPage": "이 페이지 전체 선택", "search": "검색", - "searching": "검색 중...", + "searching": "검색 중…", "unmonitor": "모니터링 해제", "grab": "가져오기", "grabbing": "가져오는 중…", @@ -288,7 +288,7 @@ "empty": "차단 목록이 비어 있습니다", "emptyHint": "실패한 다운로드는 재가져오기를 방지하기 위해 자동으로 여기에 추가됩니다", "deleteSelected": "선택 항목 삭제 ({{count}})", - "deleting": "삭제 중...", + "deleting": "삭제 중…", "colTitle": "제목", "colReason": "사유", "colDate": "날짜", @@ -597,7 +597,7 @@ "backupCreate": "Bindery 설정 전체 백업 생성", "backupHint": "저자, 도서, 인덱서, 설정을 포함합니다", "backupButton": "백업 생성", - "backupCreating": "생성 중...", + "backupCreating": "생성 중…", "existingBackups": "기존 백업:", "security": "보안", "logRetention": "로그 보존", @@ -648,14 +648,14 @@ "hardcoverNotSelected": "선택 안 함", "hardcoverBooksCount": "도서 {{count}}권", "hardcoverSyncNow": "지금 동기화", - "hardcoverSyncing": "동기화 중...", + "hardcoverSyncing": "동기화 중…", "hardcoverTokenOverride": "토큰 덮어쓰기", "hardcoverTokenOverridePlaceholderConfigured": "덮어쓰기 토큰이 숨겨져 있습니다. 새 토큰을 입력하면 교체됩니다.", "hardcoverTokenOverridePlaceholder": "목록별 토큰 덮어쓰기 값을 붙여넣으세요", "hardcoverClearOverride": "덮어쓰기 초기화", "hardcoverList": "목록", "hardcoverListPlaceholder": "토큰을 입력하면 목록이 로드됩니다", - "hardcoverListLoading": "목록 로드 중...", + "hardcoverListLoading": "목록 로드 중…", "hardcoverNoLists": "목록 없음", "hardcoverEmpty": "Hardcover 가져오기 목록이 설정되지 않았습니다.", "hardcoverLastSync": "마지막 동기화: {{date}}", @@ -732,7 +732,7 @@ "inLibrary": "내 라이브러리에 있음", "open": "열기", "openBook": "{{title}} 열기", - "searching": "검색 중...", + "searching": "검색 중…", "select": "선택", "selectBook": "{{title}} 선택", "idMissing": "이 결과에는 책 ID가 없어 추가할 수 없습니다", @@ -745,8 +745,8 @@ "showHiddenResults_one": "숨겨진 결과 {{count}}개 표시", "showHiddenResults_other": "숨겨진 결과 {{count}}개 표시", "backToResults": "결과로 돌아가기", - "adding": "추가 중...", - "searchPlaceholderAuthor": "저자 이름으로 검색...", + "adding": "추가 중…", + "searchPlaceholderAuthor": "저자 이름으로 검색…", "searchPlaceholderBook": "제목, ISBN 또는 ASIN (예: Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "저자 추가", @@ -790,12 +790,12 @@ "actionLink": "메타데이터 연결", "actionFindBetter": "더 나은 메타데이터 찾기", "title": "메타데이터 연결", - "searchPlaceholder": "저자 이름으로 검색...", + "searchPlaceholder": "저자 이름으로 검색…", "search": "검색", - "searching": "검색 중...", + "searching": "검색 중…", "searchFailed": "검색 실패", "link": "연결", - "linking": "연결 중...", + "linking": "연결 중…", "linkFailed": "연결 실패", "openExisting": "기존 저자 열기", "books": "도서 {{count}}권", @@ -808,7 +808,7 @@ "keepLabel": "유지", "removeLabel": "삭제", "mergeButton": "병합", - "merging": "병합 중...", + "merging": "병합 중…", "cancel": "취소" }, "pagination": { @@ -826,7 +826,7 @@ "title": "탐색", "subtitle": "내 라이브러리 기반", "refresh": "새로고침", - "refreshing": "새로고침 중...", + "refreshing": "새로고침 중…", "addToWanted": "찾는 중에 추가", "dismiss": "닫기", "dontSuggestAuthor": "{{author}} 추천 안 함", diff --git a/web/src/i18n/locales/nl.json b/web/src/i18n/locales/nl.json index 779a4304b..ae43fb766 100644 --- a/web/src/i18n/locales/nl.json +++ b/web/src/i18n/locales/nl.json @@ -1,14 +1,13 @@ { "common": { - "loading": "Laden...", + "loading": "Laden…", "viewOnSource": "Bekijken op {{source}} ↗", "links": "Links", "cancel": "Annuleren", "delete": "Verwijderen", "edit": "Bewerken", "save": "Opslaan", - "saving": "Opslaan...", - "deleting": "Deleting...", + "saving": "Opslaan…", "test": "Testen", "refresh": "Vernieuwen", "all": "Alle", @@ -85,7 +84,7 @@ "addAuthor": "+ Auteur toevoegen", "merge": "Samenvoegen", "mergeTip": "Twee auteurs samenvoegen — duplicaten samenvoegen tot één canonieke rij", - "searchPlaceholder": "Auteurs zoeken...", + "searchPlaceholder": "Auteurs zoeken…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "Recent", @@ -110,7 +109,7 @@ }, "books": { "title": "Boeken", - "searchPlaceholder": "Boeken of auteurs zoeken...", + "searchPlaceholder": "Boeken of auteurs zoeken…", "sortLabel": "Sorteren:", "typeLabel": "Type:", "sortTitleAZ": "A–Z", @@ -141,13 +140,13 @@ }, "wanted": { "title": "Gewenst", - "searchPlaceholder": "Zoeken op titel of auteur...", + "searchPlaceholder": "Zoeken op titel of auteur…", "countLabel": "{{filtered}} van {{total}}", "empty": "Geen gewenste boeken. Voeg een auteur toe om te beginnen.", "noMatch": "Geen boeken komen overeen met uw zoekopdracht.", "selectAllPage": "Alles op deze pagina selecteren", "search": "Zoeken", - "searching": "Zoeken...", + "searching": "Zoeken…", "unmonitor": "Niet meer monitoren", "grab": "Downloaden", "grabbing": "Downloaden…", @@ -193,7 +192,7 @@ "empty": "Blokkeerlijst is leeg", "emptyHint": "Mislukte downloads worden hier automatisch toegevoegd om opnieuw downloaden te voorkomen", "deleteSelected": "Selectie verwijderen ({{count}})", - "deleting": "Verwijderen...", + "deleting": "Verwijderen…", "colTitle": "Titel", "colReason": "Reden", "colDate": "Datum", @@ -373,7 +372,7 @@ "backupCreate": "Een back-up maken van alle Bindery-configuratie", "backupHint": "Inclusief auteurs, boeken, indexers en instellingen", "backupButton": "Back-up maken", - "backupCreating": "Aanmaken...", + "backupCreating": "Aanmaken…", "existingBackups": "Bestaande back-ups:", "security": "Beveiliging", "logRetention": "Log Retention", @@ -388,7 +387,6 @@ }, "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often Bindery searches all wanted books against your indexers. Reduce if you have a large library and are hitting indexer API rate limits.", "searchIntervalRestart": "Takes effect after the next Bindery restart." }, "import": { @@ -428,7 +426,7 @@ "inLibrary": "In je bibliotheek", "open": "Openen", "openBook": "{{title}} openen", - "searching": "Zoeken...", + "searching": "Zoeken…", "select": "Selecteren", "selectBook": "{{title}} selecteren", "idMissing": "Dit resultaat heeft geen boek-ID en kan niet worden toegevoegd", @@ -439,8 +437,8 @@ "noResults": "Geen resultaten gevonden", "searchError": "Metadataprovider niet bereikbaar — {{error}}", "backToResults": "Terug naar resultaten", - "adding": "Toevoegen...", - "searchPlaceholderAuthor": "Zoeken op auteursnaam...", + "adding": "Toevoegen…", + "searchPlaceholderAuthor": "Zoeken op auteursnaam…", "searchPlaceholderBook": "Titel, ISBN of ASIN (bijvoorbeeld Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Auteur toevoegen", @@ -483,7 +481,7 @@ "keepLabel": "Behouden", "removeLabel": "Verwijderen", "mergeButton": "Samenvoegen", - "merging": "Samenvoegen...", + "merging": "Samenvoegen…", "cancel": "Annuleren" }, "pagination": { @@ -501,7 +499,6 @@ "title": "Discover", "subtitle": "Based on your library", "refresh": "Refresh", - "refreshing": "Refreshing...", "addToWanted": "Add to Wanted", "dismiss": "Dismiss", "dontSuggestAuthor": "Don't suggest {{author}}", diff --git a/web/src/i18n/locales/tl.json b/web/src/i18n/locales/tl.json index fc9ac285c..70d1ea00a 100644 --- a/web/src/i18n/locales/tl.json +++ b/web/src/i18n/locales/tl.json @@ -1,14 +1,13 @@ { "common": { - "loading": "Naglo-load...", + "loading": "Naglo-load…", "viewOnSource": "Tingnan sa {{source}} ↗", "links": "Mga link", "cancel": "Kanselahin", "delete": "Tanggalin", "edit": "I-edit", "save": "I-save", - "saving": "Nagse-save...", - "deleting": "Deleting...", + "saving": "Nagse-save…", "test": "Subukan", "testing": "Sinusubukan…", "refresh": "I-refresh", @@ -97,7 +96,7 @@ "addAuthor": "+ Magdagdag ng May-akda", "merge": "Pagsamahin", "mergeTip": "Pagsamahin ang dalawang may-akda — pagsamahin ang mga duplikado sa isang canonical na row", - "searchPlaceholder": "Maghanap ng mga may-akda...", + "searchPlaceholder": "Maghanap ng mga may-akda…", "sortAZ": "A–Z", "sortZA": "Z–A", "sortRecent": "Kamakailan", @@ -122,7 +121,7 @@ }, "books": { "title": "Mga Libro", - "searchPlaceholder": "Maghanap ng mga libro o may-akda...", + "searchPlaceholder": "Maghanap ng mga libro o may-akda…", "sortLabel": "Ayusin:", "typeLabel": "Uri:", "sortTitleAZ": "A–Z", @@ -157,13 +156,13 @@ }, "wanted": { "title": "Hinahangad", - "searchPlaceholder": "Maghanap ayon sa pamagat o may-akda...", + "searchPlaceholder": "Maghanap ayon sa pamagat o may-akda…", "countLabel": "{{filtered}} sa {{total}}", "empty": "Walang hinahangad na libro. Magdagdag ng may-akda para magsimula.", "noMatch": "Walang librong tumutugma sa iyong paghahanap.", "selectAllPage": "Piliin lahat sa pahinang ito", "search": "Maghanap", - "searching": "Naghahanap...", + "searching": "Naghahanap…", "unmonitor": "Itigil ang pagbabantay", "grab": "Kunin", "grabbing": "Kinukuha…", @@ -209,7 +208,7 @@ "empty": "Walang laman ang blocklist", "emptyHint": "Awtomatikong idinadagdag dito ang mga nabigong download para maiwasan ang muling pagkuha", "deleteSelected": "Tanggalin ang napili ({{count}})", - "deleting": "Tinatanggal...", + "deleting": "Tinatanggal…", "colTitle": "Pamagat", "colReason": "Dahilan", "colDate": "Petsa", @@ -397,7 +396,7 @@ "backupCreate": "Gumawa ng backup ng lahat ng configuration ng Bindery", "backupHint": "Kasama ang mga may-akda, libro, indexer, at setting", "backupButton": "Gumawa ng Backup", - "backupCreating": "Ginagawa...", + "backupCreating": "Ginagawa…", "existingBackups": "Mga umiiral na backup:", "security": "Seguridad", "logRetention": "Log Retention", @@ -412,7 +411,6 @@ }, "search": "Search", "searchIntervalLabel": "Wanted search interval", - "searchIntervalHint": "How often Bindery searches all wanted books against your indexers. Reduce if you have a large library and are hitting indexer API rate limits.", "searchIntervalRestart": "Takes effect after the next Bindery restart." }, "import": { @@ -438,7 +436,7 @@ "hardcoverTokenPlaceholder": "Hardcover API token", "hardcoverList": "Listahan", "hardcoverListPlaceholder": "Maglagay ng token para i-load ang mga listahan", - "hardcoverListLoading": "Nilo-load ang mga listahan...", + "hardcoverListLoading": "Nilo-load ang mga listahan…", "hardcoverNoLists": "Walang nahanap na listahan", "hardcoverEmpty": "Walang naka-configure na Hardcover import list.", "hardcoverLastSync": "Huling sync: {{date}}", @@ -466,7 +464,7 @@ "inLibrary": "Nasa iyong aklatan", "open": "Buksan", "openBook": "Buksan ang {{title}}", - "searching": "Naghahanap...", + "searching": "Naghahanap…", "select": "Piliin", "selectBook": "Piliin ang {{title}}", "idMissing": "Walang book ID ang resultang ito kaya hindi ito maidaragdag", @@ -476,8 +474,8 @@ "noResults": "Walang nahanap na resulta", "searchError": "Hindi maabot ang metadata provider — {{error}}", "backToResults": "Bumalik sa mga resulta", - "adding": "Idinadagdag...", - "searchPlaceholderAuthor": "Maghanap ayon sa pangalan ng may-akda...", + "adding": "Idinadagdag…", + "searchPlaceholderAuthor": "Maghanap ayon sa pangalan ng may-akda…", "searchPlaceholderBook": "Pamagat, ISBN, o ASIN (halimbawa, Dune, 9780441478125, B0DBJBFHGT)", "author": { "confirmAdd": "Idagdag ang may-akda", @@ -519,7 +517,7 @@ "keepLabel": "Panatilihin", "removeLabel": "Alisin", "mergeButton": "Pagsamahin", - "merging": "Pinagsasama...", + "merging": "Pinagsasama…", "cancel": "Kanselahin" }, "pagination": { @@ -537,7 +535,7 @@ "title": "Tumuklas", "subtitle": "Batay sa iyong library", "refresh": "I-refresh", - "refreshing": "Nire-refresh...", + "refreshing": "Nire-refresh…", "addToWanted": "Idagdag sa Hinahangad", "dismiss": "I-dismiss", "dontSuggestAuthor": "Huwag imungkahi si {{author}}", diff --git a/web/src/pages/AuthorsPage.test.tsx b/web/src/pages/AuthorsPage.test.tsx index f4885a399..96b36a817 100644 --- a/web/src/pages/AuthorsPage.test.tsx +++ b/web/src/pages/AuthorsPage.test.tsx @@ -74,6 +74,8 @@ vi.mock('react-i18next', () => ({ 'common.refresh': 'Refresh', 'common.cancel': 'Cancel', 'common.apply': 'Apply', + 'series.addSeries': 'Add Series', + 'series.nameModal.name': 'Name', 'authors.bulkApplyMonitoringToExisting': 'Also apply to their existing books', 'authors.bulkApplyMonitoringToExistingHint': 'Rewrites every book of the selected authors to match. Leave it off to change the authors only.', 'bulkActionBar.clear': 'Clear', diff --git a/web/src/pages/AuthorsPage.tsx b/web/src/pages/AuthorsPage.tsx index 63e912fb3..168c9df73 100644 --- a/web/src/pages/AuthorsPage.tsx +++ b/web/src/pages/AuthorsPage.tsx @@ -417,7 +417,7 @@ export default function AuthorsPage() { onClick={() => setShowAddSeries(true)} className="px-4 py-2 bg-slate-200 dark:bg-zinc-800 hover:bg-slate-300 dark:hover:bg-zinc-700 rounded-md text-sm font-medium transition-colors" > - Add Series + {t('series.addSeries')} {/* Below `sm` the five buttons wrap and the primary action used to land on the second row, under Refresh all metadata, Merge and Add @@ -828,7 +828,7 @@ export default function AuthorsPage() { disabled={bulkBusy} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md text-sm font-medium text-white transition-colors" > - {bulkBusy ? t('common.saving', 'Saving...') : t('authors.bulkSetMonitorModeApply', 'Apply monitor mode')} + {bulkBusy ? t('common.saving', 'Saving…') : t('authors.bulkSetMonitorModeApply', 'Apply monitor mode')}

@@ -844,8 +844,8 @@ export default function AuthorsPage() { )} {showAddSeries && ( setShowAddSeries(false)} onSubmit={handleCreateSeries} /> diff --git a/web/src/pages/BookDetailPage.test.tsx b/web/src/pages/BookDetailPage.test.tsx index 319cf8686..246158a67 100644 --- a/web/src/pages/BookDetailPage.test.tsx +++ b/web/src/pages/BookDetailPage.test.tsx @@ -1674,7 +1674,7 @@ describe('BookDetailPage — Previous/Next navigation (#2548, book side)', () => fireEvent.click(screen.getByLabelText('Next book')) - expect(await screen.findByText('Loading...')).toBeInTheDocument() + expect(await screen.findByText('Loading…')).toBeInTheDocument() expect(screen.getByText('← Books')).toBeInTheDocument() expect(screen.getByLabelText('Previous book')).toBeInTheDocument() expect(screen.getByLabelText('Next book')).toBeInTheDocument() diff --git a/web/src/pages/CalendarPage.test.tsx b/web/src/pages/CalendarPage.test.tsx new file mode 100644 index 000000000..f775c9bc0 --- /dev/null +++ b/web/src/pages/CalendarPage.test.tsx @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import CalendarPage from './CalendarPage' +import { api } from '../api/client' +import type { Book } from '../api/client' +import i18n from '../i18n' + +vi.mock('../api/client', async importOriginal => { + const actual = await importOriginal() + return { ...actual, api: { ...actual.api, listAllBooks: vi.fn() } } +}) + +const release = { id: 1, title: 'Spring Release', releaseDate: '2026-03-05T00:00:00Z', monitored: true } as Book + +// Month and weekday names used to be a hardcoded English array, so the +// calendar read "March" and "Mon" in every language. +describe('CalendarPage month names', () => { + beforeEach(() => { + // Only Date is faked: timers stay real so findBy* can poll. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-03-15T12:00:00Z')) + vi.mocked(api.listAllBooks).mockResolvedValue([release]) + }) + + afterEach(async () => { + vi.useRealTimers() + await act(async () => { await i18n.changeLanguage('en') }) + }) + + it('renders English month and weekday names in English', async () => { + await act(async () => { await i18n.changeLanguage('en') }) + render() + expect(await screen.findByText('March 2026')).toBeInTheDocument() + expect(await screen.findByText('Releasing in March 2026')).toBeInTheDocument() + expect(screen.getByText('Mar 5')).toBeInTheDocument() + expect(screen.getByText('Mon')).toBeInTheDocument() + }) + + it('follows the active language', async () => { + await act(async () => { await i18n.changeLanguage('fr') }) + render() + expect(await screen.findByText('mars 2026')).toBeInTheDocument() + expect(await screen.findByText('Sorties en mars 2026')).toBeInTheDocument() + expect(screen.getByText('5 mars')).toBeInTheDocument() + expect(screen.getByText('lun.')).toBeInTheDocument() + expect(screen.queryByText('March 2026')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/CalendarPage.tsx b/web/src/pages/CalendarPage.tsx index 90b9dc361..d11005edb 100644 --- a/web/src/pages/CalendarPage.tsx +++ b/web/src/pages/CalendarPage.tsx @@ -11,16 +11,27 @@ function getFirstDayOfMonth(year: number, month: number) { return new Date(year, month, 1).getDay() } -const MONTH_NAMES = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -] +// Month and weekday names come from Intl in the active UI language, so the +// calendar follows the language switcher instead of always reading English. +// Dates are built in UTC and formatted in UTC so no local offset can move a +// label onto the neighbouring day or month. +function monthLabel(lang: string | undefined, year: number, month: number, opts: Intl.DateTimeFormatOptions) { + return new Intl.DateTimeFormat(lang, { ...opts, timeZone: 'UTC' }).format(new Date(Date.UTC(year, month, 1))) +} + +function dayOfMonthLabel(lang: string | undefined, year: number, month: number, day: number) { + return new Intl.DateTimeFormat(lang, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(year, month, day))) +} -const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] -const DAY_NAMES_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] +// Sunday first, matching getFirstDayOfMonth. 2023-01-01 was a Sunday. +function weekdayNames(lang: string | undefined, weekday: 'short' | 'narrow') { + const fmt = new Intl.DateTimeFormat(lang, { weekday, timeZone: 'UTC' }) + return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2023, 0, 1 + i)))) +} export default function CalendarPage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const lang = i18n.resolvedLanguage ?? i18n.language const [books, setBooks] = useState([]) const [loading, setLoading] = useState(true) const today = new Date() @@ -48,9 +59,9 @@ export default function CalendarPage() { }, [viewYear, viewMonth]) useEffect(() => { - document.title = 'Calendar · Bindery' + document.title = `${t('calendar.title')} · Bindery` return () => { document.title = 'Bindery' } - }, []) + }, [t]) const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } @@ -82,6 +93,8 @@ export default function CalendarPage() { while (cells.length % 7 !== 0) cells.push(null) const hasReleases = Object.keys(booksByDay).length > 0 + const dayNames = weekdayNames(lang, 'short') + const dayNamesNarrow = weekdayNames(lang, 'narrow') return (
@@ -103,8 +116,8 @@ export default function CalendarPage() { > ‹ - - {MONTH_NAMES[viewMonth]} {viewYear} + + {monthLabel(lang, viewYear, viewMonth, { month: 'long', year: 'numeric' })}
@@ -358,11 +358,11 @@ export default function SeriesPage() { {loading ? ( -
Loading...
+
{t('common.loading')}
) : seriesList.length === 0 ? (
-

No series found

-

Series are populated automatically from your monitored authors' books

+

{t('series.emptyTitle')}

+

{t('series.emptyHint')}

) : filteredSeries.length === 0 ? (
@@ -408,11 +408,11 @@ export default function SeriesPage() {
{displayMissingCount > 0 && ( - {displayMissingCount} missing + {t('series.missingBadge', { count: displayMissingCount })} )} - {bookCount} {bookCount === 1 ? 'book' : 'books'} + {t('series.bookCount', { count: bookCount })} {isOpen ? '▲' : '▼'}
@@ -430,10 +430,10 @@ export default function SeriesPage() { toggleMonitor(series)} - label={series.monitored ? 'Remove from shortlist' : 'Add to shortlist'} - title="Marks the series so you can find it again. Bindery does not yet check a shortlisted series for new books on its own; use Fill gaps." + label={series.monitored ? t('series.shortlist.remove') : t('series.shortlist.add')} + title={t('series.shortlist.hint')} > - {series.monitored ? 'Shortlisted' : 'Not shortlisted'} + {series.monitored ? t('series.shortlist.on') : t('series.shortlist.off')} {enhancedHardcoverApi && ( )} {isOpen && ( )} {isOpen && ( @@ -469,19 +473,21 @@ export default function SeriesPage() { disabled={applyingGenres === series.id} className="text-xs px-2.5 py-1 rounded font-medium bg-slate-200 dark:bg-zinc-800 hover:bg-slate-300 dark:hover:bg-zinc-700 disabled:opacity-50" title={series.genreOverrideSet - ? `Genre override active${series.genreOverride?.length ? `: ${series.genreOverride.join(', ')}` : ' (no genres)'}. Books added later inherit it. Click to edit or clear.` - : 'Set the same genres on every book in this series and lock them against metadata refresh'} + ? (series.genreOverride?.length + ? t('series.genre.activeHint', { genres: series.genreOverride.join(', ') }) + : t('series.genre.activeHintEmpty')) + : t('series.genre.setHint')} > {applyingGenres === series.id ? '…' - : series.genreOverrideSet ? 'Genre ✓' : 'Set genre'} + : series.genreOverrideSet ? t('series.genre.active') : t('series.genre.set')} )} {fillNeeded && ( )} {fillResult[series.id] && ( @@ -522,7 +528,7 @@ export default function SeriesPage() { )}

- {entry.book?.title ?? `Book ${entry.bookId}`} + {entry.book?.title ?? t('series.bookFallback', { id: entry.bookId })}

{entry.book?.releaseDate && (

@@ -539,12 +545,12 @@ export default function SeriesPage() { ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-300 dark:bg-zinc-700 text-slate-600 dark:text-zinc-400' }`}> - {entry.book.status} + {t(`bookStatus.${entry.book.status}`, { defaultValue: entry.book.status })} )} {entry.book?.excluded && ( - Excluded + {t('series.excluded')} )} @@ -555,7 +561,7 @@ export default function SeriesPage() { {isOpen && bookCount === 0 && (

- No books in this series yet + {t('series.noBooks')}
)} @@ -563,9 +569,9 @@ export default function SeriesPage() {
-

Hardcover: {series.hardcoverLink.hardcoverTitle}

+

{t('series.hardcover.heading', { title: series.hardcoverLink.hardcoverTitle })}

- {diff ? `${diff.presentCount} matched · ${diff.missingCount} missing` : 'Checking Hardcover catalog...'} + {diff ? t('series.hardcover.diffSummary', { present: diff.presentCount, missing: diff.missingCount }) : t('series.hardcover.checking')}

{/* Way back to the record this series is linked to (#1708). Absent until the slug is known, because @@ -579,36 +585,36 @@ export default function SeriesPage() { onClick={e => e.stopPropagation()} className="text-xs text-sky-700 dark:text-sky-300 hover:underline mt-0.5 inline-block" > - View on Hardcover ↗ + {t('common.viewOnSource', { source: 'Hardcover' })} )}
{(diff?.missingCount ?? 0) > 0 && (
)}
{diffLoading[series.id] && ( -
Loading Hardcover books...
+
{t('series.hardcover.loadingBooks')}
)} {diffErrors[series.id] && (
{diffErrors[series.id]}
@@ -653,15 +659,15 @@ export default function SeriesPage() { onClick={() => fillGaps(series, book, fillMediaType[series.id] ?? 'ebook')} disabled={filling === series.id} className="ml-auto text-xs px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 rounded font-medium flex-shrink-0" - title="Add this missing Hardcover book and search indexers" + title={t('series.hardcover.addTitle')} > - {filling === series.id ? '...' : 'add'} + {filling === series.id ? '…' : t('series.hardcover.add')}
) })} {diff.missing.length > 8 && ( -

{diff.missing.length - 8} more missing books

+

{t('series.hardcover.moreMissing', { count: diff.missing.length - 8 })}

)}
)} @@ -674,17 +680,17 @@ export default function SeriesPage() { )} {showAddSeries && ( setShowAddSeries(false)} onSubmit={handleCreateSeries} /> )} {editingSeries && ( setEditingSeries(null)} onSubmit={handleRenameSeries} /> diff --git a/web/src/pages/import/AdoptionView.tsx b/web/src/pages/import/AdoptionView.tsx index 1d85b38fc..1bd6802a9 100644 --- a/web/src/pages/import/AdoptionView.tsx +++ b/web/src/pages/import/AdoptionView.tsx @@ -107,7 +107,7 @@ export default function AdoptionView() {

{state.loadError}

)} {!state.loaded ? ( -
{t('common.loading', 'Loading...')}
+
{t('common.loading', 'Loading…')}
) : empty ? ( emptyState() ) : ( diff --git a/web/src/pages/settings/AboutTab.tsx b/web/src/pages/settings/AboutTab.tsx index 1231575d7..83a072b20 100644 --- a/web/src/pages/settings/AboutTab.tsx +++ b/web/src/pages/settings/AboutTab.tsx @@ -67,7 +67,7 @@ export default function AboutTab() {

) : !status ? (

- {t('common.loading', 'Loading...')} + {t('common.loading', 'Loading…')}

) : ( <> diff --git a/web/src/pages/settings/CalibreSyncModal.test.tsx b/web/src/pages/settings/CalibreSyncModal.test.tsx index a6c795ea3..d9de83fb6 100644 --- a/web/src/pages/settings/CalibreSyncModal.test.tsx +++ b/web/src/pages/settings/CalibreSyncModal.test.tsx @@ -1,7 +1,8 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { act, render, screen } from '@testing-library/react' import { CalibreSyncModal } from './CalibreTab' import type { CalibreSyncProgress } from '../../api/client' +import i18n from '../../i18n' function progress(over: Partial = {}): CalibreSyncProgress { return { @@ -63,3 +64,28 @@ describe('CalibreSyncModal', () => { expect(screen.queryByTestId('calibre-sync-skip-table')).toBeNull() }) }) + +// "Push all to Calibre" and the modal's labels were hardcoded English. +describe('CalibreSyncModal translations', () => { + afterEach(async () => { + await act(async () => { await i18n.changeLanguage('en') }) + }) + + it('renders the English strings from en.json', () => { + render() + expect(screen.getByRole('heading', { name: 'Push all to Calibre' })).toBeInTheDocument() + expect(screen.getByText('Done. Pushed 1, already in Calibre 2, failed 0, skipped 1.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + }) + + it('follows the active locale', async () => { + i18n.addResourceBundle('fr', 'translation', { + settings: { calibre: { pushAll: { label: 'Tout envoyer vers Calibre', pushed: 'Envoyés', close: 'Fermer' } } }, + }, true, true) + await act(async () => { await i18n.changeLanguage('fr') }) + render() + expect(screen.getByRole('heading', { name: 'Tout envoyer vers Calibre' })).toBeInTheDocument() + expect(screen.getByText('Envoyés')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Fermer' })).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/settings/CalibreTab.coverage.test.tsx b/web/src/pages/settings/CalibreTab.coverage.test.tsx index ff62a4c79..caf3c4953 100644 --- a/web/src/pages/settings/CalibreTab.coverage.test.tsx +++ b/web/src/pages/settings/CalibreTab.coverage.test.tsx @@ -34,10 +34,10 @@ function seed(entries: Record) { mocked.listSettings.mockResolvedValue(Object.entries(entries).map(([key, value]) => ({ key, value }))) } -// The "Push all" button label is being reworded elsewhere, so it is found -// through the block that describes it rather than by its text. +// The mocked t returns keys, so the Push all block is found through its +// description key and the button is taken from that block. function pushAllButton(): HTMLButtonElement { - const desc = screen.getByText(/Queue every imported book for the Calibre Bridge plugin/) + const desc = screen.getByText('settings.calibre.pushAll.description') const block = desc.closest('.pt-3') as HTMLElement return within(block).getByRole('button') as HTMLButtonElement } @@ -152,7 +152,7 @@ describe('CalibreTab coverage', () => { // No plugin URL yet: the bridge is not probed and Push all is blocked. expect(mocked.testCalibre).not.toHaveBeenCalled() expect(pushAllButton()).toBeDisabled() - expect(pushAllButton()).toHaveAttribute('title', 'Set the plugin URL first') + expect(pushAllButton()).toHaveAttribute('title', 'settings.calibre.pushAll.needPluginUrl') fireEvent.change(url, { target: { value: 'http://calibre:8099' } }) fireEvent.click(saveButtonFor(url)) @@ -178,12 +178,12 @@ describe('CalibreTab coverage', () => { seed({ 'calibre.mode': 'plugin', 'calibre.plugin_url': 'http://calibre:8099' }) mocked.testCalibre.mockRejectedValueOnce(new Error('refused')) render() - expect(await screen.findByText(/Bridge not reachable right now/)).toBeInTheDocument() + expect(await screen.findByText('settings.calibre.pushAll.bridgeUnreachable')).toBeInTheDocument() mocked.testCalibre.mockResolvedValueOnce({ ok: 'true', version: '' , message: 'plugin reachable' }) fireEvent.click(screen.getByText('Test connection')) expect(await screen.findByText('✓ Plugin reachable — plugin reachable')).toBeInTheDocument() - expect(screen.queryByText(/Bridge not reachable right now/)).not.toBeInTheDocument() + expect(screen.queryByText('settings.calibre.pushAll.bridgeUnreachable')).not.toBeInTheDocument() }) it('reports a failed check-in lookup in pull mode', async () => { @@ -222,8 +222,8 @@ describe('CalibreTab coverage', () => { fireEvent.click(pushAllButton()) const dialog = await screen.findByRole('dialog') expect(mocked.calibreSyncStart).toHaveBeenCalledTimes(1) - expect(await within(dialog).findByText(/Done\. Pushed 1, already in Calibre 1/)).toBeInTheDocument() - fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })) + expect(await within(dialog).findByText(/settings\.calibre\.pushAll\.done \{"pushed":1,"already":1/)).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole('button', { name: 'settings.calibre.pushAll.close' })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) diff --git a/web/src/pages/settings/CalibreTab.pullTransport.test.tsx b/web/src/pages/settings/CalibreTab.pullTransport.test.tsx index 256a7e71e..cf4774fd2 100644 --- a/web/src/pages/settings/CalibreTab.pullTransport.test.tsx +++ b/web/src/pages/settings/CalibreTab.pullTransport.test.tsx @@ -80,7 +80,7 @@ describe('CalibreTab transport', () => { // Nothing probes a plugin URL in pull. expect(mocked.testCalibre).not.toHaveBeenCalled() // Push all works without a plugin URL. - expect(screen.getByRole('button', { name: 'Push all to Calibre' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'settings.calibre.pushAll.label' })).toBeEnabled() // The queue panel shows the check in rather than reachability. expect(await screen.findByTestId('calibre-delivery-reachability')).toHaveTextContent('settings.calibre.deliveries.lastCheckIn') }) diff --git a/web/src/pages/settings/CalibreTab.pushAll.test.tsx b/web/src/pages/settings/CalibreTab.pushAll.test.tsx index 00859a014..07f1bc532 100644 --- a/web/src/pages/settings/CalibreTab.pushAll.test.tsx +++ b/web/src/pages/settings/CalibreTab.pushAll.test.tsx @@ -59,7 +59,7 @@ describe('CalibreTab Push all on the queue', () => { it('keeps Push all available while books wait for an unreachable Calibre', async () => { mocked.calibreSyncStatus.mockResolvedValue(waiting) render() - const button = await screen.findByRole('button', { name: 'Push all to Calibre' }) + const button = await screen.findByRole('button', { name: 'settings.calibre.pushAll.label' }) await waitFor(() => expect(mocked.calibreSyncStatus).toHaveBeenCalled()) await waitFor(() => expect(mocked.testCalibre).toHaveBeenCalled()) expect(button).toBeEnabled() @@ -71,6 +71,6 @@ describe('CalibreTab Push all on the queue', () => { mocked.calibreSyncStatus.mockResolvedValue({ ...waiting, queueing: true, message: 'queueing books for Calibre…' }) render() expect(await screen.findByRole('dialog')).toHaveTextContent('queueing books for Calibre') - expect(screen.getByRole('button', { name: 'Queueing…' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'settings.calibre.pushAll.queueing' })).toBeDisabled() }) }) diff --git a/web/src/pages/settings/CalibreTab.tsx b/web/src/pages/settings/CalibreTab.tsx index e256fc590..6b1980c4d 100644 --- a/web/src/pages/settings/CalibreTab.tsx +++ b/web/src/pages/settings/CalibreTab.tsx @@ -194,7 +194,7 @@ function CalibreSection({ const p = await api.calibreSyncStart() setSyncProgress(p) } catch (err) { - setSyncError(err instanceof Error ? err.message : 'Push failed to start') + setSyncError(err instanceof Error ? err.message : t('settings.calibre.pushAll.startFailed')) } } @@ -505,21 +505,21 @@ function CalibreSection({
- +

- Queue every imported book for the Calibre Bridge plugin. Books already delivered are not sent again, and queued books go out as soon as Calibre is reachable. + {t('settings.calibre.pushAll.description')}

{bridgeReachable === false && ( -

Bridge not reachable right now. Books you push will wait in the queue until it is.

+

{t('settings.calibre.pushAll.bridgeUnreachable')}

)}
@@ -973,15 +973,16 @@ export function CalibreSyncModal({ const processed = stats?.processed ?? 0 const pct = total > 0 ? Math.min(100, (processed / total) * 100) : 0 const running = !!progress?.running + const { t } = useTranslation() return (
-

Push all to Calibre

+

{t('settings.calibre.pushAll.label')}

@@ -993,7 +994,7 @@ export function CalibreSyncModal({ {progress && ( <>
- {progress.message || (running ? 'Working…' : 'Idle')} + {progress.message || (running ? t('settings.calibre.pushAll.working') : t('settings.calibre.pushAll.idle'))} {processed} / {total || '?'}
@@ -1001,19 +1002,19 @@ export function CalibreSyncModal({
-
Pushed
+
{t('settings.calibre.pushAll.pushed')}
{stats?.pushed ?? 0}
-
Already in Calibre
+
{t('settings.calibre.pushAll.alreadyInCalibre')}
{stats?.alreadyInCalibre ?? 0}
-
Failed
+
{t('settings.calibre.pushAll.failed')}
{stats?.failed ?? 0}
-
Skipped
+
{t('settings.calibre.pushAll.skipped')}
{!running && progress.error && ( -

Sync failed: {progress.error}

+

{t('settings.calibre.pushAll.syncFailed', { error: progress.error })}

)} {!running && progress.finishedAt && !progress.error && (

- Done. Pushed {stats?.pushed ?? 0}, already in Calibre {stats?.alreadyInCalibre ?? 0}, failed{' '} - {stats?.failed ?? 0}, skipped {stats?.skipped ?? 0}. + {t('settings.calibre.pushAll.done', { + pushed: stats?.pushed ?? 0, + already: stats?.alreadyInCalibre ?? 0, + failed: stats?.failed ?? 0, + skipped: stats?.skipped ?? 0, + })}

)} {progress.errors && progress.errors.length > 0 && ( @@ -1036,8 +1041,8 @@ export function CalibreSyncModal({ - - + + @@ -1059,8 +1064,8 @@ export function CalibreSyncModal({
TitleReason{t('settings.calibre.pushAll.errorTitle')}{t('settings.calibre.pushAll.errorReason')}
- - + + @@ -1085,7 +1090,7 @@ export function CalibreSyncModal({ onClick={onClose} className="px-3 py-1.5 bg-slate-600 hover:bg-slate-500 rounded text-sm font-medium disabled:opacity-50 text-white" > - {running ? 'Close and keep going' : 'Close'} + {running ? t('settings.calibre.pushAll.closeAndKeepGoing') : t('settings.calibre.pushAll.close')}
Not pushedWhy{t('settings.calibre.pushAll.skipTitle')}{t('settings.calibre.pushAll.skipReason')}