forked from akai/readest
fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left"). CBZ / ComicInfo (foliate-js submodule + Readest derivation): - comic-book.js: find ComicInfo.xml in subdirectories too, parse description / subject / identifier / published / series fields beyond the prior name+position pair. Series Count populates the canonical `belongsTo.series.total`; no top-level duplication. - bookService.ts / readerStore.ts: derive `metadata.seriesTotal` from `belongsTo.series.total` in parallel to the existing series / seriesIndex derivation. - ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded `pagesLeft = 1` for fixed-layout books and compute it from `section.total - section.current`. FooterBar uses `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section` (correct image count) instead of `pageinfo` (locations). - ProgressBar: switch the remaining-pages text to "in book" for fixed-layout titles (no chapter structure) and keep "in chapter" for reflowable books. WebDAV refactor for translation coverage: - WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a prop) instead of `_(...)`. The i18next-scanner only looks for `_`, so ~53 strings were unreachable and shipped in English to every locale. Switched both components to call `useTranslation()` themselves; helpers that aren't React FCs take `_: TranslationFunc` so the scanner sees the literal calls. - WebDAVClient.checkConnection now returns a `code` discriminator (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` / `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is reserved for the dev console. New `formatConnectError` and `formatSyncError` helpers in WebDAVForm translate via a switch where each branch is a literal `_('...')`. Same treatment for the sync-failure path that previously surfaced raw e.message. - "Syncing 0 / {{total}}" is now parameterized as "Syncing {{n}} / {{total}}" with n=0 at startup so the digit formats naturally and the template can be reused mid-sync. - "Cleanup · {{count}} book(s)" hard-coded options used unsupported ternary; rewrote as plural-aware key. i18n scanner fix (i18next-scanner.config.cjs): - vinyl-fs walked into directories whose names end in source-file extensions (Next.js route folder `runtime-config.js/`, Playwright screenshot folder `*.test.tsx/`) and crashed with EISDIR. Resolved by expanding globs via `fs.globSync` and filtering to files only before handing to the scanner. TypeScript-syntax sites that broke esprima during extraction: - WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and `failed[0]!.title` inside `_(..., options)` arguments. Replaced with `e instanceof Error ? e.message : String(e)` and `failed[0]?.title ?? ''` — also runtime-safer. User-facing em-dash cleanup: - Removed em-dashes from translation keys across SyncHistoryPanel / WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page / replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept. Locale translations: - ~2400 translations applied across all 33 locales for the keys that were either newly extractable, freshly worded, or pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__` remain after the run. Misc: - next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build runs the same lint as CI. - Collection type: add `total?: string` for ComicInfo series count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): fix vitest invocation, run with 4 workers `pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which pnpm expanded into: dotenv -e .env -e .env.test.local -- vitest -- --watch=false The second `--` made vitest treat `--watch=false` as a positional file pattern, not a flag. Vitest then fell back to defaults (in CI's non-TTY env that still meant a one-shot run, so the suite passed), but the worker pool was effectively serialized for big chunks of the 243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum of phases was ~236 s (≈2.6× effective parallelism). Replace the chained pnpm invocation with a direct call to `vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions ubuntu-latest runner provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -489,7 +489,7 @@ const AIPanel: React.FC = () => {
|
||||
{customModelStatus === 'valid' && customModelPricing && (
|
||||
<span className='text-success flex items-center gap-1 text-sm'>
|
||||
<PiCheckCircle />
|
||||
{_('Model available')} — ${customModelPricing.input}/M in, $
|
||||
{_('Model available')} · ${customModelPricing.input}/M in, $
|
||||
{customModelPricing.output}/M out
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { WebDAVSyncLogEntry, WebDAVSyncLogStatus } from '@/types/settings';
|
||||
import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { BoxedList, SettingsRow } from '../primitives';
|
||||
|
||||
/**
|
||||
@@ -18,17 +19,15 @@ import { BoxedList, SettingsRow } from '../primitives';
|
||||
* about "the log" as a whole and how to clear it.
|
||||
*
|
||||
* Presentational: all persistence happens in the parent
|
||||
* (`appendSyncLogEntry` / `handleClearSyncLog`). We accept the
|
||||
* translation function `t` rather than calling `useTranslation` here so
|
||||
* the parent stays the single source of locale truth for the page.
|
||||
* (`appendSyncLogEntry` / `handleClearSyncLog`).
|
||||
*/
|
||||
export interface SyncHistoryPanelProps {
|
||||
entries: WebDAVSyncLogEntry[];
|
||||
onClear: () => void | Promise<void>;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear, t }) => {
|
||||
const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear }) => {
|
||||
const _ = useTranslation();
|
||||
// Only one entry expanded at a time keeps the panel scannable on
|
||||
// mobile — multiple open rows can quickly push the disconnect button
|
||||
// off-screen. Set to null when no row is expanded.
|
||||
@@ -39,9 +38,9 @@ const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear, t
|
||||
return (
|
||||
<BoxedList>
|
||||
<SettingsRow
|
||||
label={t('Sync History')}
|
||||
description={t(
|
||||
"Manual syncs and cleanups — automatic syncs while reading aren't logged here.",
|
||||
label={_('Sync History')}
|
||||
description={_(
|
||||
"Manual syncs and cleanups only. Automatic syncs while reading aren't logged here.",
|
||||
)}
|
||||
>
|
||||
{hasEntries ? (
|
||||
@@ -49,13 +48,13 @@ const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear, t
|
||||
type='button'
|
||||
onClick={() => onClear()}
|
||||
className='btn btn-ghost btn-sm h-8 min-h-8 px-2'
|
||||
title={t('Clear Sync History')}
|
||||
aria-label={t('Clear Sync History')}
|
||||
title={_('Clear Sync History')}
|
||||
aria-label={_('Clear Sync History')}
|
||||
>
|
||||
{t('Clear')}
|
||||
{_('Clear')}
|
||||
</button>
|
||||
) : (
|
||||
<span className='text-base-content/50 text-xs'>{t('No manual syncs yet')}</span>
|
||||
<span className='text-base-content/50 text-xs'>{_('No manual syncs yet')}</span>
|
||||
)}
|
||||
</SettingsRow>
|
||||
{hasEntries && (
|
||||
@@ -70,12 +69,12 @@ const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear, t
|
||||
className='group flex w-full items-center gap-3 text-left'
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<SyncStatusBadge status={entry.status} t={t} />
|
||||
{entry.kind === 'cleanup' && <SyncKindBadge t={t} />}
|
||||
<SyncStatusBadge status={entry.status} />
|
||||
{entry.kind === 'cleanup' && <SyncKindBadge />}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<span className='text-sm'>{formatSyncSummaryLine(entry, t)}</span>
|
||||
<span className='text-sm'>{formatSyncSummaryLine(entry, _)}</span>
|
||||
<span className='text-base-content/60 text-[0.75em]'>
|
||||
{formatSyncTimestamp(entry.startedAt, entry.finishedAt, t)}
|
||||
{formatSyncTimestamp(entry.startedAt, entry.finishedAt, _)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
@@ -88,7 +87,7 @@ const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear, t
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && <SyncHistoryDetails entry={entry} t={t} />}
|
||||
{isExpanded && <SyncHistoryDetails entry={entry} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -103,14 +102,12 @@ const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ entries, onClear, t
|
||||
* Tailwind utilities (success / warning / error) so the badge respects
|
||||
* the user's theme (eink, dark, light) without per-mode overrides.
|
||||
*/
|
||||
const SyncStatusBadge: React.FC<{ status: WebDAVSyncLogStatus; t: SyncHistoryPanelProps['t'] }> = ({
|
||||
status,
|
||||
t,
|
||||
}) => {
|
||||
const SyncStatusBadge: React.FC<{ status: WebDAVSyncLogStatus }> = ({ status }) => {
|
||||
const _ = useTranslation();
|
||||
const map: Record<WebDAVSyncLogStatus, { label: string; className: string }> = {
|
||||
success: { label: t('OK'), className: 'bg-success/15 text-success' },
|
||||
partial: { label: t('Partial'), className: 'bg-warning/15 text-warning' },
|
||||
failure: { label: t('Failed'), className: 'bg-error/15 text-error' },
|
||||
success: { label: _('OK'), className: 'bg-success/15 text-success' },
|
||||
partial: { label: _('Partial'), className: 'bg-warning/15 text-warning' },
|
||||
failure: { label: _('Failed'), className: 'bg-error/15 text-error' },
|
||||
};
|
||||
const { label, className } = map[status];
|
||||
return (
|
||||
@@ -134,7 +131,8 @@ const SyncStatusBadge: React.FC<{ status: WebDAVSyncLogStatus; t: SyncHistoryPan
|
||||
* its own status badge (success / partial / failed) right next to
|
||||
* it; piling more colour on would just shout.
|
||||
*/
|
||||
const SyncKindBadge: React.FC<{ t: SyncHistoryPanelProps['t'] }> = ({ t }) => {
|
||||
const SyncKindBadge: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
@@ -142,7 +140,7 @@ const SyncKindBadge: React.FC<{ t: SyncHistoryPanelProps['t'] }> = ({ t }) => {
|
||||
'bg-info/15 text-info',
|
||||
)}
|
||||
>
|
||||
{t('Cleanup')}
|
||||
{_('Cleanup')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -153,13 +151,10 @@ const SyncKindBadge: React.FC<{ t: SyncHistoryPanelProps['t'] }> = ({ t }) => {
|
||||
* reusing the toast's `entry.summary`) so the text in the log stays
|
||||
* compact even when the original toast was multi-line.
|
||||
*/
|
||||
const formatSyncSummaryLine = (
|
||||
entry: WebDAVSyncLogEntry,
|
||||
t: SyncHistoryPanelProps['t'],
|
||||
): string => {
|
||||
const formatSyncSummaryLine = (entry: WebDAVSyncLogEntry, _: TranslationFunc): string => {
|
||||
if (entry.status === 'failure') {
|
||||
return (
|
||||
entry.errorMessage || (entry.kind === 'cleanup' ? t('Cleanup failed') : t('Sync failed'))
|
||||
entry.errorMessage || (entry.kind === 'cleanup' ? _('Cleanup failed') : _('Sync failed'))
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'cleanup') {
|
||||
@@ -170,38 +165,34 @@ const formatSyncSummaryLine = (
|
||||
// and watching every clause come up zero.
|
||||
const parts: string[] = [];
|
||||
if ((entry.booksDeleted ?? 0) > 0) {
|
||||
parts.push(t('{{n}} deleted', { n: entry.booksDeleted ?? 0 }));
|
||||
parts.push(_('{{n}} deleted', { n: entry.booksDeleted ?? 0 }));
|
||||
}
|
||||
if (entry.failures > 0) {
|
||||
parts.push(t('{{n}} failed', { n: entry.failures }));
|
||||
parts.push(_('{{n}} failed', { n: entry.failures }));
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : t('Nothing deleted');
|
||||
return parts.length > 0 ? parts.join(' · ') : _('Nothing deleted');
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (entry.booksDownloaded > 0) {
|
||||
parts.push(t('{{n}} downloaded', { n: entry.booksDownloaded }));
|
||||
parts.push(_('{{n}} downloaded', { n: entry.booksDownloaded }));
|
||||
}
|
||||
if (entry.filesUploaded > 0) {
|
||||
parts.push(t('{{n}} uploaded', { n: entry.filesUploaded }));
|
||||
parts.push(_('{{n}} uploaded', { n: entry.filesUploaded }));
|
||||
}
|
||||
if (entry.configsUploaded > 0 || entry.configsDownloaded > 0) {
|
||||
parts.push(t('{{n}} progress', { n: entry.configsUploaded + entry.configsDownloaded }));
|
||||
parts.push(_('{{n}} progress', { n: entry.configsUploaded + entry.configsDownloaded }));
|
||||
}
|
||||
if (entry.failures > 0) {
|
||||
parts.push(t('{{n}} failed', { n: entry.failures }));
|
||||
parts.push(_('{{n}} failed', { n: entry.failures }));
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : t('Up to date');
|
||||
return parts.length > 0 ? parts.join(' · ') : _('Up to date');
|
||||
};
|
||||
|
||||
/**
|
||||
* "Mar 18, 14:32 · 4.2 s" — short locale-aware timestamp plus a
|
||||
* duration so users can spot abnormally slow runs at a glance.
|
||||
*/
|
||||
const formatSyncTimestamp = (
|
||||
startedAt: number,
|
||||
finishedAt: number,
|
||||
t: SyncHistoryPanelProps['t'],
|
||||
): string => {
|
||||
const formatSyncTimestamp = (startedAt: number, finishedAt: number, _: TranslationFunc): string => {
|
||||
const when = new Date(startedAt).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -210,7 +201,7 @@ const formatSyncTimestamp = (
|
||||
});
|
||||
const durMs = Math.max(0, finishedAt - startedAt);
|
||||
const dur = durMs >= 1000 ? `${(durMs / 1000).toFixed(1)} s` : `${durMs} ms`;
|
||||
return t('{{when}} · {{dur}}', { when, dur });
|
||||
return _('{{when}} · {{dur}}', { when, dur });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -222,8 +213,8 @@ const formatSyncTimestamp = (
|
||||
*/
|
||||
const SyncHistoryDetails: React.FC<{
|
||||
entry: WebDAVSyncLogEntry;
|
||||
t: SyncHistoryPanelProps['t'];
|
||||
}> = ({ entry, t }) => {
|
||||
}> = ({ entry }) => {
|
||||
const _ = useTranslation();
|
||||
// Counters are grouped semantically so the user can scan them at a
|
||||
// glance instead of treating eight numbers as a flat blob:
|
||||
// - "activity": work performed during this run
|
||||
@@ -235,21 +226,21 @@ const SyncHistoryDetails: React.FC<{
|
||||
// single grid which read as one block.
|
||||
const groups: { label: string; value: number }[][] = [
|
||||
[
|
||||
{ label: t('Books downloaded'), value: entry.booksDownloaded },
|
||||
{ label: t('Files uploaded'), value: entry.filesUploaded },
|
||||
{ label: t('Configs uploaded'), value: entry.configsUploaded },
|
||||
{ label: t('Configs downloaded'), value: entry.configsDownloaded },
|
||||
{ label: t('Covers uploaded'), value: entry.coversUploaded },
|
||||
{ label: _('Books downloaded'), value: entry.booksDownloaded },
|
||||
{ label: _('Files uploaded'), value: entry.filesUploaded },
|
||||
{ label: _('Configs uploaded'), value: entry.configsUploaded },
|
||||
{ label: _('Configs downloaded'), value: entry.configsDownloaded },
|
||||
{ label: _('Covers uploaded'), value: entry.coversUploaded },
|
||||
// Cleanup-specific counter. Suppressed by the zero-filter on
|
||||
// sync entries (which always set this to zero/undefined), so
|
||||
// it only shows up on cleanup runs without polluting the
|
||||
// common sync detail view.
|
||||
{ label: t('Books deleted'), value: entry.booksDeleted ?? 0 },
|
||||
{ label: _('Books deleted'), value: entry.booksDeleted ?? 0 },
|
||||
],
|
||||
[{ label: t('Files in sync'), value: entry.filesAlreadyInSync }],
|
||||
[{ label: _('Files in sync'), value: entry.filesAlreadyInSync }],
|
||||
[
|
||||
{ label: t('Failures'), value: entry.failures },
|
||||
{ label: t('Total books'), value: entry.totalBooks },
|
||||
{ label: _('Failures'), value: entry.failures },
|
||||
{ label: _('Total books'), value: entry.totalBooks },
|
||||
],
|
||||
]
|
||||
// Suppress zero-only groups entirely so we don't render an empty
|
||||
@@ -320,13 +311,13 @@ const SyncHistoryDetails: React.FC<{
|
||||
)}
|
||||
{entry.errorMessage && (
|
||||
<div className='text-error/90 break-words text-xs'>
|
||||
<span className='text-base-content/60 mr-1'>{t('Error:')}</span>
|
||||
<span className='text-base-content/60 mr-1'>{_('Error:')}</span>
|
||||
{entry.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{entry.failedBooks && entry.failedBooks.length > 0 && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-base-content/60 text-xs'>{t('Failed books')}</span>
|
||||
<span className='text-base-content/60 text-xs'>{_('Failed books')}</span>
|
||||
<ul className='flex flex-col gap-1 text-xs'>
|
||||
{entry.failedBooks.map((f) => (
|
||||
<li key={f.hash} className='border-base-200 break-words rounded border px-2 py-1.5'>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
@@ -44,21 +45,17 @@ import {
|
||||
* Live browser for the WebDAV root the user connected to.
|
||||
*
|
||||
* Owns its own current path, listing and per-entry download status;
|
||||
* the parent supplies `settings` and `t`. Doubles as the GC surface
|
||||
* for remote orphans via cleanup mode.
|
||||
* the parent supplies `settings`. Doubles as the GC surface for remote
|
||||
* orphans via cleanup mode.
|
||||
*/
|
||||
export interface WebDAVBrowsePaneProps {
|
||||
settings: WebDAVSettings;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
/** Persist a cleanup run into the parent's sync log when supplied. */
|
||||
onAppendSyncLogEntry?: (entry: WebDAVSyncLogEntry) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
settings,
|
||||
t,
|
||||
onAppendSyncLogEntry,
|
||||
}) => {
|
||||
const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings, onAppendSyncLogEntry }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { settings: globalSettings } = useSettingsStore();
|
||||
@@ -187,8 +184,8 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
const appService = await envConfig.getAppService();
|
||||
if (!appService) return;
|
||||
const confirmed = await appService.ask(
|
||||
t(
|
||||
'Delete {{n}} book(s) from the WebDAV server?\n\nThis only removes the remote files; your local library is unaffected. The deletion cannot be undone — the bytes on the server will be permanently gone.',
|
||||
_(
|
||||
'Delete {{n}} book(s) from the WebDAV server?\n\nThis only removes the remote files; your local library is unaffected. The deletion cannot be undone. The bytes on the server will be permanently gone.',
|
||||
{ n: targets.length },
|
||||
),
|
||||
);
|
||||
@@ -253,27 +250,30 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
if (authFailed) {
|
||||
toastType = 'error';
|
||||
status = 'failure';
|
||||
message = t('WebDAV authentication failed. Reconnect in Settings.');
|
||||
message = _('WebDAV authentication failed. Reconnect in Settings.');
|
||||
errorMessage = message;
|
||||
} else if (failed.length === 0) {
|
||||
toastType = 'info';
|
||||
status = 'success';
|
||||
message = t('Deleted {{n}} book(s) from server.', { n: succeeded });
|
||||
message = _('Deleted {{n}} book(s) from server.', { n: succeeded });
|
||||
} else if (succeeded > 0) {
|
||||
toastType = 'warning';
|
||||
status = 'partial';
|
||||
message = t('Deleted {{ok}} of {{total}}; {{n}} failed (e.g. "{{first}}").', {
|
||||
ok: succeeded,
|
||||
total: targets.length,
|
||||
n: failed.length,
|
||||
first: failed[0]!.title,
|
||||
});
|
||||
message = _(
|
||||
'Deleted {{ok}} of {{total}} book(s) from server; {{n}} failed (first: "{{first}}").',
|
||||
{
|
||||
ok: succeeded,
|
||||
total: targets.length,
|
||||
n: failed.length,
|
||||
first: failed[0]?.title ?? '',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toastType = 'warning';
|
||||
status = 'failure';
|
||||
message = t('Failed to delete {{n}} book(s) (e.g. "{{first}}").', {
|
||||
message = _('Couldn\'t delete any of {{n}} book(s) from server (first: "{{first}}").', {
|
||||
n: failed.length,
|
||||
first: failed[0]!.title,
|
||||
first: failed[0]?.title ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setEntries([]);
|
||||
setLoadError((e as Error).message || t('Failed to load directory'));
|
||||
setLoadError((e as Error).message || _('Failed to load directory'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
@@ -413,7 +413,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
if (!isTauriAppPlatform()) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: t('File download is only supported on the desktop and mobile apps.'),
|
||||
message: _('File download is only supported on the desktop and mobile apps.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -459,7 +459,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
setDownloadStatus((prev) => ({ ...prev, [entry.path]: 'done' }));
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: t('Downloaded "{{title}}" to your library.', {
|
||||
message: _('Downloaded "{{title}}" to your library.', {
|
||||
title: imported.title || entry.name,
|
||||
}),
|
||||
});
|
||||
@@ -468,9 +468,9 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
setDownloadStatus((prev) => ({ ...prev, [entry.path]: 'error' }));
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: t('Failed to download "{{name}}": {{error}}', {
|
||||
message: _('Failed to download "{{name}}": {{error}}', {
|
||||
name: entry.name,
|
||||
error: (e as Error).message ?? String(e),
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -489,14 +489,14 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
'btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2',
|
||||
(currentPath === savedRoot || cleanupMode) && 'opacity-40',
|
||||
)}
|
||||
title={t('Up')}
|
||||
aria-label={t('Up')}
|
||||
title={_('Up')}
|
||||
aria-label={_('Up')}
|
||||
>
|
||||
<MdArrowBack className='h-4 w-4' />
|
||||
</button>
|
||||
<span className='truncate text-sm' title={currentPath}>
|
||||
{cleanupMode
|
||||
? t('Cleanup · {{count}} book(s)', { count: displayedEntries.length })
|
||||
? _('Cleanup · {{count}} book(s)', { count: displayedEntries.length })
|
||||
: currentPath}
|
||||
</span>
|
||||
</div>
|
||||
@@ -509,8 +509,8 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
// unmount the progress affordance mid-delete.
|
||||
disabled={isDeleting}
|
||||
className={clsx('btn btn-ghost btn-sm h-8 min-h-8 px-2', isDeleting && 'opacity-40')}
|
||||
title={t('Exit cleanup')}
|
||||
aria-label={t('Exit cleanup')}
|
||||
title={_('Exit cleanup')}
|
||||
aria-label={_('Exit cleanup')}
|
||||
>
|
||||
<MdClose className='h-4 w-4' />
|
||||
</button>
|
||||
@@ -519,8 +519,8 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
type='button'
|
||||
onClick={handleEnterCleanup}
|
||||
className='btn btn-ghost btn-sm h-8 min-h-8 px-2'
|
||||
title={t('Cleanup')}
|
||||
aria-label={t('Cleanup')}
|
||||
title={_('Cleanup')}
|
||||
aria-label={_('Cleanup')}
|
||||
>
|
||||
<MdDeleteSweep className='h-4 w-4' />
|
||||
</button>
|
||||
@@ -531,8 +531,8 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
// Refresh during a delete would race with the per-item splice.
|
||||
disabled={isDeleting}
|
||||
className={clsx('btn btn-ghost btn-sm h-8 min-h-8 px-2', isDeleting && 'opacity-40')}
|
||||
title={t('Refresh')}
|
||||
aria-label={t('Refresh')}
|
||||
title={_('Refresh')}
|
||||
aria-label={_('Refresh')}
|
||||
>
|
||||
<MdRefresh className='h-4 w-4' />
|
||||
</button>
|
||||
@@ -548,7 +548,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
<div className='text-error px-4 py-6 text-center text-sm'>{loadError}</div>
|
||||
) : displayedEntries.length === 0 ? (
|
||||
<div className='text-base-content/60 px-4 py-6 text-center text-sm'>
|
||||
{cleanupMode ? t('All clear · no books') : t('Empty directory')}
|
||||
{cleanupMode ? _('All clear · no books') : _('Empty directory')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className='divide-base-200 divide-y'>
|
||||
@@ -576,7 +576,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
// navigate (or toggle in cleanup mode).
|
||||
const rowClickable = entry.isDirectory;
|
||||
const rowTitle = isLocallyDeleted
|
||||
? t('Deleted locally · still on server')
|
||||
? _('Deleted locally · still on server')
|
||||
: undefined;
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
@@ -627,7 +627,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
// bubbling into a row-level double-toggle.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => toggleRowSelected(entry.path)}
|
||||
aria-label={t('Select')}
|
||||
aria-label={_('Select')}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
@@ -702,17 +702,17 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
)}
|
||||
title={
|
||||
dlState === 'done'
|
||||
? t('Already downloaded in this session')
|
||||
? _('Already downloaded in this session')
|
||||
: dlState === 'downloading'
|
||||
? t('Downloading…')
|
||||
: t('Download to library')
|
||||
? _('Downloading…')
|
||||
: _('Download to library')
|
||||
}
|
||||
aria-label={
|
||||
dlState === 'done'
|
||||
? t('Already downloaded in this session')
|
||||
? _('Already downloaded in this session')
|
||||
: dlState === 'downloading'
|
||||
? t('Downloading…')
|
||||
: t('Download to library')
|
||||
? _('Downloading…')
|
||||
: _('Download to library')
|
||||
}
|
||||
>
|
||||
{dlState === 'downloading' ? (
|
||||
@@ -756,11 +756,11 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
)}
|
||||
>
|
||||
{displayedEntries.length > 0 && selected.size === displayedEntries.length
|
||||
? t('Deselect all')
|
||||
: t('Select all')}
|
||||
? _('Deselect all')
|
||||
: _('Select all')}
|
||||
</button>
|
||||
<span className='text-base-content/60 truncate text-xs'>
|
||||
{t('{{n}} selected', { n: selected.size })}
|
||||
{_('{{n}} selected', { n: selected.size })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Bottom row: action. The per-entry download button
|
||||
@@ -782,7 +782,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
<span
|
||||
className={clsx('loading loading-spinner loading-xs', !isDeleting && 'invisible')}
|
||||
/>
|
||||
{t('Delete from server')}
|
||||
{_('Delete from server')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
buildRequestUrl,
|
||||
checkConnection,
|
||||
normalizeRootPath,
|
||||
WebDAVConnectResult,
|
||||
WebDAVRequestError,
|
||||
} from '@/services/webdav/WebDAVClient';
|
||||
import { type TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { syncLibrary } from '@/services/webdav/WebDAVSync';
|
||||
import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings';
|
||||
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
|
||||
@@ -41,6 +43,54 @@ interface WebDAVFormProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a connection-probe failure into a user-facing string.
|
||||
*
|
||||
* Each branch must be a literal `_('...')` call so the i18next-scanner
|
||||
* picks the keys up — that's why this is a switch on `result.code`
|
||||
* rather than the previous `_(result.message || 'Connection error')`
|
||||
* pattern, which the scanner couldn't see into.
|
||||
*/
|
||||
const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): string => {
|
||||
switch (result.code) {
|
||||
case 'SERVER_URL_REQUIRED':
|
||||
return _('Server URL is required');
|
||||
case 'AUTH_FAILED':
|
||||
return _('Authentication failed');
|
||||
case 'ROOT_NOT_FOUND':
|
||||
return _('Root directory not found');
|
||||
case 'UNEXPECTED_STATUS':
|
||||
return _('Unexpected server response (status {{status}})', {
|
||||
status: result.status ?? 0,
|
||||
});
|
||||
case 'NETWORK':
|
||||
default:
|
||||
return _('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a sync-time error into a user-facing string. WebDAVRequestError
|
||||
* carries a `code` that lets us map to a specific message without ever
|
||||
* showing the raw English `e.message` to the user.
|
||||
*/
|
||||
const formatSyncError = (_: TranslationFunc, e: unknown): string => {
|
||||
if (e instanceof WebDAVRequestError) {
|
||||
switch (e.code) {
|
||||
case 'AUTH_FAILED':
|
||||
return _('WebDAV authentication failed. Reconnect in Settings.');
|
||||
case 'NOT_FOUND':
|
||||
return _('Remote resource not found');
|
||||
case 'NETWORK':
|
||||
return _('Network error');
|
||||
}
|
||||
if (typeof e.status === 'number') {
|
||||
return _('Sync failed (status {{status}})', { status: e.status });
|
||||
}
|
||||
}
|
||||
return _('Sync failed.');
|
||||
};
|
||||
|
||||
/**
|
||||
* WebDAV integration form. Two modes share the same panel:
|
||||
*
|
||||
@@ -98,7 +148,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
if (!result.success) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`,
|
||||
message: `${_('Failed to connect')}: ${formatConnectError(_, result)}`,
|
||||
});
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
@@ -234,7 +284,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
await persistWebdav({ deviceId });
|
||||
}
|
||||
|
||||
beginSync(_('Syncing 0 / {{total}}', { total: eligibleBooks.length }));
|
||||
beginSync(_('Syncing {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }));
|
||||
|
||||
// Captured before the run begins so we can attribute startedAt
|
||||
// accurately even when the run fails in the catch block (the
|
||||
@@ -391,7 +441,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
onProgress: ({ book, index, total, action }) => {
|
||||
const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
|
||||
updateProgress(
|
||||
_('{{action}} {{n}} / {{total}} — {{title}}', {
|
||||
_('{{action}} {{n}} / {{total}} · {{title}}', {
|
||||
action: actionStr,
|
||||
n: index + 1,
|
||||
total,
|
||||
@@ -411,7 +461,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
parts.push(_('downloaded {{n}} book(s)', { n: result.booksDownloaded }));
|
||||
}
|
||||
if (result.configsDownloaded > 0) {
|
||||
parts.push(_('pulled {{n}} progress entr(ies)', { n: result.configsDownloaded }));
|
||||
parts.push(_('pulled progress for {{n}} book(s)', { n: result.configsDownloaded }));
|
||||
}
|
||||
if (result.configsUploaded > 0) {
|
||||
parts.push(_('pushed {{n}} config(s)', { n: result.configsUploaded }));
|
||||
@@ -484,10 +534,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
};
|
||||
await appendSyncLogEntry(entry);
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED'
|
||||
? _('WebDAV authentication failed. Reconnect in Settings.')
|
||||
: _('Sync failed: {{error}}', { error: (e as Error).message ?? String(e) });
|
||||
const message = formatSyncError(_, e);
|
||||
eventDispatcher.dispatch('toast', { type: 'error', message });
|
||||
// Persist a "failure" entry so the user can show what went wrong
|
||||
// without rummaging through the dev console. We don't have a
|
||||
@@ -542,10 +589,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
<SettingsSwitchRow
|
||||
label={_('Upload Book Files')}
|
||||
description={_(
|
||||
'This toggle only controls ' +
|
||||
'whether this device contributes the books. ' +
|
||||
'Reading progress and annotations are always synced both ways, and books ' +
|
||||
'already on the server are always downloaded.',
|
||||
'Only affects uploading book files. Reading progress and downloads always sync.',
|
||||
)}
|
||||
checked={stored.syncBooks ?? false}
|
||||
onChange={handleToggleSyncBooks}
|
||||
@@ -600,9 +644,9 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
runs with full counters and per-book failures. We render
|
||||
even when the log is empty so users can find where it
|
||||
lives before their first run. */}
|
||||
<SyncHistoryPanel entries={stored.syncLog ?? []} onClear={handleClearSyncLog} t={_} />
|
||||
<SyncHistoryPanel entries={stored.syncLog ?? []} onClear={handleClearSyncLog} />
|
||||
|
||||
<WebDAVBrowsePane settings={stored} t={_} onAppendSyncLogEntry={appendSyncLogEntry} />
|
||||
<WebDAVBrowsePane settings={stored} onAppendSyncLogEntry={appendSyncLogEntry} />
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user