From c914eeb7b38d1c3cbf7f24dcdfb3281e4060f119 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 18 Jul 2026 01:06:21 +0330 Subject: [PATCH] feat(i18n): add localization infrastructure and English catalog Refs #17 --- package-lock.json | 95 ++- package.json | 2 + src/App.tsx | 85 ++- src/components/AddDownloadsModal.tsx | 183 ++--- src/components/DeleteConfirmationModal.tsx | 21 +- src/components/DownloadItem.tsx | 59 +- src/components/DownloadTable.tsx | 117 ++-- src/components/DuplicateResolutionModal.tsx | 16 +- src/components/ErrorBoundary.tsx | 7 +- src/components/KeychainPermissionModal.tsx | 53 +- src/components/LogsView.tsx | 54 +- src/components/PropertiesModal.tsx | 126 ++-- src/components/SchedulerView.tsx | 146 ++-- src/components/SettingsView.tsx | 447 ++++++------ src/components/Sidebar.tsx | 74 +- src/components/SpeedLimiterView.tsx | 36 +- src/components/WindowControls.tsx | 17 +- src/contexts/ToastContext.tsx | 4 +- src/i18n/index.test.ts | 16 + src/i18n/index.ts | 51 ++ src/i18n/locales.test.ts | 39 ++ src/i18n/locales.ts | 41 ++ src/i18n/resources.ts | 711 ++++++++++++++++++++ src/i18next.d.ts | 11 + src/main.tsx | 12 +- src/store/useDownloadStore.ts | 13 +- src/store/useSettingsStore.ts | 5 +- src/utils/addDownloadMetadata.ts | 56 +- 28 files changed, 1845 insertions(+), 652 deletions(-) create mode 100644 src/i18n/index.test.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales.test.ts create mode 100644 src/i18n/locales.ts create mode 100644 src/i18n/resources.ts create mode 100644 src/i18next.d.ts diff --git a/package-lock.json b/package-lock.json index 103430a..1e9a48f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,11 @@ "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", + "i18next": "^26.3.6", "lucide-react": "^1.24.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.10", "zustand": "^5.0.14" }, "devDependencies": { @@ -39,6 +41,15 @@ "node": ">=22" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1791,6 +1802,43 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2190,6 +2238,33 @@ "react": "^19.2.7" } }, + "node_modules/react-i18next": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", + "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2332,7 +2407,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc" @@ -2394,6 +2469,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -2561,6 +2645,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 19d030d..eec80c9 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,11 @@ "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", + "i18next": "^26.3.6", "lucide-react": "^1.24.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.10", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 5ab51e3..7804760 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,8 @@ import { getVersion } from '@tauri-apps/api/app'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; +import { syncDocumentLocale } from './i18n'; +import { useTranslation } from 'react-i18next'; let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); @@ -99,11 +101,22 @@ const playCompletionChime = async () => { }; function App() { + const { i18n, t } = useTranslation(); const platform = usePlatformInfo(); const [filter, setFilter] = useState('all'); const [coreReady, setCoreReady] = useState(false); const [keychainConsentVersion, setKeychainConsentVersion] = useState(''); + useEffect(() => { + const handleLanguageChanged = (language?: string) => { + syncDocumentLocale(language ?? i18n.language); + }; + + handleLanguageChanged(); + i18n.on('languageChanged', handleLanguageChanged); + return () => i18n.off('languageChanged', handleLanguageChanged); + }, [i18n]); + const [sidebarWidth, setSidebarWidth] = useState(() => { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220; @@ -151,7 +164,7 @@ function App() { useEffect(() => subscribeToSettingsPersistenceErrors(() => { addToast({ - message: 'Could not save settings. Check storage permissions and try again.', + message: t($ => $.app.settingsSaveFailed), variant: 'error', isActionable: true }); @@ -181,7 +194,7 @@ function App() { const schedulePostQueueAction = useCallback((action: Exclude) => { clearPendingPostActionTimer(); - const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'; + const actionLabel = t($ => $.scheduler.postActions[action]); let timerId: number | null = null; let toastId: string | null = null; const cancel = () => { @@ -199,13 +212,13 @@ function App() { onDismiss: clearPendingPostActionTimer, message: (
- {actionLabel} in 10 seconds. + {t($ => $.app.systemActionCountdown, { action: actionLabel })}
) @@ -226,7 +239,7 @@ function App() { ); if (activeTransfers) { addToast({ - message: 'System action cancelled because another download is active or queued.', + message: t($ => $.app.systemActionCancelled), variant: 'warning', isActionable: true }); @@ -235,7 +248,7 @@ function App() { invoke('perform_system_action', { action }).catch(error => { console.error('Scheduled post action failed:', error); addToast({ - message: `Scheduled system action failed: ${String(error)}`, + message: t($ => $.app.systemActionFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -313,12 +326,12 @@ function App() { if (!settings.showNotifications) return; const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id); - const fileName = item?.fileName || 'A file'; + const fileName = item?.fileName || t($ => $.app.unknownFile); if (event.payload.status === 'completed') { try { sendNotification({ - title: 'Download Complete', - body: `${fileName} has finished downloading.` + title: t($ => $.app.downloadCompleteTitle), + body: t($ => $.app.downloadCompleteBody, { fileName }) }); } catch (error) { console.error('Completion notification failed:', error); @@ -326,8 +339,8 @@ function App() { } else { try { sendNotification({ - title: 'Download Failed', - body: `${fileName} failed to download.`, + title: t($ => $.app.downloadFailedTitle), + body: t($ => $.app.downloadFailedBody, { fileName }), }); } catch (error) { console.error('Failure notification failed:', error); @@ -371,7 +384,7 @@ function App() { if (!active) return; console.error('Failed to initialize Firelink state:', error); addToast({ - message: `Could not initialize saved downloads: ${String(error)}`, + message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -424,7 +437,7 @@ function App() { isActionable: true, message: (
-

Browser extension disconnected because its pairing token changed.

+

{t($ => $.app.extensionDisconnected)}

@@ -467,7 +480,7 @@ function App() { } catch (error) { console.error('Failed to hydrate extension pairing token:', error); addToast({ - message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`, + message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -521,7 +534,7 @@ function App() { useDownloadStore.getState().resumePendingDownloads().catch(error => { console.error('Failed to resume saved downloads after startup:', error); addToast({ - message: `Could not resume saved downloads: ${String(error)}`, + message: t($ => $.app.resumeDownloadsFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -545,14 +558,14 @@ function App() { .then(result => { if (result.type !== 'UpdateAvailable') return; if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) { - throw new Error('The update check returned an untrusted release URL.'); + throw new Error(t($ => $.settings.common.updateUntrustedReleaseUrl)); } addToast({ variant: 'info', isActionable: true, message: (
- Firelink {result.update.version} is available. + {t($ => $.app.updateAvailable, { version: result.update.version })}
) @@ -595,7 +608,7 @@ function App() { }).catch(error => { console.error('Failed to update sleep prevention:', error); addToast({ - message: `Could not update sleep prevention: ${String(error)}`, + message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -634,7 +647,7 @@ function App() { state.setSchedulerActiveDownloadIds([]); state.setSchedulerRunning(false); addToast({ - message: 'Scheduler has no valid queues selected. Update Scheduler settings.', + message: t($ => $.app.schedulerNoQueues), variant: 'warning', isActionable: true }); @@ -668,7 +681,9 @@ function App() { const failedPauses = pauseResults.filter(result => result.status === 'rejected').length; if (failedPauses > 0) { addToast({ - message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`, + message: failedPauses === 1 + ? t($ => $.app.schedulerPauseOneFailed) + : t($ => $.app.schedulerPauseManyFailed, { count: failedPauses }), variant: 'error', isActionable: true }); @@ -702,14 +717,14 @@ function App() { if (completionState !== 'completed') { addToast({ - message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.', + message: t($ => $.app.scheduledIncomplete), variant: 'warning', isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { if (downloads.some(download => isActiveDownloadStatus(download.status))) { addToast({ - message: 'Scheduled system action skipped because another download is active or queued.', + message: t($ => $.app.scheduledActionSkippedActive), variant: 'warning', isActionable: true }); @@ -736,7 +751,7 @@ function App() { const permission = await requestPermission(); if (permission !== 'granted') { addToast({ - message: 'System notifications are disabled for Firelink.', + message: t($ => $.app.notificationsDisabled), variant: 'warning', isActionable: true }); @@ -744,7 +759,7 @@ function App() { } } catch (error) { addToast({ - message: `Could not configure notifications: ${String(error)}`, + message: t($ => $.app.notificationsFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -920,7 +935,7 @@ function App() {
$.actions.resizeSidebar)} />
@@ -934,8 +949,8 @@ function App() { type="button" onClick={toggleSidebar} className="app-icon-button app-sidebar-reveal-button h-7 w-7" - title="Show Sidebar" - aria-label="Show Sidebar" + title={t($ => $.actions.showSidebar)} + aria-label={t($ => $.actions.showSidebar)} > @@ -950,11 +965,11 @@ function App() { {/* Status Bar */}
- Ready + {t($ => $.status.ready)}
- {activeDownloadCount} active - {queuedCount} queued - {doneCount} done + {t($ => $.status.active, { count: activeDownloadCount })} + {t($ => $.status.queued, { count: queuedCount })} + {t($ => $.status.done, { count: doneCount })}
diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index b141534..de91db3 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -22,12 +22,13 @@ import { import { getPlatformInfo } from '../utils/platform'; import { isTransferLocked } from '../utils/downloadActions'; import { useToast } from '../contexts/ToastContext'; +import { useTranslation } from 'react-i18next'; import { canSubmitMetadataRows, appendRequestUrlsAfterVersion, mediaFileNameForSelectedFormat, mediaFormatSelectorForRow, - metadataSummaryMessage, + metadataSummaryState, playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, @@ -36,7 +37,6 @@ import { } from '../utils/addDownloadMetadata'; const formatBytes = (bytes: number) => { - if (bytes === 0) return 'Unknown size'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); @@ -112,6 +112,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [ ].filter(Boolean).join('\n'); export const AddDownloadsModal = () => { + const { t } = useTranslation(); const { addToast } = useToast(); const { isAddModalOpen, @@ -214,7 +215,7 @@ export const AddDownloadsModal = () => { const hasPendingInput = Boolean( urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim() ); - if (hasPendingInput && !window.confirm('Discard this download setup?')) return; + if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return; toggleAddModal(false); }, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]); @@ -468,7 +469,7 @@ export const AddDownloadsModal = () => { }); if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return; if (!playlistData.entries.length) { - throw new Error('Playlist contains no downloadable entries'); + throw new Error(t($ => $.addDownloads.playlistNoEntries)); } setPlaylistExpansions(current => ({ ...current, @@ -492,7 +493,7 @@ export const AddDownloadsModal = () => { bytes, isApproximate, formatLabel: f.format_label || f.ext.toUpperCase(), - detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : 'Unknown size', + detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : t($ => $.addDownloads.unknownSize), selector: f.format_id, type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video' }; @@ -517,7 +518,7 @@ export const AddDownloadsModal = () => { }) )); } else { - throw new Error("Invalid media metadata or no formats found"); + throw new Error(t($ => $.addDownloads.invalidMediaMetadata)); } } else { let keychainPassword = null; @@ -657,7 +658,7 @@ export const AddDownloadsModal = () => { return; } if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) { - addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true }); return; } isSubmittingRef.current = true; @@ -723,12 +724,12 @@ export const AddDownloadsModal = () => { ) ); if (isUrlDupe) { - newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' }); + newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' }); } else if (hasBatchConflict) { newConflicts.push({ id: i.toString(), fileName: finalFile, - reason: { type: 'file', msg: 'Another selected download uses this destination' }, + reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) }, resolution: 'rename', replaceAllowed: false }); @@ -767,8 +768,8 @@ export const AddDownloadsModal = () => { reason: { type: 'file', msg: fileExistsInStore - ? 'Existing Firelink download uses this destination' - : 'File exists on disk; rename or skip to avoid deleting unrelated data' + ? t($ => $.addDownloads.existingDownloadDestination) + : t($ => $.addDownloads.fileExistsOnDisk) }, resolution: 'rename', replaceAllowed: fileExistsInStore @@ -881,7 +882,7 @@ export const AddDownloadsModal = () => { count++; } if (exists) { - throw new Error(`Could not find an available name for ${finalFile}.`); + throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile })); } itemsToAdd[idx] = { ...item, file: newName }; @@ -917,11 +918,11 @@ export const AddDownloadsModal = () => { } if (existingItem && isTransferLocked(existingItem.status)) { - throw new Error(`Pause ${existingItem.fileName} before replacing it.`); + throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName })); } if (!existingItem) { - throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`); + throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); } // Let the backend decide whether resumable sidecars still // exist after stopping the old transfer. This avoids a race @@ -972,7 +973,7 @@ export const AddDownloadsModal = () => { sizeBytes: item.sizeBytes }, action); if (!added) { - throw new Error('Backend rejected download start.'); + throw new Error(t($ => $.addDownloads.backendRejectedStart)); } addedCount += 1; } catch (e) { @@ -983,13 +984,15 @@ export const AddDownloadsModal = () => { toggleAddModal(false); if (failures.length > 0) { addToast({ - message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`, + message: t($ => $.addDownloads.addedWithFailures, { added: addedCount, failed: failures.length, detail: failures[0] }), variant: 'error', isActionable: true }); } else if (addedCount > 0) { addToast({ - message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`, + message: addedCount === 1 + ? t($ => $.addDownloads.addedOne) + : t($ => $.addDownloads.addedMany, { count: addedCount }), variant: 'success' }); } @@ -1070,6 +1073,21 @@ export const AddDownloadsModal = () => { ); const playlistSummaries = Object.entries(playlistExpansions) .filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl)); + const metadataSummary = (() => { + const summary = metadataSummaryState(parsedItems); + const plural = (count: number) => count === 1 ? '' : 's'; + switch (summary.type) { + case 'empty': return t($ => $.addDownloads.pasteOneOrMore); + case 'none-selected': return t($ => $.addDownloads.selectAtLeastOne); + case 'invalid': return t($ => $.addDownloads.correctInvalid, { count: summary.count, plural: plural(summary.count) }); + case 'loading': return t($ => $.addDownloads.waitingForMetadata, { count: summary.count, plural: plural(summary.count) }); + case 'unsafe': return t($ => $.addDownloads.removeUnsafe, { count: summary.count, plural: plural(summary.count) }); + case 'media-error': return t($ => $.addDownloads.mediaMetadataUnavailableSummary, { count: summary.count, plural: plural(summary.count) }); + case 'all-error': return t($ => $.addDownloads.metadataUnavailableFallback); + case 'fallback': return t($ => $.addDownloads.fallbackReady, { ready: summary.ready, readyPlural: plural(summary.ready), failed: summary.failed }); + case 'ready': return t($ => $.addDownloads.readyToAdd, { count: summary.count, plural: plural(summary.count) }); + } + })(); return ( <> @@ -1090,7 +1108,7 @@ export const AddDownloadsModal = () => { ) .catch(error => { addToast({ - message: `Could not resolve duplicate downloads: ${String(error)}`, + message: t($ => $.addDownloads.duplicateResolveFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -1124,12 +1142,12 @@ export const AddDownloadsModal = () => {
- Download Links + {t($ => $.addDownloads.downloadLinks)}
- +
@@ -456,7 +482,7 @@ export const PropertiesModal = () => { onClick={() => setSelectedPropertiesDownloadId(null)} className="app-button px-4 text-xs" > - Cancel + {t($ => $.properties.cancel)} diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 889e0c8..1153fd7 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -10,22 +10,23 @@ import { isActiveDownloadStatus } from '../utils/downloads'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { usePlatformInfo } from '../utils/platform'; +import { useTranslation } from 'react-i18next'; const days = [ - { value: 0, label: 'Su' }, - { value: 1, label: 'Mo' }, - { value: 2, label: 'Tu' }, - { value: 3, label: 'We' }, - { value: 4, label: 'Th' }, - { value: 5, label: 'Fr' }, - { value: 6, label: 'Sa' }, -]; + { value: 0, key: 'su' }, + { value: 1, key: 'mo' }, + { value: 2, key: 'tu' }, + { value: 3, key: 'we' }, + { value: 4, key: 'th' }, + { value: 5, key: 'fr' }, + { value: 6, key: 'sa' }, +] as const; -const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [ - { value: 'none', label: 'Do nothing', icon: CheckCircle2 }, - { value: 'sleep', label: 'Sleep', icon: Moon }, - { value: 'restart', label: 'Restart', icon: RotateCcw }, - { value: 'shutdown', label: 'Shut down', icon: Power }, +const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [ + { value: 'none', icon: CheckCircle2 }, + { value: 'sleep', icon: Moon }, + { value: 'restart', icon: RotateCcw }, + { value: 'shutdown', icon: Power }, ]; const minuteOfDay = (value: string) => { @@ -33,8 +34,8 @@ const minuteOfDay = (value: string) => { return hour * 60 + minute; }; -function nextScheduledRun(settings: SchedulerSettings): string { - if (!settings.enabled) return 'Scheduler is disabled'; +function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' { + if (!settings.enabled) return 'disabled'; const [hour, minute] = settings.startTime.split(':').map(Number); const now = new Date(); @@ -45,20 +46,15 @@ function nextScheduledRun(settings: SchedulerSettings): string { candidate.setHours(hour, minute, 0, 0); const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay()); if (allowedDay && candidate > now) { - return candidate.toLocaleString(undefined, { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }); + return candidate; } } - return 'No scheduled day selected'; + return 'none'; } export default function SchedulerView() { + const { t } = useTranslation(); const savedSettings = useSettingsStore(state => state.scheduler); const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const setScheduler = useSettingsStore(state => state.setScheduler); @@ -75,7 +71,18 @@ export default function SchedulerView() { }, [savedSettings]); - const nextRun = useMemo(() => nextScheduledRun(draft), [draft]); + const nextRun = useMemo(() => { + const scheduledRun = nextScheduledRun(draft); + if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled); + if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay); + return scheduledRun.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); + }, [draft, t]); const hasUnsavedChanges = useMemo( () => JSON.stringify(draft) !== JSON.stringify(savedSettings), [draft, savedSettings] @@ -116,15 +123,15 @@ export default function SchedulerView() { const save = () => { if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) { - addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.scheduler.validationDay), variant: 'error', isActionable: true }); return; } if (draft.enabled && effectiveSelectedQueueIds.length === 0) { - addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true }); return; } if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) { - addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true }); return; } const normalized = { @@ -134,7 +141,7 @@ export default function SchedulerView() { }; setScheduler(normalized); setDraft(normalized); - addToast({ message: 'Scheduler settings saved', variant: 'success' }); + addToast({ message: t($ => $.scheduler.saved), variant: 'success' }); }; const runNow = async () => { @@ -155,9 +162,14 @@ export default function SchedulerView() { if (activeIds.length > 0) { useSettingsStore.getState().setSchedulerRunning(true); useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds); - addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' }); + addToast({ + message: activeIds.length === 1 + ? t($ => $.scheduler.trackingOne) + : t($ => $.scheduler.trackingMany, { count: activeIds.length }), + variant: 'success' + }); } else { - addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' }); + addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' }); } }; @@ -168,7 +180,14 @@ export default function SchedulerView() { const count = counts.reduce((total, queueCount) => total + queueCount, 0); useSettingsStore.getState().setSchedulerRunning(false); useSettingsStore.getState().setSchedulerActiveDownloadIds([]); - addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' }); + addToast({ + message: count === 0 + ? t($ => $.scheduler.noActiveDownloads) + : count === 1 + ? t($ => $.scheduler.pausedOne) + : t($ => $.scheduler.pausedMany, { count }), + variant: 'info' + }); }; const refreshPermissionStatus = useCallback(async (showMessage = false) => { @@ -178,12 +197,12 @@ export default function SchedulerView() { await invoke('check_automation_permission'); setAutomationPermissionGranted(true); if (showMessage) { - setPermissionMessage('Automation permission is available.'); + setPermissionMessage(t($ => $.scheduler.permissionAvailable)); } } catch { setAutomationPermissionGranted(false); if (showMessage) { - setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.'); + setPermissionMessage(t($ => $.scheduler.permissionMissingDetails)); } } }, [isMac]); @@ -222,18 +241,18 @@ export default function SchedulerView() { const handlePermissionAction = async () => { if (automationPermissionGranted) { - await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.'); + await openAutomationSettings(t($ => $.scheduler.revokePermissionDetails)); return; } - setPermissionMessage('Requesting Automation permission...'); + setPermissionMessage(t($ => $.scheduler.requestingPermission)); try { await invoke('request_automation_permission'); setAutomationPermissionGranted(true); - setPermissionMessage('Automation permission is available.'); + setPermissionMessage(t($ => $.scheduler.permissionAvailable)); } catch { setAutomationPermissionGranted(false); - await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.'); + await openAutomationSettings(t($ => $.scheduler.enablePermissionDetails)); } }; @@ -253,27 +272,27 @@ export default function SchedulerView() { className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${draft.enabled ? 'translate-x-4' : 'translate-x-1'}`} /> - Scheduler + {t($ => $.scheduler.title)} - {schedulerRunning ? 'Running' : nextRun} + {schedulerRunning ? t($ => $.scheduler.running) : nextRun} {hasUnsavedChanges && ( - Unsaved changes + {t($ => $.scheduler.unsavedChanges)} )}
@@ -282,29 +301,29 @@ export default function SchedulerView() {
- Timing + {t($ => $.scheduler.timing)}
updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />

- If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed. + {t($ => $.scheduler.timingDescription)}

{!draft.everyday && (
@@ -320,7 +339,7 @@ export default function SchedulerView() { selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover' }`} > - {day.label} + {t($ => $.scheduler.days[day.key])} ); })} @@ -330,7 +349,7 @@ export default function SchedulerView() {
- Queues to Schedule + {t($ => $.scheduler.queuesToSchedule)}
{queues.map(queue => { @@ -346,7 +365,7 @@ export default function SchedulerView() { /> {queue.name} {queue.isMain && ( - Default queue + {t($ => $.scheduler.defaultQueue)} )} ); @@ -356,9 +375,9 @@ export default function SchedulerView() {
- After Completion + {t($ => $.scheduler.afterCompletion)}
-

Choose what happens after downloads started by the scheduler finish.

+

{t($ => $.scheduler.afterCompletionDescription)}

{postActions.map(action => { const Icon = action.icon; @@ -368,13 +387,13 @@ export default function SchedulerView() { }`}> updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" /> - {action.label} + {t($ => $.scheduler.postActions[action.value])} ); })}
{draft.postQueueAction !== 'none' && ( -

This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.

+

{t($ => $.scheduler.actionWarning)}

)}
@@ -382,27 +401,27 @@ export default function SchedulerView() { {isMac ? (
- System Permissions + {t($ => $.scheduler.systemPermissions)}
-

Sleep, restart, and shut down require macOS Automation permission for System Events.

+

{t($ => $.scheduler.macPermissionDescription)}

{automationPermissionGranted ? ( <> - Automation permission granted + {t($ => $.scheduler.permissionGranted)} ) : ( <> - {automationPermissionGranted === null ? 'Checking Automation permission...' : 'Automation permission missing'} + {automationPermissionGranted === null ? t($ => $.scheduler.permissionChecking) : t($ => $.scheduler.permissionMissing)} )}
{permissionMessage &&

{permissionMessage}

} @@ -410,11 +429,12 @@ export default function SchedulerView() { ) : (
- System Actions + {t($ => $.scheduler.systemActions)}

- Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}. - Firelink reports any rejected action when it runs; no permanent permission is claimed in advance. + {platform.os === 'windows' + ? t($ => $.scheduler.windowsActionsDescription) + : t($ => $.scheduler.linuxActionsDescription)}

)} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index ee9f61c..9398a0f 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -31,17 +31,18 @@ import { import { usePlatformInfo } from '../utils/platform'; import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; import { normalizeCustomProxy } from '../store/useDownloadStore'; +import { useTranslation } from 'react-i18next'; -const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [ - { type: 'downloads', label: 'Downloads', icon: Download }, - { type: 'lookandfeel', label: 'Look and feel', icon: Palette }, - { type: 'network', label: 'Network', icon: Globe }, - { type: 'locations', label: 'Locations', icon: Folder }, - { type: 'sitelogins', label: 'Site Logins', icon: Key }, - { type: 'power', label: 'Power', icon: Moon }, - { type: 'engine', label: 'Engine', icon: Terminal }, - { type: 'integrations', label: 'Integrations', icon: Puzzle }, - { type: 'about', label: 'About', icon: Info }, +const settingsTabs: { type: SettingsTab; icon: typeof Download }[] = [ + { type: 'downloads', icon: Download }, + { type: 'lookandfeel', icon: Palette }, + { type: 'network', icon: Globe }, + { type: 'locations', icon: Folder }, + { type: 'sitelogins', icon: Key }, + { type: 'power', icon: Moon }, + { type: 'engine', icon: Terminal }, + { type: 'integrations', icon: Puzzle }, + { type: 'about', icon: Info }, ]; const engineChecks = [ @@ -172,15 +173,18 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => { const CategoryFolderInput = ({ category, + categoryLabel, settings, onBrowse, disabled = false }: { category: string; + categoryLabel: string; settings: SettingsState; onBrowse: () => void; disabled?: boolean; }) => { + const { t } = useTranslation(); const base = settings.baseDownloadFolder || '~/Downloads'; const sub = Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category) ? settings.categorySubfolders[category] @@ -222,14 +226,14 @@ const CategoryFolderInput = ({ setLocalValue(null); }} className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay disabled:opacity-60 disabled:cursor-not-allowed" - aria-label={`${category} subfolder`} + aria-label={t($ => $.settings.locations.categorySubfolder, { category: categoryLabel })} /> {override && ( )}
@@ -247,6 +251,7 @@ const CategoryFolderInput = ({ }; export default function SettingsView() { + const { t } = useTranslation(); const settings = useSettingsStore(); const activeTab = settings.activeSettingsTab; const platform = usePlatformInfo(); @@ -256,22 +261,22 @@ export default function SettingsView() { : platform.os === 'windows' ? 'Windows' : platform.os === 'linux' - ? 'Linux' - : 'this OS'; + ? 'Linux' + : t($ => $.settings.common.thisOs); const trayIconLabel = platform.os === 'macos' - ? 'Show menu bar icon' + ? t($ => $.settings.lookAndFeel.menuBarIcon) : platform.os === 'linux' - ? 'Show status indicator icon' - : 'Show system tray icon'; + ? t($ => $.settings.lookAndFeel.statusIndicatorIcon) + : t($ => $.settings.lookAndFeel.systemTrayIcon); const trayIconDescription = platform.os === 'macos' - ? 'Provides quick access from the macOS menu bar.' + ? t($ => $.settings.lookAndFeel.macosTrayDescription) : platform.os === 'windows' - ? 'Provides quick access from the Windows notification area.' + ? t($ => $.settings.lookAndFeel.windowsTrayDescription) : platform.os === 'linux' - ? 'Provides quick access from the desktop tray or status area when available.' - : 'Provides quick access from the OS tray area when available.'; + ? t($ => $.settings.lookAndFeel.linuxTrayDescription) + : t($ => $.settings.lookAndFeel.defaultTrayDescription); const userAgentMenuRef = useRef(null); const [isUserAgentMenuOpen, setIsUserAgentMenuOpen] = useState(false); @@ -280,7 +285,7 @@ const [engineStatus, setEngineStatus] = useState(null const [expandedEngine, setExpandedEngine] = useState(null); const [isRecheckingEngines, setIsRecheckingEngines] = useState(false); const engineRunId = useRef(0); - const [appVersion, setAppVersion] = useState('Unknown'); + const [appVersion, setAppVersion] = useState(''); const [extensionServerPort, setExtensionServerPort] = useState(null); const [systemProxyStatus, setSystemProxyStatus] = useState('idle'); @@ -399,23 +404,23 @@ runEngineChecks(false); try { await openUrl(url); } catch (error) { - showToast(`Could not open ${label}: ${String(error)}`, 'error'); + showToast(t($ => $.settings.common.externalOpenFailed, { label, detail: String(error) }), 'error'); } }; const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null; const renderEngineStatus = (item: EngineStatusItem | null) => { - if (!item) return Checking...; - if (item.ready) return Ready; - return Error / Missing; + if (!item) return {t($ => $.settings.common.checking)}; + if (item.ready) return {t($ => $.settings.common.ready)}; + return {t($ => $.settings.common.errorMissing)}; }; const renderEngineVersion = (item: EngineStatusItem | null) => { - if (!item) return 'Checking...'; + if (!item) return t($ => $.settings.common.checking); if (item.version) return item.version; - if (item.error) return `Error: ${item.error.length > 80 ? item.error.substring(0, 80) + '…' : item.error}`; - return 'Unknown'; + if (item.error) return `${t($ => $.settings.common.error)}: ${item.error.length > 80 ? item.error.substring(0, 80) + '…' : item.error}`; + return t($ => $.addDownloads.unknown); }; const renderEngineDetails = (item: EngineStatusItem | null) => { @@ -427,22 +432,22 @@ runEngineChecks(false); onClick={() => setExpandedEngine(isExpanded ? null : item.kind)} className="text-accent text-[11px] font-medium hover:underline mt-1" > - {isExpanded ? 'Hide technical details' : 'Show technical details'} + {isExpanded ? t($ => $.settings.common.hideTechnicalDetails) : t($ => $.settings.common.showTechnicalDetails)} {isExpanded && (
- {item.resolved_path &&

Binary: {item.resolved_path}

} - {item.expected_sidecar &&

Expected: {item.expected_sidecar}

} - {item.error &&

Error: {item.error}

} - {item.remediation_hint &&

Tip: {item.remediation_hint}

} - {item.stderr_tail &&
stderr
{item.stderr_tail}
} - {item.daemon_alive != null &&

Daemon process alive: {String(item.daemon_alive)}

} - {item.rpc_ready != null &&

RPC ready: {String(item.rpc_ready)}

} - {item.rpc_port != null &&

RPC port: {item.rpc_port}

} - {item.last_stderr_tail &&
daemon stderr
{item.last_stderr_tail}
} - {item.expects_internal_dir === true &&

Packaging: PyInstaller onedir (_internal required)

} - {item.has_internal_dir === true &&

_internal directory found: true

} - {item.has_python_framework != null &&

Python runtime found: {String(item.has_python_framework)}

} + {item.resolved_path &&

{t($ => $.settings.common.binary)}: {item.resolved_path}

} + {item.expected_sidecar &&

{t($ => $.settings.common.expected)}: {item.expected_sidecar}

} + {item.error &&

{t($ => $.settings.common.error)}: {item.error}

} + {item.remediation_hint &&

{t($ => $.settings.common.tip)}: {item.remediation_hint}

} + {item.stderr_tail &&
{t($ => $.settings.common.stderr)}
{item.stderr_tail}
} + {item.daemon_alive != null &&

{t($ => $.settings.common.daemonProcessAlive)}: {String(item.daemon_alive)}

} + {item.rpc_ready != null &&

{t($ => $.settings.common.rpcReady)}: {String(item.rpc_ready)}

} + {item.rpc_port != null &&

{t($ => $.settings.common.rpcPort)}: {item.rpc_port}

} + {item.last_stderr_tail &&
{t($ => $.settings.common.daemonStderr)}
{item.last_stderr_tail}
} + {item.expects_internal_dir === true &&

{t($ => $.settings.common.packaging)}

} + {item.has_internal_dir === true &&

{t($ => $.settings.common.internalDirectoryFound)}: true

} + {item.has_python_framework != null &&

{t($ => $.settings.common.pythonRuntimeFound)}: {String(item.has_python_framework)}

}
)} @@ -466,7 +471,7 @@ runEngineChecks(false); }); } else if (result.type === 'UpdateAvailable') { if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) { - throw new Error('The update check returned an untrusted release URL.'); + throw new Error(t($ => $.settings.common.updateUntrustedReleaseUrl)); } setManualUpdateStatus({ type: 'update-available', @@ -476,13 +481,13 @@ runEngineChecks(false); } else { setManualUpdateStatus({ type: 'error', - message: 'The update check returned an unexpected response.' + message: t($ => $.settings.common.updateUnexpectedResponse) }); } } catch (error) { setManualUpdateStatus({ type: 'error', - message: `Update check failed: ${String(error)}` + message: t($ => $.settings.common.updateFailed, { detail: String(error) }) }); } finally { setIsCheckingForUpdates(false); @@ -538,10 +543,10 @@ runEngineChecks(false); } } catch (e) { console.error("Failed to create directories on disk:", e); - showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning'); + showToast(t($ => $.settings.locations.baseFolderCreateFailed, { detail: String(e) }), 'warning'); return; } - showToast("Base download folder updated", 'success'); + showToast(t($ => $.settings.locations.baseFolderUpdated), 'success'); } } catch (e) { console.error("Failed to browse base path:", e); @@ -552,15 +557,15 @@ runEngineChecks(false); if (saveLoginInFlight.current) return; const fieldErrors: typeof loginFieldErrors = {}; if (!loginPattern.trim()) { - fieldErrors.pattern = 'URL pattern is required.'; + fieldErrors.pattern = t($ => $.settings.siteLogins.urlPatternRequired); } else if (/\s/.test(loginPattern.trim())) { - fieldErrors.pattern = 'URL pattern cannot contain whitespace.'; + fieldErrors.pattern = t($ => $.settings.siteLogins.urlPatternWhitespace); } if (!loginUser.trim()) { - fieldErrors.username = 'Username is required.'; + fieldErrors.username = t($ => $.settings.siteLogins.usernameRequired); } if (!loginPass) { - fieldErrors.password = 'Password is required.'; + fieldErrors.password = t($ => $.settings.siteLogins.passwordRequired); } if (Object.keys(fieldErrors).length > 0) { setLoginFieldErrors(fieldErrors); @@ -572,7 +577,7 @@ runEngineChecks(false); if (!settings.keychainAccessReady) { settings.setShowKeychainModal(true); - setLoginError('Grant credential-store access before saving a site login.'); + setLoginError(t($ => $.settings.siteLogins.accessBeforeSave)); return; } saveLoginInFlight.current = true; @@ -593,7 +598,7 @@ runEngineChecks(false); }); } catch (e) { console.error("Failed to save site login:", e); - setLoginError("Failed to save site credential securely."); + setLoginError(t($ => $.settings.siteLogins.saveFailed)); return; } finally { saveLoginInFlight.current = false; @@ -604,19 +609,41 @@ runEngineChecks(false); setLoginUser(''); setLoginPass(''); setLoginError(''); - showToast("Added site credential", 'success'); + showToast(t($ => $.settings.siteLogins.addedCredential), 'success'); }; const copyToken = async () => { try { await navigator.clipboard.writeText(settings.extensionPairingToken); - showToast("Token copied to clipboard!", 'success'); + showToast(t($ => $.settings.integrations.tokenCopied), 'success'); } catch (error) { - showToast(`Could not copy token: ${String(error)}`, 'error'); + showToast(t($ => $.settings.integrations.tokenCopyFailed, { detail: String(error) }), 'error'); } }; - const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads'; + const tabLabels: Record = { + downloads: t($ => $.settings.tabs.downloads), + lookandfeel: t($ => $.settings.tabs.lookAndFeel), + network: t($ => $.settings.tabs.network), + locations: t($ => $.settings.tabs.locations), + sitelogins: t($ => $.settings.tabs.siteLogins), + power: t($ => $.settings.tabs.power), + engine: t($ => $.settings.tabs.engine), + integrations: t($ => $.settings.tabs.integrations), + about: t($ => $.settings.tabs.about), + }; + const activeTabLabel = tabLabels[activeTab] ?? t($ => $.settings.tabs.downloads); + const categoryLabel = (category: string) => { + switch (category) { + case 'Musics': return t($ => $.navigation.categories.musics); + case 'Movies': return t($ => $.navigation.categories.movies); + case 'Compressed': return t($ => $.navigation.categories.compressed); + case 'Documents': return t($ => $.navigation.categories.documents); + case 'Pictures': return t($ => $.navigation.categories.pictures); + case 'Applications': return t($ => $.navigation.categories.applications); + default: return t($ => $.navigation.categories.other); + } + }; const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => { const active = activeTab === type; @@ -645,7 +672,7 @@ runEngineChecks(false);
{settingsTabs.map(tab => ( - + ))}
@@ -662,8 +689,8 @@ runEngineChecks(false);
- Default connections: - New downloads; existing items keep their saved value + {t($ => $.settings.downloads.defaultConnections)} + {t($ => $.settings.downloads.defaultConnectionsDescription)}
- Parallel downloads: - Max simultaneous active files + {t($ => $.settings.downloads.parallelDownloads)} + {t($ => $.settings.downloads.parallelDownloadsDescription)}
- Automatic retries: - If a connection fails + {t($ => $.settings.downloads.automaticRetries)} + {t($ => $.settings.downloads.automaticRetriesDescription)}
@@ -1452,22 +1491,20 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
- Firelink Icon + {t($ $.settings.about.firelinkIcon)} className="w-[68px] h-[68px] drop-shadow-md rounded-xl shrink-0" />

Firelink

-

Version {appVersion}

-

- Cross-platform download manager for macOS, Windows, and Linux. -

+

{t($ => $.settings.about.version, { version: appVersion || t($ => $.settings.about.unknown) })}

+

{t($ => $.settings.about.description)}

@@ -1492,19 +1529,19 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled )} {manualUpdateStatus.type === 'up-to-date' && - `Firelink ${manualUpdateStatus.latestVersion} is up to date.`} + t($ => $.settings.about.upToDate, { version: manualUpdateStatus.latestVersion })} {manualUpdateStatus.type === 'update-available' && - `Firelink ${manualUpdateStatus.version} is available.`} + t($ => $.settings.about.updateAvailable, { version: manualUpdateStatus.version })} {manualUpdateStatus.type === 'error' && manualUpdateStatus.message}
{manualUpdateStatus.type === 'update-available' && ( )} @@ -1512,12 +1549,12 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled )}
-

Updates

+

{t($ => $.settings.about.updates)}

-

Check for Updates

-

Firelink checks GitHub Releases for new versions.

+

{t($ => $.settings.about.checkForUpdates)}

+

{t($ => $.settings.about.updateDescription)}

- Credits: aria2, yt-dlp, FFmpeg, and Deno. + {t($ => $.settings.about.credits)}
- Copyright © 2026 NimBold. All rights reserved. + {t($ => $.settings.about.copyright)}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 28a29ed..22baca7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { isTransferActiveStatus } from '../utils/downloads'; +import { useTranslation } from 'react-i18next'; export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string; @@ -24,6 +25,7 @@ export const Sidebar: React.FC = (props) => { const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore(); const { activeView, setActiveView, toggleSidebar } = useSettingsStore(); const { addToast } = useToast(); + const { t } = useTranslation(); const [isAddingQueue, setIsAddingQueue] = useState(false); const [newQueueName, setNewQueueName] = useState(''); @@ -84,11 +86,11 @@ export const Sidebar: React.FC = (props) => { const queueId = selectedFilter.replace('queue:', ''); const q = queues.find(q => q.id === queueId); if (q && !q.isMain) { - if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) { + if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: q.name }))) { return; } void removeQueue(queueId).catch(error => { - addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true }); + addToast({ message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }), variant: 'error', isActionable: true }); }); } } @@ -144,11 +146,11 @@ export const Sidebar: React.FC = (props) => { if (addQueueSubmitRef.current) return; const normalizedName = newQueueName.trim(); if (!normalizedName) { - addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true }); return; } if (!addQueue(normalizedName)) { - addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true }); return; } addQueueSubmitRef.current = true; @@ -161,11 +163,11 @@ export const Sidebar: React.FC = (props) => { const normalizedName = editingQueueName.trim(); if (!renamingQueueId) return; if (!normalizedName) { - addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true }); return; } if (!renameQueue(renamingQueueId, normalizedName)) { - addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true }); + addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true }); return; } renameQueueSubmitRef.current = true; @@ -240,18 +242,18 @@ export const Sidebar: React.FC = (props) => { type="button" onClick={toggleSidebar} className="sidebar-toggle-button" - title="Hide Sidebar" + title={t($ => $.actions.hideSidebar)} >
-
Library
- - - - +
{t($ => $.navigation.library)}
+ $.navigation.filters.all)} filter="all" /> + $.navigation.filters.active)} filter="active" /> + $.navigation.filters.completed)} filter="completed" /> + $.navigation.filters.unfinished)} filter="unfinished" />
@@ -263,7 +265,7 @@ export const Sidebar: React.FC = (props) => { aria-controls="sidebar-folders-list" onClick={() => setFoldersCollapsed(collapsed => !collapsed)} > - Folders + {t($ => $.navigation.folders)}
-
Queues
+
{t($ => $.navigation.queues)}
{queues.map(queue => ( ))} @@ -300,7 +302,7 @@ export const Sidebar: React.FC = (props) => { $.actions.queueName)} className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0" value={newQueueName} onChange={e => setNewQueueName(e.target.value)} @@ -318,16 +320,16 @@ export const Sidebar: React.FC = (props) => { className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1" > - Add new queue + {t($ => $.actions.addNewQueue)} )}
-
Tools
- - - +
{t($ => $.navigation.tools)}
+ $.navigation.scheduler)} view="scheduler" /> + $.navigation.speedLimiter)} view="speedLimiter" /> + $.navigation.logs)} view="logs" />
@@ -339,7 +341,7 @@ export const Sidebar: React.FC = (props) => { className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors" > - Settings + {t($ => $.navigation.settings)} @@ -360,7 +362,7 @@ export const Sidebar: React.FC = (props) => { setContextMenu(null); void startQueue(queueId).catch(error => { addToast({ - message: `Could not start queue: ${String(error)}`, + message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -368,7 +370,7 @@ export const Sidebar: React.FC = (props) => { }} > - Start Queue + {t($ => $.actions.startQueue)}
{!queues.find(q => q.id === contextMenu.id)?.isMain && ( )}
diff --git a/src/components/SpeedLimiterView.tsx b/src/components/SpeedLimiterView.tsx index 70aba90..e754cb5 100644 --- a/src/components/SpeedLimiterView.tsx +++ b/src/components/SpeedLimiterView.tsx @@ -3,6 +3,7 @@ import { Gauge, Plus, Save, X, Zap } from 'lucide-react'; import { useSettingsStore } from '../store/useSettingsStore'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; +import { useTranslation } from 'react-i18next'; type SpeedUnit = 'KB/s' | 'MB/s'; @@ -89,6 +90,7 @@ export function formatPresetValue(value: number): string { } export default function SpeedLimiterView() { + const { t } = useTranslation(); const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB); const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit); @@ -132,12 +134,14 @@ export default function SpeedLimiterView() { setLastCustomSpeedLimitKiB(valueKiB); setLastCustomSpeedLimitUnit(unit); addToast({ - message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled', + message: enabled + ? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit }) + : t($ => $.speedLimiter.globalLimitDisabled), variant: 'success' }); } catch (error) { addToast({ - message: `Could not save global speed limit: ${String(error)}`, + message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }), variant: 'error', isActionable: true }); @@ -163,8 +167,8 @@ export default function SpeedLimiterView() { setValue(String(storedPresetDisplayValue)); addToast({ message: alreadyExists - ? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets` - : `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`, + ? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit }) + : t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }), variant: alreadyExists ? 'info' : 'success' }); }; @@ -174,7 +178,7 @@ export default function SpeedLimiterView() { const nextPresets = presetValues.filter(value => value !== presetValue); setSpeedLimitPresetValues(nextPresets); addToast({ - message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`, + message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }), variant: 'info' }); }; @@ -207,25 +211,25 @@ export default function SpeedLimiterView() { className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${enabled ? 'translate-x-4' : 'translate-x-1'}`} /> - Speed Limiter + {t($ => $.speedLimiter.title)} - {enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'} + {enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
- Global Speed Limit + {t($ => $.speedLimiter.globalSpeedLimit)}

- Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers. + {t($ => $.speedLimiter.description)}

@@ -257,7 +261,7 @@ export default function SpeedLimiterView() {
- Quick Presets + {t($ => $.speedLimiter.quickPresets)}
{presetValues.map(presetValue => { @@ -280,8 +284,8 @@ export default function SpeedLimiterView() { disabled={!enabled || isSaving} onClick={() => removePreset(presetValue)} className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50" - title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`} - aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`} + title={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })} + aria-label={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })} > @@ -297,7 +301,7 @@ export default function SpeedLimiterView() { disabled={!enabled || isSaving} onChange={event => setCustomPresetValue(event.target.value)} className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50" - aria-label={`Custom preset in ${unit}`} + aria-label={t($ => $.speedLimiter.customPresetIn, { unit })} /> {unit} diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index d790620..b609c3d 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -1,6 +1,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { Minus, Square, X } from 'lucide-react'; import type { PointerEvent } from 'react'; +import { useTranslation } from 'react-i18next'; const appWindow = getCurrentWindow(); @@ -9,13 +10,15 @@ const stopTitlebarDrag = (event: PointerEvent) => { }; export function WindowControls() { + const { t } = useTranslation(); + return ( -
+
$.window.controls)}> diff --git a/src/i18n/index.test.ts b/src/i18n/index.test.ts new file mode 100644 index 0000000..c451245 --- /dev/null +++ b/src/i18n/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import i18n, { i18nReady } from './index'; + +describe('i18n runtime', () => { + it('falls back to bundled English while planned catalogs are untranslated', async () => { + await i18nReady; + const originalLanguage = i18n.language; + + try { + await i18n.changeLanguage('fa'); + expect(i18n.t($ => $.navigation.library)).toBe('Library'); + } finally { + await i18n.changeLanguage(originalLanguage); + } + }); +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..6d48b34 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,51 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { + APP_LOCALES, + DEFAULT_LOCALE, + localeDirection, + resolveAppLocale, + type AppLocale, +} from './locales'; +import { defaultNS, resources } from './resources'; + +export { APP_LOCALES, APP_LOCALE_METADATA, DEFAULT_LOCALE, isAppLocale, localeDirection, resolveAppLocale } from './locales'; +export type { AppLocale, AppLocaleDirection } from './locales'; + +const initialLocale = resolveAppLocale( + typeof navigator === 'undefined' ? undefined : navigator.language +); + +export const i18nReady = i18n + .use(initReactI18next) + .init({ + defaultNS, + fallbackLng: DEFAULT_LOCALE, + initAsync: false, + lng: initialLocale, + interpolation: { + escapeValue: false, + }, + ns: [defaultNS], + resources, + returnNull: false, + supportedLngs: APP_LOCALES, + }); + +void i18nReady.then(() => syncDocumentLocale(initialLocale)); + +export const changeAppLocale = async (locale: AppLocale): Promise => { + await i18n.changeLanguage(locale); + syncDocumentLocale(locale); +}; + +export const syncDocumentLocale = (value: string | null | undefined): AppLocale => { + const locale = resolveAppLocale(value); + if (typeof document !== 'undefined') { + document.documentElement.lang = locale; + document.documentElement.dir = localeDirection(locale); + } + return locale; +}; + +export default i18n; diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts new file mode 100644 index 0000000..226d2df --- /dev/null +++ b/src/i18n/locales.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + APP_LOCALES, + DEFAULT_LOCALE, + isAppLocale, + localeDirection, + resolveAppLocale, +} from './locales'; + +describe('app locale metadata', () => { + it('keeps the planned locale set stable', () => { + expect(APP_LOCALES).toEqual(['en', 'zh-CN', 'he', 'fa', 'uk']); + expect(DEFAULT_LOCALE).toBe('en'); + }); + + it('normalizes common system language tags', () => { + expect(resolveAppLocale('en-US')).toBe('en'); + expect(resolveAppLocale('zh-Hans-CN')).toBe('zh-CN'); + expect(resolveAppLocale('iw-IL')).toBe('he'); + expect(resolveAppLocale('fa-IR')).toBe('fa'); + expect(resolveAppLocale('uk-UA')).toBe('uk'); + expect(resolveAppLocale('de-DE')).toBe('en'); + }); + + it('marks the planned RTL locales correctly', () => { + expect(localeDirection('en')).toBe('ltr'); + expect(localeDirection('zh-CN')).toBe('ltr'); + expect(localeDirection('he')).toBe('rtl'); + expect(localeDirection('fa')).toBe('rtl'); + expect(localeDirection('uk')).toBe('ltr'); + }); + + it('guards locale values before they reach i18next', () => { + expect(isAppLocale('en')).toBe(true); + expect(isAppLocale('fa')).toBe(true); + expect(isAppLocale('de')).toBe(false); + expect(isAppLocale(null)).toBe(false); + }); +}); diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts new file mode 100644 index 0000000..d39fdb4 --- /dev/null +++ b/src/i18n/locales.ts @@ -0,0 +1,41 @@ +export const APP_LOCALES = ['en', 'zh-CN', 'he', 'fa', 'uk'] as const; + +export type AppLocale = typeof APP_LOCALES[number]; +export type AppLocaleDirection = 'ltr' | 'rtl'; + +export const DEFAULT_LOCALE: AppLocale = 'en'; + +export const APP_LOCALE_METADATA: Record = { + en: { direction: 'ltr', isTranslated: true }, + 'zh-CN': { direction: 'ltr', isTranslated: false }, + he: { direction: 'rtl', isTranslated: false }, + fa: { direction: 'rtl', isTranslated: false }, + uk: { direction: 'ltr', isTranslated: false }, +}; + +export const isAppLocale = (value: unknown): value is AppLocale => + typeof value === 'string' && (APP_LOCALES as readonly string[]).includes(value); + +/** + * Resolve a system or persisted BCP 47 language tag to a planned Firelink + * locale. Non-English locales intentionally fall back to English resources + * until their translations are added. + */ +export const resolveAppLocale = (value: string | null | undefined): AppLocale => { + const normalized = value?.trim().replace(/_/g, '-').toLowerCase(); + if (!normalized) return DEFAULT_LOCALE; + + if (normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN'; + if (normalized === 'he' || normalized.startsWith('he-') || normalized === 'iw' || normalized.startsWith('iw-')) return 'he'; + if (normalized === 'fa' || normalized.startsWith('fa-')) return 'fa'; + if (normalized === 'uk' || normalized.startsWith('uk-')) return 'uk'; + if (normalized === 'en' || normalized.startsWith('en-')) return 'en'; + + return DEFAULT_LOCALE; +}; + +export const localeDirection = (locale: AppLocale): AppLocaleDirection => + APP_LOCALE_METADATA[locale].direction; diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts new file mode 100644 index 0000000..169a225 --- /dev/null +++ b/src/i18n/resources.ts @@ -0,0 +1,711 @@ +const common = { + navigation: { + library: 'Library', + filters: { + all: 'All', + active: 'Active', + completed: 'Completed', + unfinished: 'Unfinished', + }, + categories: { + musics: 'Musics', + movies: 'Movies', + compressed: 'Compressed', + documents: 'Documents', + pictures: 'Pictures', + applications: 'Applications', + other: 'Other', + }, + folders: 'Folders', + queues: 'Queues', + tools: 'Tools', + scheduler: 'Scheduler', + speedLimiter: 'Speed Limiter', + logs: 'Logs', + settings: 'Settings', + }, + actions: { + hideSidebar: 'Hide Sidebar', + showSidebar: 'Show Sidebar', + resizeSidebar: 'Resize Sidebar', + cancel: 'Cancel', + continue: 'Continue', + dismissNotification: 'Dismiss notification', + queueName: 'Queue name', + addNewQueue: 'Add new queue', + startQueue: 'Start Queue', + pauseQueue: 'Pause Queue', + renameQueue: 'Rename Queue', + deleteQueue: 'Delete Queue', + }, + dialogs: { + errorBoundary: { + title: 'Something went wrong.', + description: 'A critical error occurred in the React component tree. The error details below can help identify the root cause.', + reload: 'Reload App', + }, + duplicateDownloads: { + title: 'Duplicate Downloads Detected', + description: 'Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.', + rename: 'Rename', + replace: 'Replace', + skip: 'Skip', + }, + removeDownload: { + title: 'Remove Download', + confirmationSingle: 'Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.', + confirmationMultiple: 'Are you sure you want to remove these {{count}} items from the list? You can also choose to delete the underlying files from your hard drive.', + errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}', + remove: 'Remove', + deleteFile: 'Delete file', + }, + }, + window: { + controls: 'Window controls', + close: 'Close', + minimize: 'Minimize', + maximize: 'Maximize', + }, + downloads: { + actions: { + moveUp: 'Move Up', + moveDown: 'Move Down', + pause: 'Pause', + start: 'Start', + resume: 'Resume', + options: 'Options', + }, + size: { + downloadedOf: '{{downloaded}} downloaded of {{total}} {{unit}}', + downloadedOfApproximate: '{{downloaded}} downloaded of approximately {{total}} {{unit}}', + }, + status: { + ready: 'Ready', + staged: 'In queue', + queued: 'Queued', + downloading: 'Downloading', + processing: 'Processing', + paused: 'Paused', + completed: 'Completed', + failed: 'Failed', + retrying: 'Retrying', + }, + values: { + processing: 'Processing...', + muxing: 'Muxing...', + }, + }, + speedLimiter: { + title: 'Speed Limiter', + unlimited: 'Unlimited', + saveLimit: 'Save Limit', + globalSpeedLimit: 'Global Speed Limit', + description: 'Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.', + quickPresets: 'Quick Presets', + removePreset: 'Remove {{value}} {{unit}} preset', + customPresetIn: 'Custom preset in {{unit}}', + addQuickPreset: 'Add quick preset', + globalLimitSaved: 'Global limit saved at {{value}} {{unit}}', + globalLimitDisabled: 'Global speed limit disabled', + saveFailed: 'Could not save global speed limit: {{detail}}', + presetAlreadyExists: '{{value}} {{unit}} is already in quick presets', + presetAdded: 'Added {{value}} {{unit}} quick preset', + presetRemoved: 'Removed {{value}} {{unit}} quick preset', + }, + logs: { + title: 'Logs', + entries: '({{count}} entries)', + collecting: 'Collecting', + off: 'Off', + allLevels: 'All Levels', + levels: { + error: 'Error', + warn: 'Warn', + info: 'Info', + debug: 'Debug', + trace: 'Trace', + }, + pauseLogging: 'Pause diagnostic logging', + enableLogging: 'Enable diagnostic logging', + clearDisplayed: 'Clear displayed logs', + export: 'Export logs', + exportButton: 'Export Logs', + logFiles: 'Log Files', + localDiagnostics: 'Local diagnostics:', + diagnosticsDescription: 'Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.', + noEntries: 'No persisted log entries are available yet.', + disabledDescription: 'Diagnostic logging is off. Existing support logs will appear here when available.', + copy: 'Copy', + copied: 'Copied to clipboard', + copyFailed: 'Failed to copy to clipboard', + exported: 'Support logs exported', + exportFailed: 'Could not export logs: {{detail}}', + cleared: 'Logs cleared', + clearFailed: 'Could not clear logs: {{detail}}', + enabled: 'Diagnostic logging enabled', + disabled: 'Diagnostic logging disabled', + updateFailed: 'Could not update diagnostic logging: {{detail}}', + }, + scheduler: { + title: 'Scheduler', + running: 'Running', + disabled: 'Scheduler is disabled', + noScheduledDay: 'No scheduled day selected', + unsavedChanges: 'Unsaved changes', + runNow: 'Run Now', + pause: 'Pause', + saveSettings: 'Save Settings', + timing: 'Timing', + startTime: 'Start Time', + stopTime: 'Stop Time', + timingDescription: 'If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.', + runEveryDay: 'Run Every Day', + queuesToSchedule: 'Queues to Schedule', + defaultQueue: 'Default queue', + afterCompletion: 'After Completion', + afterCompletionDescription: 'Choose what happens after downloads started by the scheduler finish.', + actionWarning: 'This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.', + systemPermissions: 'System Permissions', + macPermissionDescription: 'Sleep, restart, and shut down require macOS Automation permission for System Events.', + permissionGranted: 'Automation permission granted', + permissionChecking: 'Checking Automation permission...', + permissionMissing: 'Automation permission missing', + revokePermission: 'Revoke permission', + grantPermission: 'Grant permission', + permissionAvailable: 'Automation permission is available.', + permissionMissingDetails: 'Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.', + revokePermissionDetails: 'macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.', + requestingPermission: 'Requesting Automation permission...', + enablePermissionDetails: 'Enable Firelink under Automation for System Events in System Settings, then return to Firelink.', + systemActions: 'System Actions', + windowsActionsDescription: 'Sleep, restart, and shut down use Windows system privileges. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.', + linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.', + validationDay: 'Select at least one day for the scheduler', + validationQueue: 'Select at least one queue for the scheduler', + validationStopTime: 'Stop time must be later than start time', + saved: 'Scheduler settings saved', + trackingOne: 'Tracking 1 scheduled download', + trackingMany: 'Tracking {{count}} scheduled downloads', + noStartableDownloads: 'No downloads in the selected queues can be started', + pausedOne: 'Paused 1 active download', + pausedMany: 'Paused {{count}} active downloads', + noActiveDownloads: 'No active downloads', + days: { + su: 'Su', + mo: 'Mo', + tu: 'Tu', + we: 'We', + th: 'Th', + fr: 'Fr', + sa: 'Sa', + }, + postActions: { + none: 'Do nothing', + sleep: 'Sleep', + restart: 'Restart', + shutdown: 'Shut down', + }, + }, + properties: { + progress: 'Progress', + size: 'Size', + speed: 'Speed', + eta: 'ETA', + connections: 'Connections', + speedCap: 'Speed cap', + category: 'Category', + lastTry: 'Last try', + dateAdded: 'Date added', + destination: 'Destination', + lastError: 'Last error', + saved: ' (saved)', + defaultValue: ' (default)', + savedTooltip: 'Saved for this download; Settings changes apply to new downloads.', + defaultTooltip: 'Using the current default for new downloads.', + identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.', + transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.', + download: 'Download', + url: 'URL', + fileName: 'File name', + saveLocation: 'Save location', + select: 'Select', + perFile: 'per file', + useCurrentDefault: 'Use current default ({{count}})', + savedPerDownload: 'Saved per download. The Settings default applies to new downloads.', + limit: 'Limit', + siteLogin: 'Site Login', + siteLoginRedownload: 'Site Login for Redownload', + matchingSiteLogin: 'Matching site login', + customCredentials: 'Custom credentials', + noLogin: 'No login', + useSavedLogin: 'Will use saved login if available.', + advancedTransfer: 'Advanced Transfer', + advancedTransferRedownload: 'Advanced Transfer for Redownload', + checksum: 'Checksum', + verify: 'Verify', + algorithm: 'Algorithm', + digest: 'Digest', + expectedDigest: 'Expected digest', + cookies: 'Cookies', + headers: 'Headers', + mirrors: 'Mirrors', + username: 'Username', + password: 'Password', + enterValidUrl: 'Enter a valid URL.', + fileNameEmpty: 'File name cannot be empty.', + cancel: 'Cancel', + save: 'Save', + }, + keychain: { + title: 'Credential Storage Access Needed', + stores: { + portable: 'the portable Firelink data folder', + windows: 'Windows Credential Manager', + linux: 'your Linux credential store', + macos: 'macOS Keychain', + system: "this system's credential store", + siteCredentials: "the system's credential store", + }, + grantLabelPortable: 'Continue', + grantLabelMacos: 'Grant Access', + grantLabelDefault: 'Enable Secure Storage', + description: 'Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts, Firelink stores its pairing token in {{pairingStore}}. Optional site credentials are stored in {{siteCredentialStore}}.', + portableExplanation: 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.', + macosExplanation: 'macOS may show a Keychain prompt after you grant access.', + defaultExplanation: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.', + note: 'Note:', + portableNote: 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.', + defaultNote: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.', + hint: 'Hint:', + portableHint: 'The portable pairing token is already stored with this folder; you can enable it here or select Later.', + defaultHint: 'If you select Later, the extension will only work for this session.', + enableFromSettings: 'You can enable storage anytime from Settings > Integrations.', + later: 'Later', + enabling: 'Enabling...', + timeout: 'Credential storage request timed out. You can select Later and try again.', + unavailable: '{{store}} is unavailable.', + accessRequired: 'Grant credential-store access before regenerating the pairing token.', + storeUnavailable: 'Credential store access is unavailable.', + }, + app: { + settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.', + systemActionCountdown: '{{action}} in 10 seconds.', + systemActionCancelled: 'System action cancelled because another download is active or queued.', + systemActionFailed: 'Scheduled system action failed: {{detail}}', + downloadCompleteTitle: 'Download Complete', + downloadCompleteBody: '{{fileName}} has finished downloading.', + unknownFile: 'A file', + downloadFailedTitle: 'Download Failed', + downloadFailedBody: '{{fileName}} failed to download.', + initializeDownloadsFailed: 'Could not initialize saved downloads: {{detail}}', + extensionDisconnected: 'Browser extension disconnected because its pairing token changed.', + copyToken: 'Copy token', + integrations: 'Integrations', + copyTokenFailed: 'Could not copy pairing token: {{detail}}', + credentialPersistenceFailed: 'Secure credential persistence is unavailable. Browser pairing works for this session only: {{detail}}', + resumeDownloadsFailed: 'Could not resume saved downloads: {{detail}}', + updateAvailable: 'Firelink {{version}} is available.', + viewRelease: 'View release', + sleepPreventionFailed: 'Could not update sleep prevention: {{detail}}', + schedulerNoQueues: 'Scheduler has no valid queues selected. Update Scheduler settings.', + schedulerPauseOneFailed: 'Scheduler could not pause 1 download.', + schedulerPauseManyFailed: 'Scheduler could not pause {{count}} downloads.', + scheduledIncomplete: 'Scheduled downloads did not all complete. The post-queue system action was skipped.', + scheduledActionSkippedActive: 'Scheduled system action skipped because another download is active or queued.', + notificationsDisabled: 'System notifications are disabled for Firelink.', + notificationsFailed: 'Could not configure notifications: {{detail}}', + }, + sidebar: { + deleteQueueConfirm: 'Delete queue "{{name}}"? Its unfinished downloads will move to Main Queue.', + deleteQueueFailed: 'Could not delete queue: {{detail}}', + queueNameEmpty: 'Queue name cannot be empty', + queueNameExists: 'A queue with this name already exists', + startQueueFailed: 'Could not start queue: {{detail}}', + pauseQueueFailed: 'Could not pause queue: {{detail}}', + }, + downloadTable: { + unknownQueue: 'Unknown Queue', + allDownloads: 'All Downloads', + active: 'Active', + completed: 'Completed', + unfinished: 'Unfinished', + headers: { + fileName: 'File Name', + size: 'Size', + status: 'Status', + speed: 'Speed', + eta: 'ETA', + dateAdded: 'Date Added', + }, + addDownload: 'Add Download', + resumeAll: 'Resume All', + pauseAll: 'Pause All', + queueEmpty: 'Queue is empty', + noCompletedDownloads: 'No Completed Downloads', + noDownloads: 'No Downloads', + queueEmptyDescription: 'Add downloads to this queue from an item menu or the Add window.', + completedDescription: 'Completed downloads will appear here.', + clickToAdd: 'Click', + addButtonOr: 'button or', + toAddDownloads: 'to add downloads', + nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?', + nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?', + interactionError: '{{message}}: {{detail}}', + openFileFailed: 'Could not open downloaded file', + revealFileFailed: 'Could not show download in Finder', + pauseFailed: 'Could not pause download', + resumeFailed: 'Could not resume {{fileName}}', + moveManyFailed: 'Could not move downloads to queue', + moveOneFailed: 'Could not move download to queue', + copyAddressesFailed: 'Could not copy addresses', + copyAddressFailed: 'Could not copy address', + copyPathFailed: 'Could not copy file path', + missingFileName: 'File name is missing', + redownloadFailed: 'Redownload failed', + startResume: 'Start/Resume', + addToQueue: 'Add to Queue', + copyAddress: 'Copy Address', + remove: 'Remove', + open: 'Open', + showInFolder: 'Show in Folder', + pause: 'Pause', + start: 'Start', + resume: 'Resume', + redownload: 'Redownload', + copyFilePath: 'Copy File Path', + properties: 'Properties', + backendRejectedStart: 'The backend rejected the start/resume request.', + transferActive: 'Cannot change properties while transfer is active. Pause it first.', + redownloadNotFound: 'Cannot redownload: download was not found.', + redownloadActive: 'Cannot redownload a {{status}} download. Pause or wait for it to finish first.', + originalUrlMissing: 'Cannot redownload: original URL is missing.', + pauseBeforeMove: 'Pause {{file}} before moving it to another queue.', + }, + addDownloads: { + unknownSize: 'Unknown size', + unknown: 'Unknown', + discardSetup: 'Discard this download setup?', + speedInvalid: 'Speed limit must be greater than zero', + duplicateResolveFailed: 'Could not resolve duplicate downloads: {{detail}}', + addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}', + addedOne: '1 download added', + addedMany: '{{count}} downloads added', + urlAlreadyQueued: 'URL already in queue', + destinationConflict: 'Another selected download uses this destination', + existingDownloadDestination: 'Existing Firelink download uses this destination', + fileExistsOnDisk: 'File exists on disk; rename or skip to avoid deleting unrelated data', + backendRejectedStart: 'Backend rejected download start.', + playlistNoEntries: 'Playlist contains no downloadable entries', + invalidMediaMetadata: 'Invalid media metadata or no formats found', + noAvailableName: 'Could not find an available name for {{file}}.', + pauseBeforeReplace: 'Pause {{file}} before replacing it.', + cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.', + downloadLinks: 'Download Links', + pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from Youtube, X, TikTok, Instagram, Reddit, etc.', + playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}', + safeEntryLimit: ' (safe entry limit reached)', + selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked', + clearSelection: 'Clear selection', + selectAll: 'Select all', + refreshMetadata: 'Refresh Metadata', + files: 'Files', + required: 'Required', + free: 'Free', + preview: 'Preview', + file: 'File', + size: 'Size', + status: 'Status', + downloadPreview: 'Download preview', + noLinks: 'No links added yet.', + selectItem: 'Select {{file}}', + fetchingPlaylist: 'Fetching playlist...', + fetching: 'Fetching...', + unsafeUrl: 'Unsafe URL', + playlistFailed: 'Playlist failed', + metadataFailed: 'Metadata failed', + fallback: 'Fallback', + invalid: 'Invalid', + ready: 'Ready', + mediaFormat: 'Media Format', + playlistItem: 'Playlist item {{index}}', + fetchingMediaStreams: 'Fetching media streams...', + availableStreams: 'Available Streams', + availableMediaStreams: 'Available media streams', + metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.', + saveLocation: 'Save Location', + browse: 'Browse', + categoryFolders: 'Files will be organized into category folders.', + sharedFolder: 'All selected downloads will use this folder.', + transferSettings: 'Transfer Settings', + connectionsPerFile: 'Connections per File', + connectionsPerFileAria: 'Connections per file', + limitSpeedPerFile: 'Limit speed per file', + speedLimitPerFileAria: 'Speed limit per file', + authorization: 'Authorization', + useAuthorization: 'Use authorization', + username: 'Username', + password: 'Password', + advancedTransfer: 'Advanced Transfer', + verifyChecksum: 'Verify Checksum', + checksumAlgorithm: 'Checksum algorithm', + expectedDigest: 'Expected digest', + headers: 'Headers', + requestHeaders: 'Request headers', + cookies: 'Cookies', + cookiePlaceholder: 'name=value; other=value', + mirrors: 'Mirrors', + cancel: 'Cancel', + startDownloads: 'Start Downloads', + addToQueue: 'Add to queue', + pasteOneOrMore: 'Paste one or more links.', + selectAtLeastOne: 'Select at least one download.', + correctInvalid: 'Correct or remove {{count}} invalid URL{{plural}} before continuing.', + waitingForMetadata: 'Waiting for metadata for {{count}} download{{plural}}.', + removeUnsafe: 'Remove {{count}} unsafe URL{{plural}} before continuing.', + mediaMetadataUnavailableSummary: 'Media metadata is unavailable for {{count}} item{{plural}}. Refresh metadata before adding.', + metadataUnavailableFallback: 'Metadata is unavailable. Downloads can still be added using fallback details.', + fallbackReady: '{{ready}} download{{readyPlural}} ready; {{failed}} will use fallback filename and unknown size.', + readyToAdd: 'Ready to add {{count}} download{{plural}}.', + }, + settings: { + tabs: { + downloads: 'Downloads', + lookAndFeel: 'Look and feel', + network: 'Network', + locations: 'Locations', + siteLogins: 'Site Logins', + power: 'Power', + engine: 'Engine', + integrations: 'Integrations', + about: 'About', + }, + common: { + thisOs: 'this OS', + checking: 'Checking...', + ready: 'Ready', + errorMissing: 'Error / Missing', + hideTechnicalDetails: 'Hide technical details', + showTechnicalDetails: 'Show technical details', + binary: 'Binary', + expected: 'Expected', + error: 'Error', + tip: 'Tip', + stderr: 'stderr', + daemonProcessAlive: 'Daemon process alive', + rpcReady: 'RPC ready', + rpcPort: 'RPC port', + daemonStderr: 'daemon stderr', + packaging: 'Packaging: PyInstaller onedir (_internal required)', + internalDirectoryFound: '_internal directory found', + pythonRuntimeFound: 'Python runtime found', + externalOpenFailed: 'Could not open {{label}}: {{detail}}', + updateUnexpectedResponse: 'The update check returned an unexpected response.', + updateUntrustedReleaseUrl: 'The update check returned an untrusted release URL.', + updateFailed: 'Update check failed: {{detail}}', + }, + downloads: { + defaultConnections: 'Default connections:', + defaultConnectionsDescription: 'New downloads; existing items keep their saved value', + parallelDownloads: 'Parallel downloads:', + parallelDownloadsDescription: 'Max simultaneous active files', + automaticRetries: 'Automatic retries:', + automaticRetriesDescription: 'If a connection fails', + systemNotification: 'Show system notification when download completes', + systemNotificationDescription: 'Uses your operating system notification settings', + completionChime: 'Play in-app completion chime', + completionChimeDescription: 'Optional Firelink sound, independent of system notifications', + clipboardLinks: 'Add clipboard links when Firelink becomes active', + clipboardLinksDescription: 'Opens supported copied links in the Add window. Off by default.', + }, + lookAndFeel: { + appTheme: 'App Theme', + theme: 'Theme', + ariaLabel: 'App theme', + system: 'System', + light: 'Light', + dark: 'Dark', + dracula: 'Dracula', + nord: 'Nord', + systemAppearance: 'System follows the current {{platform}} light or dark appearance.', + display: 'Display', + fontSize: 'Font size', + fontSizeDescription: 'Scales the download list and compact controls.', + small: 'Small', + standard: 'Standard', + large: 'Large', + listDensity: 'List density', + listDensityDescription: 'Changes row height, spacing, and progress bar scale.', + compact: 'Compact', + comfortable: 'Comfortable', + relaxed: 'Relaxed', + osIntegration: 'OS Integration', + dockBadge: 'Show badge on Dock icon', + dockBadgeDescription: 'Displays active download count on Firelink Dock icon.', + menuBarIcon: 'Show menu bar icon', + statusIndicatorIcon: 'Show status indicator icon', + systemTrayIcon: 'Show system tray icon', + macosTrayDescription: 'Provides quick access from the macOS menu bar.', + windowsTrayDescription: 'Provides quick access from the Windows notification area.', + linuxTrayDescription: 'Provides quick access from the desktop tray or status area when available.', + defaultTrayDescription: 'Provides quick access from the OS tray area when available.', + }, + network: { + proxy: 'Proxy', + mode: 'Mode', + modeDescription: 'Controls proxy use for new download requests.', + noProxy: 'No Proxy', + systemProxy: 'System Proxy', + customProxy: 'Custom Proxy', + proxyHost: 'Proxy host', + proxyHostDescription: 'Host name, IP address, or HTTP proxy URL.', + proxyHostPlaceholder: '127.0.0.1 or http://127.0.0.1', + proxyPort: 'Proxy port', + proxyPortDescription: 'Valid range is 1 to 65535.', + noProxyDescription: 'Downloads ignore configured proxies.', + systemProxyDescription: 'Downloads use the detected {{platform}} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.', + customProxyDescription: 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.', + invalidCustomProxy: 'Enter a valid HTTP proxy host and port to enable the custom proxy.', + incompleteCustomProxy: 'Enter a proxy host and port to enable the custom proxy.', + checkingSystemProxy: 'Checking system proxy configuration…', + detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.', + noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.', + systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.', + identity: 'Identity', + customUserAgent: 'Custom User-Agent', + userAgentDescription: 'Applied to metadata fetches and download engines.', + userAgentPlaceholder: 'Leave blank for Firelink default', + userAgentOverrides: 'Overrides the outbound User-Agent header. Leave blank for Firelink defaults.', + chromeWindows: 'Chrome (Windows)', + chromeMacos: 'Chrome (macOS)', + edgeWindows: 'Edge (Windows)', + firefoxWindows: 'Firefox (Windows)', + firefoxMacos: 'Firefox (macOS)', + safariMacos: 'Safari (macOS)', + windowsDesktop: 'Windows desktop', + macosDesktop: 'macOS desktop', + }, + locations: { + baseDownloadFolder: 'Base Download Folder', + baseDownloadFolderDescription: 'Automatic category folders are created inside this folder.', + browse: 'Browse', + baseFolderCreateFailed: 'Base folder saved, but category folders could not be created: {{detail}}', + baseFolderUpdated: 'Base download folder updated', + askWhereToSave: 'Ask where to save when adding downloads', + automaticCategorySubfolders: 'Automatically save files to category subfolders', + automaticCategorySubfoldersDescription: 'When off, automatic downloads use the base folder.', + categorySubfolders: 'Category Subfolders', + relativeToBase: 'Relative to the base folder', + disabled: 'Disabled', + categorySubfolder: '{{category}} subfolder', + customFolder: 'Custom folder', + useAutomatic: 'Use automatic', + resetDefaults: 'Reset Defaults', + resetToast: 'Reset category locations to default', + }, + siteLogins: { + title: 'Site Credentials', + noSavedLogins: 'No saved logins.', + user: 'User: {{username}}', + deleteCredential: 'Delete credential', + deletedCredential: 'Deleted credential', + deleteFailed: 'Could not delete credential: {{detail}}', + addTitle: 'Add Site Credentials', + urlPattern: 'URL Pattern:', + urlPatternPlaceholder: 'e.g. *.example.com or example.com/downloads', + username: 'Username:', + password: 'Password:', + usernamePlaceholder: 'Username', + passwordPlaceholder: 'Password', + saving: 'Saving…', + addLogin: 'Add Login', + urlPatternRequired: 'URL pattern is required.', + urlPatternWhitespace: 'URL pattern cannot contain whitespace.', + usernameRequired: 'Username is required.', + passwordRequired: 'Password is required.', + accessBeforeSave: 'Grant credential-store access before saving a site login.', + saveFailed: 'Failed to save site credential securely.', + addedCredential: 'Added site credential', + }, + power: { + title: 'Power Management', + preventSleep: 'Prevent system sleep while downloads are active', + preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.', + }, + engine: { + title: 'Media Downloader & Engines', + description: 'Successful results are reused for this app session. Recheck runs real validation again.', + recheck: 'Recheck engines', + coreDownloader: 'Core Downloader (Aria2)', + version: 'Version:', + status: 'Status:', + mediaExtractors: 'Media Extractors', + browserCookiesSource: 'Browser Cookies Source:', + cookieDescription: 'yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.', + none: 'None', + }, + integrations: { + title: 'Connect Browser Extension', + description: 'Capture downloads directly from your browser in three easy steps.', + portablePairingEnabled: 'Portable Pairing Enabled', + pairingTokenPersisted: 'Pairing Token Persisted', + portablePersistedDescription: 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.', + systemPersistedDescription: 'Your pairing token is stored in the system credential store and will persist across restarts.', + portablePairingAvailable: 'Portable Pairing Available', + credentialStorageNeeded: 'Credential Storage Needed', + portableAvailableDescription: 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.', + credentialStorageDescription: "Firelink needs access to this system's credential store to securely save your pairing token across app restarts. Currently, your extension will only stay connected for this session.", + reviewPortablePairing: 'Review Portable Pairing', + grantCredentialAccess: 'Grant Credential Access', + copyToken: 'Copy Token', + tokenDescription: 'This secure token authorizes your browser extension.', + tokenCopied: 'Token copied to clipboard!', + tokenCopyFailed: 'Could not copy token: {{detail}}', + pairingTokenRegenerated: 'Pairing token regenerated', + regenerateFailed: 'Could not regenerate pairing token: {{detail}}', + getExtension: 'Get Extension', + extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.', + firefoxAddons: 'Firefox Add-ons', + chromiumZip: 'Chromium ZIP', + pasteConnect: 'Paste & Connect', + pasteConnectDescription: "Click the Firelink icon in your browser's toolbar and paste the copied token.", + extensionServerStatus: 'Extension Server Status:', + listening: '● Listening on 127.0.0.1:{{port}}', + unavailable: '● Server unavailable', + }, + about: { + firelinkIcon: 'Firelink Icon', + unknown: 'Unknown', + version: 'Version {{version}}', + description: 'Cross-platform download manager for macOS, Windows, and Linux.', + sourceCode: 'Source Code', + source: 'Source', + upToDate: 'Firelink {{version}} is up to date.', + updateAvailable: 'Firelink {{version}} is available.', + viewRelease: 'View release', + updates: 'Updates', + checkForUpdates: 'Check for Updates', + updateDescription: 'Firelink checks GitHub Releases for new versions.', + checking: 'Checking...', + checkNow: 'Check Now', + automaticUpdates: 'Automatically check for updates', + createdBy: 'Created by NimBold', + mitLicense: 'MIT License', + credits: 'Credits: aria2, yt-dlp, FFmpeg, and Deno.', + copyright: 'Copyright © 2026 NimBold. All rights reserved.', + }, + }, + status: { + ready: 'Ready', + active: '{{count}} active', + queued: '{{count}} queued', + done: '{{count}} done', + }, +} as const; + +export const defaultNS = 'common' as const; + +export const resources = { + en: { + common, + }, +} as const; diff --git a/src/i18next.d.ts b/src/i18next.d.ts new file mode 100644 index 0000000..c42dc6e --- /dev/null +++ b/src/i18next.d.ts @@ -0,0 +1,11 @@ +import 'i18next'; +import type { defaultNS, resources } from './i18n/resources'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: typeof defaultNS; + enableSelector: 'optimize'; + resources: typeof resources['en']; + returnNull: false; + } +} diff --git a/src/main.tsx b/src/main.tsx index 6e1f6c8..33cf060 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; +import { i18nReady } from "./i18n"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { ToastProvider } from "./contexts/ToastContext"; import { error as logError, warn as logWarn, initLogger } from "./utils/logger"; @@ -34,7 +35,9 @@ console.warn = (...values: unknown[]) => { }; const rootElement = document.getElementById("root"); -if (rootElement) { +const renderApp = () => { + if (!rootElement) return; + createRoot(rootElement).render( @@ -44,7 +47,12 @@ if (rootElement) { , ); -} +}; + +void i18nReady.then(renderApp).catch(error => { + console.error('Failed to initialize localization:', error); + renderApp(); +}); // Prevent the webview's default context menu ("Reload", etc.) on right-click. // Individual components that provide custom context menus call preventDefault() diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index eecde77..44d0230 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -14,6 +14,7 @@ import { resolveCategoryDestination } from '../utils/downloadLocations'; import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; +import i18n from '../i18n'; export type { DownloadCategory } from '../utils/downloads'; @@ -896,7 +897,7 @@ export const useDownloadStore = create((set, get) => ({ if (!item) return; if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { - throw new Error("Cannot change properties while transfer is active. Pause it first."); + throw new Error(i18n.t($ => $.downloadTable.transferActive)); } if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { @@ -1011,15 +1012,17 @@ export const useDownloadStore = create((set, get) => ({ await waitForPendingStartupResume(); const targetItem = get().downloads.find(d => d.id === id); if (!targetItem) { - throw new Error('Cannot redownload: download was not found.'); + throw new Error(i18n.t($ => $.downloadTable.redownloadNotFound)); } if (!['completed', 'failed', 'paused'].includes(targetItem.status)) { - throw new Error(`Cannot redownload a ${targetItem.status} download. Pause or wait for it to finish first.`); + throw new Error(i18n.t($ => $.downloadTable.redownloadActive, { + status: i18n.t($ => $.downloads.status[targetItem.status]) + })); } const url = targetItem.url?.trim(); - if (!url) throw new Error('Cannot redownload: original URL is missing.'); + if (!url) throw new Error(i18n.t($ => $.downloadTable.originalUrlMissing)); setDownloadControlIntent(id, 'resume'); await invalidateAndWaitForDispatch(id); @@ -1283,7 +1286,7 @@ export const useDownloadStore = create((set, get) => ({ const selected = get().downloads.filter(item => selectedIds.has(item.id)); const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued'); if (locked) { - throw new Error(`Pause ${locked.fileName} before moving it to another queue.`); + throw new Error(i18n.t($ => $.downloadTable.pauseBeforeMove, { file: locked.fileName })); } const movableSelected = selected.filter(item => item.status !== 'completed'); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 19237bd..158f7e8 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -19,6 +19,7 @@ import { normalizeDownloadLocationSettings } from '../utils/downloadLocations'; import { normalizeSpeedLimitForBackend } from '../utils/downloads'; +import i18n from '../i18n'; let settingsQueue: Promise = Promise.resolve(); let pairingTokenHydrationRequest: Promise | null = null; @@ -425,11 +426,11 @@ export const useSettingsStore = create()( const current = get(); if (!current.keychainAccessReady && !current.isPairingTokenPersistent) { set({ showKeychainModal: true }); - throw new Error('Grant credential-store access before regenerating the pairing token.'); + throw new Error(i18n.t($ => $.keychain.accessRequired)); } const result = await invoke('regenerate_pairing_token'); if (!result.persistent) { - throw new Error(result.error || 'Credential store access is unavailable.'); + throw new Error(result.error || i18n.t($ => $.keychain.storeUnavailable)); } set({ extensionPairingToken: result.token, diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 6b0363c..7b6ff32 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -4,6 +4,7 @@ import { isMediaUrl } from './downloads'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; +import i18n from '../i18n'; export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid'; @@ -361,20 +362,55 @@ export const mediaFileNameForSelectedFormat = ( return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`); }; -export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => { - if (rows.length === 0) return 'Paste one or more links.'; +export type MetadataSummaryState = + | { type: 'empty' } + | { type: 'none-selected' } + | { type: 'invalid'; count: number } + | { type: 'loading'; count: number } + | { type: 'unsafe'; count: number } + | { type: 'media-error'; count: number } + | { type: 'all-error' } + | { type: 'fallback'; ready: number; failed: number } + | { type: 'ready'; count: number }; + +export const metadataSummaryState = (rows: AddDownloadDraftRow[]): MetadataSummaryState => { + if (rows.length === 0) return { type: 'empty' }; const selectedRows = rows.filter(row => row.selected !== false); - if (selectedRows.length === 0) return 'Select at least one download.'; + if (selectedRows.length === 0) return { type: 'none-selected' }; + + const invalid = selectedRows.filter(row => row.status === 'invalid').length; + if (invalid > 0) return { type: 'invalid', count: invalid }; + + const loading = selectedRows.filter(row => row.status === 'loading').length; + if (loading > 0) return { type: 'loading', count: loading }; + + const failed = selectedRows.filter(row => row.status === 'metadata-error').length; + const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length; + const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length; + const ready = selectedRows.filter(row => row.status === 'ready').length; + if (blocked > 0) return { type: 'unsafe', count: blocked }; + if (failedMedia > 0) return { type: 'media-error', count: failedMedia }; + if (failed === selectedRows.length) return { type: 'all-error' }; + if (failed > 0) return { type: 'fallback', ready, failed }; + return { type: 'ready', count: ready }; +}; + +export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => { + if (rows.length === 0) return i18n.t($ => $.addDownloads.pasteOneOrMore); + + const selectedRows = rows.filter(row => row.selected !== false); + if (selectedRows.length === 0) return i18n.t($ => $.addDownloads.selectAtLeastOne); + const plural = (count: number) => count === 1 ? '' : 's'; const invalid = selectedRows.filter(row => row.status === 'invalid').length; if (invalid > 0) { - return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`; + return i18n.t($ => $.addDownloads.correctInvalid, { count: invalid, plural: plural(invalid) }); } const loading = selectedRows.filter(row => row.status === 'loading').length; if (loading > 0) { - return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`; + return i18n.t($ => $.addDownloads.waitingForMetadata, { count: loading, plural: plural(loading) }); } const failed = selectedRows.filter(row => row.status === 'metadata-error').length; @@ -382,16 +418,16 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => { const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length; const ready = selectedRows.filter(row => row.status === 'ready').length; if (blocked > 0) { - return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`; + return i18n.t($ => $.addDownloads.removeUnsafe, { count: blocked, plural: plural(blocked) }); } if (failedMedia > 0) { - return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`; + return i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummary, { count: failedMedia, plural: plural(failedMedia) }); } if (failed === selectedRows.length) { - return 'Metadata is unavailable. Downloads can still be added using fallback details.'; + return i18n.t($ => $.addDownloads.metadataUnavailableFallback); } if (failed > 0) { - return `${ready} download${ready === 1 ? '' : 's'} ready; ${failed} will use fallback filename and unknown size.`; + return i18n.t($ => $.addDownloads.fallbackReady, { ready, readyPlural: plural(ready), failed }); } - return `Ready to add ${ready} download${ready === 1 ? '' : 's'}.`; + return i18n.t($ => $.addDownloads.readyToAdd, { count: ready, plural: plural(ready) }); };