diff --git a/web/js/components/preact/settings/Go2rtcTab.jsx b/web/js/components/preact/settings/Go2rtcTab.jsx
index 479403b6..ad0df31e 100644
--- a/web/js/components/preact/settings/Go2rtcTab.jsx
+++ b/web/js/components/preact/settings/Go2rtcTab.jsx
@@ -461,7 +461,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
{settings.go2rtcOverrideDisabledReason ? (
- Override quarantined
+ {t('settings.go2rtcOverrideQuarantined')}
{settings.go2rtcOverrideDisabledReason}
@@ -478,7 +478,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
setOverrideValidation(null);
}}
>
- Restore quarantined version into the editor
+ {t('settings.go2rtcRestoreQuarantined')}
) : null}
@@ -502,10 +502,10 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
{formatBytes(utf8ByteLength(settings.go2rtcConfigOverride))} / {formatBytes(GO2RTC_OVERRIDE_MAX_BYTES)}
- {overrideValidating ? ' • validating…' : null}
+ {overrideValidating ? t('settings.go2rtcValidating') : null}
{overrideValidation && overrideValidation.libyaml_available === false ? (
- libyaml unavailable — server-side validation skipped
+ {t('settings.go2rtcValidationSkipped')}
) : null}
@@ -513,7 +513,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
{overrideValidation && overrideValidation.valid === false && overrideValidation.error ? (
- Line {overrideValidation.error.line || '?'}, col {overrideValidation.error.column || '?'}
+ {t('settings.go2rtcErrorPosition', { line: overrideValidation.error.line || '?', column: overrideValidation.error.column || '?' })}
{overrideValidation.error.message}
@@ -536,7 +536,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
disabled={!canModifySettings}
onClick={loadEffectiveConfig}
>
- Show effective config
+ {t('settings.go2rtcShowEffectiveConfig')}
- Load example…
+ {t('settings.go2rtcLoadExample')}
{OVERRIDE_PRESETS.map((p, i) => (
{p.label}
))}
@@ -553,7 +553,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
{/* Supported-sections collapsible */}
- Supported go2rtc sections
+ {t('settings.go2rtcSupportedSections')}
{KNOWN_GO2RTC_SECTIONS.map(([k, desc]) => (
{k} — {desc}
@@ -568,13 +568,13 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
setEffectiveConfigOpen(false)}>
e.stopPropagation()}>
-
go2rtc effective config (redacted)
- setEffectiveConfigOpen(false)}>Close
+ {t('settings.go2rtcEffectiveConfig')}
+ setEffectiveConfigOpen(false)}>{t('common.close')}
{effectiveConfigLoading ? (
-
Loading…
+
{t('settings.go2rtcLoading')}
) : effectiveConfig?.error ? (
-
Failed to load: {effectiveConfig.error}
+
{t('settings.go2rtcLoadFailed', { message: effectiveConfig.error })}
) : effectiveConfig ? (
{(() => {
@@ -595,7 +595,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
})()}
{effectiveConfig.redaction_available === false ? (
- libyaml unavailable on the server — secret redaction is SKIPPED. Treat this preview as if it contained your secrets in cleartext.
+ {t('settings.go2rtcRedactionUnavailable')}
) : null}
{Array.isArray(effectiveConfig.warnings) && effectiveConfig.warnings.length > 0 ? (
@@ -605,12 +605,12 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
) : null}
-
go2rtc.yaml (base)
-
{effectiveConfig.base || '(empty)'}
+
{t('settings.go2rtcBaseConfig')}
+
{effectiveConfig.base || t('settings.go2rtcEmpty')}
override.yaml
-
{effectiveConfig.override || '(none)'}
+
{effectiveConfig.override || t('settings.go2rtcNone')}
diff --git a/web/js/components/preact/settings/StorageTab.jsx b/web/js/components/preact/settings/StorageTab.jsx
index 9d9a1702..fbb0a6cb 100644
--- a/web/js/components/preact/settings/StorageTab.jsx
+++ b/web/js/components/preact/settings/StorageTab.jsx
@@ -311,7 +311,7 @@ function StorageTargetsPanel({ canModifySettings, t }) {
{target.name}
{target.is_default && {t('settings.storageTargets.default')} }
- {health.status || 'unknown'}
+ {health.status || t('common.unknown')}
{!objectStorage && {t(`settings.storageTargets.pressure.${health.pressure || 'unavailable'}`)} }
{target.storage_class}
diff --git a/web/js/components/preact/settings/StreamsDefaultsTab.jsx b/web/js/components/preact/settings/StreamsDefaultsTab.jsx
index ad3000f9..4ff1a6de 100644
--- a/web/js/components/preact/settings/StreamsDefaultsTab.jsx
+++ b/web/js/components/preact/settings/StreamsDefaultsTab.jsx
@@ -37,9 +37,9 @@ export function StreamsDefaultsTab({ settings, handleInputChange, canModifySetti
-
{t('settings.streamsDefaults') || 'Streams Defaults'}
+
{t('settings.streamsDefaults')}
- {t('settings.streamsDefaultsDescription') || 'Defaults applied to new streams. Per-stream overrides always win.'}
+ {t('settings.streamsDefaultsDescription')}
{t('settings.defaultPreDetectionBufferSeconds')}
diff --git a/web/js/components/preact/system/NetworkInfo.jsx b/web/js/components/preact/system/NetworkInfo.jsx
index a84b6d81..1138237a 100644
--- a/web/js/components/preact/system/NetworkInfo.jsx
+++ b/web/js/components/preact/system/NetworkInfo.jsx
@@ -26,7 +26,7 @@ export function NetworkInfo({ systemInfo }) {
{iface.address || t('system.noIp')}
- MAC: {iface.mac && iface.mac !== 'Unavailable' ? iface.mac : t('common.unknown')} | {iface.up === true ? t('system.up') : iface.up === false ? t('system.down') : t('common.unknown')}
+ {t('system.macLabel')} {iface.mac && iface.mac !== 'Unavailable' ? iface.mac : t('common.unknown')} | {iface.up === true ? t('system.up') : iface.up === false ? t('system.down') : t('common.unknown')}
{iface.capability && iface.capability !== 'available' ? ` · ${iface.capability}` : ''}
diff --git a/web/js/components/preact/system/StreamStorage.jsx b/web/js/components/preact/system/StreamStorage.jsx
index 622b7deb..b0f31dff 100644
--- a/web/js/components/preact/system/StreamStorage.jsx
+++ b/web/js/components/preact/system/StreamStorage.jsx
@@ -18,7 +18,7 @@ export function StreamStorage({ systemInfo, formatBytes }) {
if (!systemInfo.streamStorage || !Array.isArray(systemInfo.streamStorage) || systemInfo.streamStorage.length === 0) {
return (
-
Stream Storage
+
{t('system.streamStorage')}
{t('system.noStreamStorageInformationAvailable')}
diff --git a/web/js/components/preact/timeline/CalendarPicker.jsx b/web/js/components/preact/timeline/CalendarPicker.jsx
index 46301337..47d94dc2 100644
--- a/web/js/components/preact/timeline/CalendarPicker.jsx
+++ b/web/js/components/preact/timeline/CalendarPicker.jsx
@@ -12,12 +12,6 @@ import { useI18n } from '../../../i18n.js';
/* ── helpers ────────────────────────────────────────────────── */
-const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
-const MONTHS = [
- 'January','February','March','April','May','June',
- 'July','August','September','October','November','December'
-];
-
/** Parse YYYY-MM-DD into local Date at midnight */
function parseLocal(str) {
return dayjs(str);
@@ -41,7 +35,7 @@ function buildGrid(year, month) {
/* ── component ──────────────────────────────────────────────── */
export function CalendarPicker({ value, onChange }) {
- const { t } = useI18n();
+ const { t, locale } = useI18n();
const today = dayjs().format('YYYY-MM-DD');
const selected = value || today;
@@ -80,9 +74,9 @@ export function CalendarPicker({ value, onChange }) {
else setViewMonth(m => m + 1);
};
const goToday = () => {
- const t = dayjs();
- setViewYear(t.year());
- setViewMonth(t.month());
+ const currentDate = dayjs();
+ setViewYear(currentDate.year());
+ setViewMonth(currentDate.month());
pick(today);
};
@@ -98,7 +92,12 @@ export function CalendarPicker({ value, onChange }) {
/* ── display helpers ── */
const displayDate = parseLocal(selected);
- const displayStr = `${MONTHS[displayDate.month()]} ${displayDate.date()}, ${displayDate.year()}`;
+ const dateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: 'long' });
+ const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' });
+ const weekdayFormatter = new Intl.DateTimeFormat(locale, { weekday: 'short' });
+ const displayStr = dateFormatter.format(displayDate.toDate());
+ const weekdays = Array.from({ length: 7 }, (_, day) =>
+ weekdayFormatter.format(new Date(2023, 0, 1 + day)));
return (
@@ -161,7 +160,7 @@ export function CalendarPicker({ value, onChange }) {
- {MONTHS[viewMonth]} {viewYear}
+ {monthFormatter.format(new Date(viewYear, viewMonth, 1))}
- {DAYS.map(d => {d} )}
+ {weekdays.map((day, index) => {day} )}
{/* Day cells */}
@@ -214,7 +213,7 @@ export function CalendarPicker({ value, onChange }) {
onClick={goToday}
className="text-xs px-3 py-1 rounded bg-secondary text-secondary-foreground hover:bg-accent transition-colors"
>
- Today
+ {t('recordings.today')}
diff --git a/web/js/components/preact/timeline/TimelinePage.jsx b/web/js/components/preact/timeline/TimelinePage.jsx
index ad9402bf..4d243ef1 100644
--- a/web/js/components/preact/timeline/TimelinePage.jsx
+++ b/web/js/components/preact/timeline/TimelinePage.jsx
@@ -1072,7 +1072,7 @@ export function TimelinePage() {
useEffect(() => {
if (streamsError) {
console.error('TimelinePage: Error loading streams:', streamsError);
- showStatusMessage('Error loading streams: ' + streamsError.message, 'error');
+ showStatusMessage(t('recordings.errorLoadingStreams', { message: streamsError.message }), 'error');
}
}, [streamsError]);
@@ -1107,16 +1107,16 @@ export function TimelinePage() {
const uniqueStreams = [...new Set(segs.map(s => s.stream))];
setSelectedStream(uniqueStreams.join(', '));
- showStatusMessage(`Loaded ${segs.length} selected recording segments across ${availableDates.length} day${availableDates.length !== 1 ? 's' : ''}`, 'success');
+ showStatusMessage(t(availableDates.length === 1 ? 'timeline.loadedSelectedSegmentsOneDay' : 'timeline.loadedSelectedSegmentsManyDays', { count: segs.length, days: availableDates.length }), 'success');
} else {
setIdsTimelineSegments([]);
loadSegmentsIntoTimeline([], selectedDate);
- showStatusMessage('No recordings found for the selected IDs', 'info');
+ showStatusMessage(t('timeline.noRecordingsForSelectedIds'), 'info');
}
} catch (err) {
console.error('Error fetching segments by IDs:', err);
setIdsTimelineSegments([]);
- showStatusMessage('Error loading selected recordings: ' + err.message, 'error');
+ showStatusMessage(t('timeline.errorLoadingSelectedRecordings', { message: err.message }), 'error');
} finally {
setIdsLoading(false);
}
@@ -1196,7 +1196,7 @@ export function TimelinePage() {
useEffect(() => {
if (timelineError) {
console.error('TimelinePage: Error loading timeline data:', timelineError.message);
- showStatusMessage('Error loading timeline data: ' + timelineError.message, 'error');
+ showStatusMessage(t('timeline.errorLoadingData', { message: timelineError.message }), 'error');
setSegments([]);
setDetectionIntervals([]);
return;
@@ -1210,7 +1210,7 @@ export function TimelinePage() {
if (timelineSegments.length === 0) {
loadSegmentsIntoTimeline([], selectedDate);
- showStatusMessage('No recordings found for the selected date', 'warning');
+ showStatusMessage(t('timeline.noRecordingsForSelectedDate'), 'warning');
return;
}
@@ -1389,7 +1389,7 @@ export function TimelinePage() {
- {idsMode ? 'No selected recordings are visible for this day' : 'No recordings found for the selected date and stream'}
+ {t(idsMode ? 'timeline.noSelectedRecordingsVisible' : 'timeline.noRecordingsForDateAndStream')}
);
diff --git a/web/js/components/preact/timeline/TimelinePlayer.jsx b/web/js/components/preact/timeline/TimelinePlayer.jsx
index 60239603..4ec11c72 100644
--- a/web/js/components/preact/timeline/TimelinePlayer.jsx
+++ b/web/js/components/preact/timeline/TimelinePlayer.jsx
@@ -907,7 +907,7 @@ export function TimelinePlayer({
}, 0);
}
- showStatusMessage(`Snapshot saved: ${fileName}`, 'success', 2000);
+ showStatusMessage(t('recordings.snapshotSaved', { fileName }), 'success', 2000);
}, 'image/jpeg', 0.95);
}, [segmentRecordingData, t]);
diff --git a/web/js/components/preact/timeline/TimelineSegments.jsx b/web/js/components/preact/timeline/TimelineSegments.jsx
index 0d2c7e56..ee1534a6 100644
--- a/web/js/components/preact/timeline/TimelineSegments.jsx
+++ b/web/js/components/preact/timeline/TimelineSegments.jsx
@@ -220,7 +220,7 @@ export function TimelineSegments({ segments: propSegments, detectionIntervals: p
if (!segments || segments.length === 0) {
return (
- No segments to display
+ {t('timeline.noSegmentsToDisplay')}
);
}
@@ -328,7 +328,7 @@ export function TimelineSegments({ segments: propSegments, detectionIntervals: p
{renderSegments()}
{dragPreview && (
diff --git a/web/js/utils/batch-delete-status.js b/web/js/utils/batch-delete-status.js
index 235cef60..ff509b7a 100644
--- a/web/js/utils/batch-delete-status.js
+++ b/web/js/utils/batch-delete-status.js
@@ -1,13 +1,17 @@
/** Keep accepted-but-pending deletions distinct from completed cleanup and failures. */
+import { t } from '../i18n.js';
+
export function batchDeleteStatus(result) {
const succeeded = result.succeeded || 0;
const failed = result.failed || 0;
const pending = result.pending_deletions || 0;
if (pending > 0) {
- return `Accepted ${succeeded} deletions; ${pending} await storage cleanup${failed > 0 ? `; failed to delete ${failed}` : ''}`;
+ return t('recordings.batchDeletePending', { succeeded, pending })
+ + (failed > 0 ? t('recordings.batchDeletePendingFailures', { count: failed }) : '');
}
if (succeeded > 0) {
- return `Successfully deleted ${succeeded} recording${succeeded !== 1 ? 's' : ''}${failed > 0 ? `, but failed to delete ${failed}` : ''}`;
+ return t(succeeded === 1 ? 'recordings.batchDeleteSucceededOne' : 'recordings.batchDeleteSucceededMany', { count: succeeded })
+ + (failed > 0 ? t('recordings.batchDeleteSucceededFailures', { count: failed }) : '');
}
- return `Failed to delete ${failed} recording${failed !== 1 ? 's' : ''}`;
+ return t(failed === 1 ? 'recordings.batchDeleteFailedOne' : 'recordings.batchDeleteFailedMany', { count: failed });
}
diff --git a/web/public/locales/en.json b/web/public/locales/en.json
index 8c36af1f..a246eded 100644
--- a/web/public/locales/en.json
+++ b/web/public/locales/en.json
@@ -1079,6 +1079,7 @@
"recordings.tags": "Tags",
"recordings.downloadSelectedTitle": "Download selected recordings",
"recordings.manageTagsButton": "Manage Tags",
+ "recordings.manageTagsInline": "Manage tags",
"recordings.manageTagsTitle": "Manage tags for selected recordings",
"recordings.columnCaptureMethod": "Capture Method",
"recordings.columnStartTime": "Start Time",
@@ -2647,5 +2648,151 @@
"live.eptzCanvasHint": "Drag to pan and tilt; wheel or pinch to zoom; Home resets",
"live.eptzReset": "Reset ePTZ view",
"live.autoShort": "Auto",
- "live.webrtcShort": "WebRTC"
+ "live.webrtcShort": "WebRTC",
+ "recordings.confirmDelete": "Confirm Delete",
+ "recordings.confirmDeleteItem": "Are you sure you want to delete this item?",
+ "recordings.confirmDeleteFromStream": "Are you sure you want to delete this recording from {stream}? This action cannot be undone.",
+ "recordings.thisStream": "this stream",
+ "recordings.deleteSelectedRecordings": "Delete Selected Recordings",
+ "recordings.confirmDeleteSelectedOne": "Are you sure you want to delete {count} selected recording?",
+ "recordings.confirmDeleteSelectedMany": "Are you sure you want to delete {count} selected recordings?",
+ "recordings.deleteAllFilteredRecordings": "Delete All Filtered Recordings",
+ "recordings.confirmDeleteAllFiltered": "Are you sure you want to delete all recordings matching the current filters? This action cannot be undone.",
+ "recordings.snapshotOfStream": "Snapshot: {stream}",
+ "recordings.noDetectionsLoaded": "No detections loaded",
+ "recordings.invalidRecordingTimestamps": "Error: Invalid recording timestamps",
+ "recordings.detectionAvailableOne": "{count} detection available",
+ "recordings.detectionAvailableMany": "{count} detections available",
+ "recordings.noDetectionsForRecording": "No detections found for this recording",
+ "recordings.errorLoadingDetections": "Error loading detections",
+ "recordings.snapshotSaved": "Snapshot saved: {fileName}",
+ "recordings.showingDetectionOne": "Showing {count} detection at current time",
+ "recordings.showingDetectionMany": "Showing {count} detections at current time",
+ "recordings.noDetectionsAtCurrentTime": "No detections at current time ({count} total)",
+ "recordings.preparingCompatiblePlayback": "Preparing a compatible version for this browser…",
+ "recordings.video": "Video",
+ "recordings.playbackControls": "PLAYBACK CONTROLS",
+ "recordings.playbackSpeed": "Playback Speed",
+ "recordings.normalSpeed": "1× (Normal)",
+ "recordings.currentSpeed": "Current Speed: {speed}",
+ "recordings.normalSpeedSuffix": "(Normal)",
+ "recordings.detectionOverlays": "Detection Overlays",
+ "recordings.showDetectionOverlays": "Show Detection Overlays",
+ "recordings.detectionSensitivity": "Detection Sensitivity",
+ "recordings.timeWindowOne": "Time Window: {count} second",
+ "recordings.timeWindowMany": "Time Window: {count} seconds",
+ "recordings.protectAction": "Protect",
+ "recordings.snapshotDownloadMissingData": "Download failed: No image data available",
+ "recordings.snapshotDownloaded": "Snapshot downloaded successfully",
+ "recordings.downloadFailed": "Download failed: {message}",
+ "recordings.errorShowingVideo": "Error showing video modal: {message}",
+ "recordings.errorShowingSnapshot": "Error showing snapshot preview: {message}",
+ "recordings.selectedRecordingOne": "{count} selected recording",
+ "recordings.selectedRecordingMany": "{count} selected recordings",
+ "recordings.filteredRecordingOne": "{count} filtered recording",
+ "recordings.filteredRecordingMany": "{count} filtered recordings",
+ "recordings.allMatchingRecordings": "all matching recordings",
+ "recordings.batchDeleteCancelled": "Batch delete cancelled",
+ "recordings.confirmBatchDelete": "Confirm Batch Delete",
+ "recordings.actionCannotBeUndone": "This action cannot be undone",
+ "recordings.aboutToPermanentlyDelete": "You are about to permanently delete {description}.",
+ "recordings.batchDeleteIrreversible": "Once started, this operation cannot be cancelled or reversed.",
+ "recordings.deleteDescription": "Delete {description}",
+ "recordings.batchDeleteComplete": "Batch Delete Complete",
+ "recordings.deletingRecordings": "Deleting Recordings…",
+ "recordings.succeededLabel": "Succeeded:",
+ "recordings.failedLabel": "Failed:",
+ "recordings.batchDeleteCannotCancel": "This operation cannot be cancelled once started.",
+ "recordings.pleaseWait": "Please wait…",
+ "recordings.startingDownloadOne": "Starting {count} download…",
+ "recordings.startingDownloadMany": "Starting {count} downloads…",
+ "recordings.zipDownloadStarted": "ZIP download started",
+ "recordings.downloadSelectedRecordings": "Download Selected Recordings",
+ "recordings.recordingSelectedOne": "{count} recording selected",
+ "recordings.recordingSelectedMany": "{count} recordings selected",
+ "recordings.zipArchiveRecommended": "ZIP archive (recommended)",
+ "recordings.zipArchiveHelp": "All files packaged into a single .zip",
+ "recordings.sequentialDownloads": "Sequential downloads",
+ "recordings.sequentialDownloadsHelp": "Each file downloaded separately (browser may block multiple tabs)",
+ "recordings.zipFilename": "ZIP filename",
+ "recordings.buildingZip": "Building ZIP…",
+ "recordings.downloading": "Downloading…",
+ "recordings.working": "Working…",
+ "recordings.tapToRetry": "Tap to retry",
+ "recordings.noTags": "No tags",
+ "recordings.noTagsYet": "No tags yet",
+ "recordings.recordingTagsCount": "Recording Tags ({count} recordings)",
+ "timeline.noSelectedRecordingsVisible": "No selected recordings are visible for this day",
+ "timeline.noRecordingsForDateAndStream": "No recordings found for the selected date and stream",
+ "timeline.noSegmentsToDisplay": "No segments to display",
+ "timeline.recordingTimeline": "Recording timeline",
+ "system.macLabel": "MAC:",
+ "settings.go2rtcOverrideQuarantined": "Override quarantined",
+ "settings.go2rtcRestoreQuarantined": "Restore quarantined version into the editor",
+ "settings.go2rtcValidating": " • validating…",
+ "settings.go2rtcValidationSkipped": "libyaml unavailable — server-side validation skipped",
+ "settings.go2rtcErrorPosition": "Line {line}, col {column}",
+ "settings.go2rtcShowEffectiveConfig": "Show effective config",
+ "settings.go2rtcLoadExample": "Load example…",
+ "settings.go2rtcSupportedSections": "Supported go2rtc sections",
+ "settings.go2rtcEffectiveConfig": "go2rtc effective config (redacted)",
+ "settings.go2rtcLoading": "Loading…",
+ "settings.go2rtcLoadFailed": "Failed to load: {message}",
+ "settings.go2rtcRedactionUnavailable": "libyaml unavailable on the server — secret redaction is SKIPPED. Treat this preview as if it contained your secrets in cleartext.",
+ "settings.go2rtcBaseConfig": "go2rtc.yaml (base)",
+ "settings.go2rtcEmpty": "(empty)",
+ "settings.go2rtcNone": "(none)",
+ "streamsConfig.detectionScheduleHelpShort": "— restrict detection-triggered clips independently",
+ "streamsConfig.detectionScheduleHelp": "External events are still annotated outside these hours, but they will not start a detection clip.",
+ "streamsConfig.inheritGlobalSetting": "-1 = inherit the global setting",
+ "streamsConfig.zonePoints": "({count} points)",
+ "live.navigator.planCameraCounts": "{live}/{total} live · {total} placed",
+ "live.eptzUnavailable": "ePTZ unavailable",
+ "live.pushToTalkShort": "PTT",
+ "live.toggleTalkShort": "TOG",
+ "fleet.location": "Location",
+ "fleet.tags": "Tags",
+ "streamsConfig.zonesLoadFailed": "Failed to load detection zones (HTTP {statusCode}{statusText}). Please try again or contact an administrator.",
+ "streamsConfig.zonesLoadError": "An error occurred while loading detection zones. Details: {errorMessage}",
+ "recordings.inProgress": "In progress",
+ "recordings.noSelectionForDeletion": "No recordings selected for deletion",
+ "recordings.batchDeleteStartFailed": "Failed to start batch delete operation",
+ "recordings.batchDeleteUnavailable": "Batch delete function not available",
+ "recordings.noFilterForDeletion": "No filter specified for deletion",
+ "recordings.serverError": "Server error {status}",
+ "recordings.statusPollFailed": "Status poll failed",
+ "recordings.zipCreationFailed": "ZIP creation failed on server",
+ "recordings.unknownError": "Unknown error",
+ "recordings.protectFailed": "Failed to protect recording",
+ "recordings.unprotectFailed": "Failed to unprotect recording",
+ "live.snapshotVideoUnavailable": "Cannot take snapshot: Video not available",
+ "live.snapshotInvalidDimensions": "Cannot take snapshot: Video not loaded or has invalid dimensions",
+ "live.manualRecordingStarted": "Manual recording started",
+ "live.manualRecordingStopped": "Manual recording stopped",
+ "live.manualRecordingFailed": "Manual recording failed: {message}",
+ "timeline.loadedSelectedSegmentsOneDay": "Loaded {count} selected recording segments across {days} day",
+ "timeline.loadedSelectedSegmentsManyDays": "Loaded {count} selected recording segments across {days} days",
+ "timeline.noRecordingsForSelectedIds": "No recordings found for the selected IDs",
+ "timeline.errorLoadingSelectedRecordings": "Error loading selected recordings: {message}",
+ "timeline.errorLoadingData": "Error loading timeline data: {message}",
+ "timeline.noRecordingsForSelectedDate": "No recordings found for the selected date",
+ "recordings.tagAdded": "Tag \"{tag}\" added",
+ "recordings.tagRemoved": "Tag \"{tag}\" removed",
+ "recordings.tagRemovedFromRecordings": "Tag \"{tag}\" removed from {count} recordings",
+ "recordings.tagAddedToRecordings": "Tag \"{tag}\" added to {count} recordings",
+ "recordings.errorDeletingRecording": "Error deleting recording: {message}",
+ "recordings.errorBatchDelete": "Error in batch delete operation: {message}",
+ "recordings.errorDeleteAll": "Error in delete all operation: {message}",
+ "recordings.recordingMissingId": "Error: Recording has no id property",
+ "recordings.errorSettingTags": "Error setting tags: {message}",
+ "recordings.errorUpdatingTags": "Error updating tags: {message}",
+ "recordings.preparingArchivedRecording": "Preparing archived recording…",
+ "recordings.downloadStarted": "Download started",
+ "recordings.batchDeletePending": "Accepted {succeeded} deletions; {pending} await storage cleanup",
+ "recordings.batchDeletePendingFailures": "; failed to delete {count}",
+ "recordings.batchDeleteSucceededOne": "Successfully deleted {count} recording",
+ "recordings.batchDeleteSucceededMany": "Successfully deleted {count} recordings",
+ "recordings.batchDeleteSucceededFailures": ", but failed to delete {count}",
+ "recordings.batchDeleteFailedOne": "Failed to delete {count} recording",
+ "recordings.batchDeleteFailedMany": "Failed to delete {count} recordings"
}
diff --git a/web/tests/locale-coverage.spec.js b/web/tests/locale-coverage.spec.js
index 3ce1f926..fc1b16df 100644
--- a/web/tests/locale-coverage.spec.js
+++ b/web/tests/locale-coverage.spec.js
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
+import { parse } from '@babel/parser';
const localeDir = path.resolve(__dirname, '../public/locales');
const readCatalog = (code) => JSON.parse(fs.readFileSync(path.join(localeDir, `${code}.json`), 'utf8'));
@@ -21,6 +22,36 @@ const shortcutKeys = new Set([
'timeline.inlineHint',
]);
+test('static translation keys used by the web UI exist in English', () => {
+ const sourceRoot = path.resolve(__dirname, '../js');
+ const sourceFiles = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const file = path.join(directory, entry.name);
+ return entry.isDirectory() ? sourceFiles(file) : /\.jsx?$/.test(entry.name) ? [file] : [];
+ });
+ const missing = [];
+
+ for (const file of sourceFiles(sourceRoot)) {
+ const source = fs.readFileSync(file, 'utf8');
+ const ast = parse(source, { sourceType: 'module', plugins: ['jsx'] });
+ const visit = (node) => {
+ if (!node || typeof node !== 'object') return;
+ if (Array.isArray(node)) return node.forEach(visit);
+ if (node.type === 'CallExpression' && ['t', 'translate'].includes(node.callee?.name)) {
+ const key = node.arguments?.[0];
+ if (key?.type === 'StringLiteral' && !englishKeySet.has(key.value)) {
+ missing.push(`${path.relative(sourceRoot, file)}: ${key.value}`);
+ }
+ }
+ for (const [property, child] of Object.entries(node)) {
+ if (!['loc', 'start', 'end'].includes(property)) visit(child);
+ }
+ };
+ visit(ast);
+ }
+
+ expect(missing).toEqual([]);
+});
+
describe('locale catalog coverage', () => {
for (const { code } of manifest.locales) {
if (code === 'en') continue;
diff --git a/web/tests/recordingsAPI.spec.js b/web/tests/recordingsAPI.spec.js
index 274166ee..debaa409 100644
--- a/web/tests/recordingsAPI.spec.js
+++ b/web/tests/recordingsAPI.spec.js
@@ -4,6 +4,13 @@ jest.mock('../js/components/preact/ToastContainer.jsx', () => ({
showStatusMessage: jest.fn()
}));
+jest.mock('../js/i18n.js', () => {
+ const english = require('../public/locales/en.json');
+ return {
+ t: (key, params = {}) => (english[key] || key).replace(/\{([^}]+)\}/g, (_, name) => String(params[name] ?? '')),
+ };
+});
+
jest.mock('../js/fetch-utils.js', () => ({
fetchJSON: jest.fn(),
enhancedFetch: jest.fn()