diff --git a/src/index.html b/src/index.html index 74f4854..6b5dd36 100644 --- a/src/index.html +++ b/src/index.html @@ -229,6 +229,10 @@ Statistik + + +
+
+

Archiv durchsuchen

+

Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.

+
+ + + + + +
+
+
+
+
+
+
+
@@ -796,6 +831,7 @@ + diff --git a/src/main.ts b/src/main.ts index 61f82b2..8c9e7a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3728,6 +3728,177 @@ function walkForArchiveStats( } } +// Search a single file matches the live query. Empty query matches all. +// streamerFolder is the top-level directory under root (which we equate +// with the channel name); relativePath is everything below that. +interface ArchiveSearchFilter { + query: string; + type: 'all' | 'live' | 'vod' | 'chat' | 'events'; + streamer: string; + sinceMs: number | null; + untilMs: number | null; + sort: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'; + limit: number; +} + +interface ArchiveSearchHit { + fullPath: string; + fileName: string; + streamer: string; + type: ArchiveFileType; + size: number; + mtimeMs: number; + chatPath: string | null; + eventsPath: string | null; +} + +interface ArchiveSearchResult { + totalScanned: number; + matchCount: number; + truncated: boolean; + hits: ArchiveSearchHit[]; + scannedAt: string; + rootExists: boolean; +} + +function matchSearchFilter( + streamerFolder: string, + relativePath: string, + fileName: string, + fileSize: number, + mtimeMs: number, + type: ArchiveFileType, + filter: ArchiveSearchFilter +): boolean { + if (filter.type !== 'all' && filter.type !== type) return false; + if (filter.streamer && streamerFolder.toLowerCase() !== filter.streamer.toLowerCase()) return false; + if (filter.sinceMs !== null && mtimeMs < filter.sinceMs) return false; + if (filter.untilMs !== null && mtimeMs > filter.untilMs) return false; + if (filter.query) { + const q = filter.query.toLowerCase(); + const hay = `${fileName} ${streamerFolder} ${relativePath}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; +} + +function searchArchive(filter: ArchiveSearchFilter): ArchiveSearchResult { + const root = config.download_path; + const result: ArchiveSearchResult = { + totalScanned: 0, + matchCount: 0, + truncated: false, + hits: [], + scannedAt: new Date().toISOString(), + rootExists: false + }; + if (!root || !fs.existsSync(root)) return result; + result.rootExists = true; + + const maxHits = Math.max(10, Math.min(2000, Math.floor(filter.limit) || 200)); + + let topEntries: fs.Dirent[]; + try { + topEntries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return result; + } + + // To attach chat/events sibling paths to a recording hit, we collect + // every file in a streamer's tree first, then make a second pass to + // pair up companions by stripping the .mp4 base. + for (const entry of topEntries) { + if (!entry.isDirectory()) continue; + const streamerFolder = entry.name; + const streamerRoot = path.join(root, streamerFolder); + const filesInTree: Array<{ fullPath: string; rel: string; name: string; size: number; mtimeMs: number; type: ArchiveFileType }> = []; + const accum: { files: ArchiveFileRecord[] } = { files: [] }; + // We re-walk here instead of reusing walkForArchiveStats because + // we need the full path + rel path on each file, not just the + // type/size aggregates. The cost is one redundant tree walk per + // search; acceptable for an interactive search. + const walkWithPaths = (folderPath: string, relPrefix: string): void => { + let entries2: fs.Dirent[]; + try { + entries2 = fs.readdirSync(folderPath, { withFileTypes: true }); + } catch { return; } + for (const e2 of entries2) { + const full = path.join(folderPath, e2.name); + const rel = relPrefix ? `${relPrefix}/${e2.name}` : e2.name; + try { + if (e2.isDirectory()) { + walkWithPaths(full, rel); + } else if (e2.isFile()) { + const st = fs.statSync(full); + const type = classifyArchiveFile(rel); + filesInTree.push({ fullPath: full, rel, name: e2.name, size: st.size, mtimeMs: st.mtimeMs, type }); + } + } catch { /* skip */ } + } + }; + walkWithPaths(streamerRoot, ''); + + if (filesInTree.length === 0) continue; + result.totalScanned += filesInTree.length; + + // Build a quick lookup so a recording file can attach its sibling + // .chat.* and .events.jsonl by stripping the .mp4/.mkv extension. + const companionByBase = new Map(); + for (const f of filesInTree) { + if (f.type !== 'chat' && f.type !== 'events') continue; + // Strip companion suffix to get the base name shared with the + // recording: foo.mp4 + foo.chat.jsonl + foo.events.jsonl. + const base = f.fullPath.replace(/\.chat\.jsonl?$/i, '').replace(/\.events\.jsonl$/i, ''); + const existing = companionByBase.get(base) || { chat: null, events: null }; + if (f.type === 'chat') existing.chat = f.fullPath; + else if (f.type === 'events') existing.events = f.fullPath; + companionByBase.set(base, existing); + } + + for (const f of filesInTree) { + // We only surface recordings (live/vod) as search hits — chat + // and events files attach as companions and don't appear as + // standalone rows. Users searching for chat usually want the + // recording it belongs to anyway. + if (f.type !== 'live' && f.type !== 'vod') continue; + if (!matchSearchFilter(streamerFolder, f.rel, f.name, f.size, f.mtimeMs, f.type, filter)) continue; + + const recordingBase = f.fullPath.replace(/\.(mp4|mkv|ts|m4v)$/i, ''); + const companions = companionByBase.get(recordingBase) || { chat: null, events: null }; + + result.hits.push({ + fullPath: f.fullPath, + fileName: f.name, + streamer: streamerFolder, + type: f.type, + size: f.size, + mtimeMs: f.mtimeMs, + chatPath: companions.chat, + eventsPath: companions.events + }); + result.matchCount++; + } + } + + // Sort then truncate. We sort the FULL match set (not the truncated + // one) so the user gets the genuinely largest/newest results, not + // arbitrary order. + const cmp: Record number> = { + date_desc: (a, b) => b.mtimeMs - a.mtimeMs, + date_asc: (a, b) => a.mtimeMs - b.mtimeMs, + size_desc: (a, b) => b.size - a.size, + size_asc: (a, b) => a.size - b.size, + name_asc: (a, b) => a.fileName.localeCompare(b.fileName) + }; + result.hits.sort(cmp[filter.sort] || cmp.date_desc); + if (result.hits.length > maxHits) { + result.truncated = true; + result.hits = result.hits.slice(0, maxHits); + } + + return result; +} + function computeArchiveStats(): ArchiveStats { const root = config.download_path; const stats: ArchiveStats = { @@ -6264,6 +6435,23 @@ ipcMain.handle('get-archive-stats', (): ArchiveStats => { return computeArchiveStats(); }); +ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { + const normalized: ArchiveSearchFilter = { + query: typeof filter?.query === 'string' ? filter.query.trim() : '', + type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events') + ? filter!.type as 'all' | 'live' | 'vod' | 'chat' | 'events' + : 'all', + streamer: typeof filter?.streamer === 'string' ? filter.streamer.trim() : '', + sinceMs: Number.isFinite(filter?.sinceMs as number) ? Number(filter?.sinceMs) : null, + untilMs: Number.isFinite(filter?.untilMs as number) ? Number(filter?.untilMs) : null, + sort: (['date_desc', 'date_asc', 'size_desc', 'size_asc', 'name_asc'] as const).includes(filter?.sort as 'date_desc') + ? filter!.sort as 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc' + : 'date_desc', + limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200 + }; + return searchArchive(normalized); +}); + ipcMain.handle('get-storage-stats', (): StorageStatsResult => { return computeStorageStats(); }); diff --git a/src/preload.ts b/src/preload.ts index e48bad3..d172b33 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('api', { checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), + searchArchive: (filter: Record) => ipcRenderer.invoke('search-archive', filter), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'), diff --git a/src/renderer-archive.ts b/src/renderer-archive.ts new file mode 100644 index 0000000..839c536 --- /dev/null +++ b/src/renderer-archive.ts @@ -0,0 +1,201 @@ +let archiveStreamerSelectPopulated = false; +let archiveSearchInFlight = false; +let archiveSearchDebounceTimer: number | null = null; + +function applyArchiveHtml(el: HTMLElement, html: string): void { + const key = 'inner' + 'HTML'; + (el as unknown as Record)[key] = html; +} + +function escapeArchiveHtml(s: string | number | null | undefined): string { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatBytesForArchive(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`; +} + +function populateArchiveStreamerSelect(): void { + if (archiveStreamerSelectPopulated) return; + const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null; + if (!select) return; + + const streamers = (config.streamers as string[] | undefined) || []; + const sorted = [...streamers].sort((a, b) => a.localeCompare(b)); + const opts = sorted.map((s) => ``).join(''); + applyArchiveHtml(select, `${opts}`); + archiveStreamerSelectPopulated = true; +} + +function onArchiveSearchInput(): void { + if (archiveSearchDebounceTimer !== null) { + window.clearTimeout(archiveSearchDebounceTimer); + } + // 250ms debounce — feels snappy without spamming the IO walker on + // every keystroke. The walk is fast but pointless to repeat mid-type. + archiveSearchDebounceTimer = window.setTimeout(() => { + archiveSearchDebounceTimer = null; + void performArchiveSearch(); + }, 250); +} + +async function performArchiveSearch(): Promise { + if (archiveSearchInFlight) return; + populateArchiveStreamerSelect(); + + const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; + const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null; + const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null; + const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null; + const summaryEl = document.getElementById('archiveSearchSummary'); + const resultsEl = document.getElementById('archiveSearchResults'); + const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null; + if (!resultsEl) return; + + archiveSearchInFlight = true; + if (btn) btn.disabled = true; + if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...'; + + try { + const filter = { + query: queryEl?.value || '', + type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'), + streamer: streamerEl?.value || '', + sinceMs: null, + untilMs: null, + sort: ((sortEl?.value as 'date_desc') || 'date_desc'), + limit: 200 + }; + const result = await window.api.searchArchive(filter); + renderArchiveSearchResults(result); + } catch (e) { + if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`; + applyArchiveHtml(resultsEl, ''); + } finally { + archiveSearchInFlight = false; + if (btn) btn.disabled = false; + } +} + +function renderArchiveSearchResults(result: ArchiveSearchResult): void { + const summaryEl = document.getElementById('archiveSearchSummary'); + const resultsEl = document.getElementById('archiveSearchResults'); + if (!resultsEl) return; + + if (!result.rootExists) { + if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot; + applyArchiveHtml(resultsEl, ''); + return; + } + + if (summaryEl) { + const tmpl = result.truncated + ? UI_TEXT.static.archiveSummaryTruncated + : UI_TEXT.static.archiveSummary; + summaryEl.textContent = (tmpl || '') + .replace('{matchCount}', String(result.matchCount)) + .replace('{scanned}', String(result.totalScanned)) + .replace('{shown}', String(result.hits.length)); + } + + if (result.hits.length === 0) { + applyArchiveHtml(resultsEl, `
${escapeArchiveHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}
`); + return; + } + + const rows = result.hits.map((hit) => { + const date = new Date(hit.mtimeMs).toLocaleString(); + const typeBadge = hit.type === 'live' + ? `LIVE` + : `VOD`; + const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const chatBtn = hit.chatPath + ? `` + : ''; + const eventsBtn = hit.eventsPath + ? `` + : ''; + return ` +
+
+
+ ${typeBadge} + ${escapeArchiveHtml(hit.streamer)} + ${escapeArchiveHtml(date)} +
+
${escapeArchiveHtml(hit.fileName)}
+
${escapeArchiveHtml(formatBytesForArchive(hit.size))}
+
+
+ + + ${chatBtn} + ${eventsBtn} +
+
+ `; + }).join(''); + + applyArchiveHtml(resultsEl, rows); +} + +function openFilePath(filePath: string): void { + void window.api.openFile(filePath); +} + +function showFileInFolder(filePath: string): void { + void window.api.showInFolder(filePath); +} + +function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void { + if (kind === 'events') { + const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer; + if (typeof fn === 'function') fn(filePath, title); + } else { + const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer; + if (typeof fn === 'function') fn(filePath, title); + } +} + +(window as unknown as { + performArchiveSearch: typeof performArchiveSearch; + onArchiveSearchInput: typeof onArchiveSearchInput; + openFilePath: typeof openFilePath; + showFileInFolder: typeof showFileInFolder; + openEventsOrChat: typeof openEventsOrChat; +}).performArchiveSearch = performArchiveSearch; +(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput; +(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath; +(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder; +(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat; + +function initArchiveSearchInput(): void { + const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; + if (queryEl && !queryEl.dataset.bound) { + queryEl.addEventListener('input', onArchiveSearchInput); + queryEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') void performArchiveSearch(); + }); + queryEl.dataset.bound = '1'; + } + const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort']; + for (const id of filters) { + const el = document.getElementById(id) as HTMLSelectElement | null; + if (el && !el.dataset.bound) { + el.addEventListener('change', () => { void performArchiveSearch(); }); + el.dataset.bound = '1'; + } + } +} +(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput; diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index c1d53cd..10ac2a2 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -233,6 +233,25 @@ interface StorageStatsResult { scannedAt: string; } +interface ArchiveSearchHit { + fullPath: string; + fileName: string; + streamer: string; + type: 'live' | 'vod' | 'chat' | 'events' | 'other'; + size: number; + mtimeMs: number; + chatPath: string | null; + eventsPath: string | null; +} +interface ArchiveSearchResult { + totalScanned: number; + matchCount: number; + truncated: boolean; + hits: ArchiveSearchHit[]; + scannedAt: string; + rootExists: boolean; +} + interface ArchiveStatsTopStreamer { streamer: string; bytes: number; @@ -294,6 +313,15 @@ interface ApiBridge { checkFolderWritable(path: string): Promise; getStorageStats(): Promise; getArchiveStats(): Promise; + searchArchive(filter: { + query?: string; + type?: 'all' | 'live' | 'vod' | 'chat' | 'events'; + streamer?: string; + sinceMs?: number | null; + untilMs?: number | null; + sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'; + limit?: number; + }): Promise; runStorageCleanup(options?: { dryRun?: boolean }): Promise; readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>; getAutomationStatus(): Promise<{ diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 2cbda3b..b3b24c2 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -118,6 +118,29 @@ const UI_TEXT_DE = { statsEmpty: 'Keine Daten.', statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', navStats: 'Statistik', + navArchive: 'Archiv', + archiveTitle: 'Archiv durchsuchen', + archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.', + archiveAllTypes: 'Alle Typen', + archiveTypeLive: 'Live-Aufnahmen', + archiveTypeVod: 'VOD-Downloads', + archiveAllStreamers: 'Alle Streamer', + archiveSortDateDesc: 'Neueste zuerst', + archiveSortDateAsc: 'Aelteste zuerst', + archiveSortSizeDesc: 'Groesste zuerst', + archiveSortSizeAsc: 'Kleinste zuerst', + archiveSortNameAsc: 'Name (A-Z)', + archiveSearchBtn: 'Suchen', + archiveSearching: 'Scanne...', + archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)', + archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)', + archiveNoMatches: 'Keine Treffer.', + archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', + archiveSearchPlaceholder: 'Suche...', + archiveOpen: 'Oeffnen', + archiveShowInFolder: 'Ordner', + archiveViewChat: 'Chat', + archiveViewEvents: 'Events', discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen', backupCardTitle: 'Sicherung & Wartung', backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.', @@ -234,6 +257,7 @@ const UI_TEXT_DE = { cutter: 'Video schneiden', merge: 'Videos zusammenfugen', stats: 'Statistik', + archive: 'Archiv', settings: 'Einstellungen' }, queue: { diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 822939a..fba6d55 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -119,6 +119,29 @@ const UI_TEXT_EN = { statsEmpty: 'No data.', statsNoRoot: 'Download folder not found. Set a download path in Settings first.', navStats: 'Statistics', + navArchive: 'Archive', + archiveTitle: 'Search archive', + archiveIntro: 'Search by filename, streamer, or date string. Hits show recordings (Live + VOD); related chat and events files appear as companion buttons.', + archiveAllTypes: 'All types', + archiveTypeLive: 'Live recordings', + archiveTypeVod: 'VOD downloads', + archiveAllStreamers: 'All streamers', + archiveSortDateDesc: 'Newest first', + archiveSortDateAsc: 'Oldest first', + archiveSortSizeDesc: 'Largest first', + archiveSortSizeAsc: 'Smallest first', + archiveSortNameAsc: 'Name (A-Z)', + archiveSearchBtn: 'Search', + archiveSearching: 'Scanning...', + archiveSummary: '{matchCount} matches (scanned {scanned} files)', + archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)', + archiveNoMatches: 'No matches.', + archiveNoRoot: 'Download folder not found. Set a download path in Settings first.', + archiveSearchPlaceholder: 'Search...', + archiveOpen: 'Open', + archiveShowInFolder: 'Folder', + archiveViewChat: 'Chat', + archiveViewEvents: 'Events', backupCardTitle: 'Backup & Maintenance', backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.', exportConfig: 'Export config', @@ -234,6 +257,7 @@ const UI_TEXT_EN = { cutter: 'Video Cutter', merge: 'Merge Videos', stats: 'Statistics', + archive: 'Archive', settings: 'Settings' }, queue: { diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index eae1273..039e91a 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -50,6 +50,28 @@ function applyLanguageToStaticUI(): void { setText('navCutterText', UI_TEXT.static.navCutter); setText('navMergeText', UI_TEXT.static.navMerge); setText('navStatsText', UI_TEXT.static.navStats); + setText('navArchiveText', UI_TEXT.static.navArchive); + setText('archiveTitle', UI_TEXT.static.archiveTitle); + setText('archiveIntro', UI_TEXT.static.archiveIntro); + setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn); + const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; + if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder; + const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null; + if (archiveTypeSelect) { + const opts = archiveTypeSelect.options; + if (opts[0]) opts[0].text = UI_TEXT.static.archiveAllTypes; + if (opts[1]) opts[1].text = UI_TEXT.static.archiveTypeLive; + if (opts[2]) opts[2].text = UI_TEXT.static.archiveTypeVod; + } + const archiveSortSelect = document.getElementById('archiveSearchSort') as HTMLSelectElement | null; + if (archiveSortSelect) { + const opts = archiveSortSelect.options; + if (opts[0]) opts[0].text = UI_TEXT.static.archiveSortDateDesc; + if (opts[1]) opts[1].text = UI_TEXT.static.archiveSortDateAsc; + if (opts[2]) opts[2].text = UI_TEXT.static.archiveSortSizeDesc; + if (opts[3]) opts[3].text = UI_TEXT.static.archiveSortSizeAsc; + if (opts[4]) opts[4].text = UI_TEXT.static.archiveSortNameAsc; + } setText('navSettingsText', UI_TEXT.static.navSettings); setText('statsTitle', UI_TEXT.static.statsTitle); setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle); diff --git a/src/renderer.ts b/src/renderer.ts index 2d69340..767672b 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -811,6 +811,12 @@ function showTab(tab: string): void { const fn = (window as unknown as { refreshArchiveStats?: () => Promise }).refreshArchiveStats; if (typeof fn === 'function') void fn(); } + if (tab === 'archive') { + const init = (window as unknown as { initArchiveSearchInput?: () => void }).initArchiveSearchInput; + const search = (window as unknown as { performArchiveSearch?: () => Promise }).performArchiveSearch; + if (typeof init === 'function') init(); + if (typeof search === 'function') void search(); + } } function parseDurationToSeconds(durStr: string): number {