feat: hide-downloaded filter + reset list + config export/import
Three companion features around the 4.5.22 already-downloaded badge. 1. "Hide downloaded" toggle in the VOD filter row. Persisted to localStorage so power users who keep it on across sessions don't re-flip it on every launch. Filter applies before the title-search filter so the match counter stays consistent. 2. "Reset downloaded list" button in a new Backup & Maintenance settings card. Confirm-dialog before clearing, IPC returns the removed count for a "cleared N entries" toast. Renderer refreshes its config copy + re-renders the VOD grid so badges disappear immediately. No files are touched. 3. Config export / import via dialog.show*Dialog. Export strips client_secret (should never travel as plain text via cloud sync), tags the file with __exportVersion + __exportedAt. Import runs the JSON through normalizeConfigTemplates so out-of-range fields fall back to defaults; if the imported file lacks client_secret, the existing value is preserved. After import the renderer reloads config + relocalizes if language changed + re-renders streamers / settings form / VOD grid. DE + EN locale strings for every label, button, toast, and confirm dialog. New backupCardTitle / backupCardIntro section header in Settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56d4e0904f
commit
e2c0e3a2bf
@ -257,6 +257,10 @@
|
||||
<option value="duration_asc">Shortest first</option>
|
||||
</select>
|
||||
<span id="vodFilterCount" style="color: var(--text-secondary); font-size:12px; min-width:80px;"></span>
|
||||
<label id="vodHideDownloadedLabel" style="display:flex; align-items:center; gap:6px; color: var(--text-secondary); font-size:12px; cursor:pointer; user-select:none;" title="">
|
||||
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()" style="accent-color: var(--accent); cursor:pointer;">
|
||||
<span id="vodHideDownloadedText">Hide downloaded</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="vodBulkBar" class="vod-bulk-bar" style="display:none; align-items:center; gap:10px; padding:8px 12px; background: rgba(145, 70, 255, 0.12); border:1px solid rgba(145, 70, 255, 0.4); border-radius:6px; margin-bottom:12px;">
|
||||
<span id="vodBulkCount" style="color: var(--text); font-size:13px; font-weight:600;">0 selected</span>
|
||||
@ -558,6 +562,16 @@
|
||||
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="backupCardTitle">Sicherung & Wartung</h3>
|
||||
<p id="backupCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
|
||||
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
|
||||
<button class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
|
||||
<button class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
|
||||
<button class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
|
||||
78
src/main.ts
78
src/main.ts
@ -4276,6 +4276,84 @@ ipcMain.handle('export-runtime-metrics', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-downloaded-vod-ids', () => {
|
||||
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
||||
config.downloaded_vod_ids = [];
|
||||
saveConfig(config);
|
||||
appendDebugLog('reset-downloaded-vod-ids', { previousCount: count });
|
||||
return { success: true, removedCount: count };
|
||||
});
|
||||
|
||||
ipcMain.handle('export-config', async () => {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const defaultName = `twitch-vod-manager-config-${timestamp}.json`;
|
||||
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
||||
|
||||
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
||||
defaultPath: path.join(preferredDir, defaultName),
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }]
|
||||
});
|
||||
|
||||
if (dialogResult.canceled || !dialogResult.filePath) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
|
||||
// Strip the secrets from the export — Client Secret should not
|
||||
// travel as plain text across machines / cloud sync. The user
|
||||
// re-enters it on the new machine after import.
|
||||
const exportable = {
|
||||
...config,
|
||||
client_secret: '',
|
||||
__exportVersion: 1,
|
||||
__exportedAt: new Date().toISOString()
|
||||
};
|
||||
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2));
|
||||
return { success: true, filePath: dialogResult.filePath };
|
||||
} catch (e) {
|
||||
appendDebugLog('config-export-failed', String(e));
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('import-config', async () => {
|
||||
try {
|
||||
const dialogResult = await dialog.showOpenDialog(mainWindow!, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }]
|
||||
});
|
||||
if (dialogResult.canceled || !dialogResult.filePaths[0]) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
|
||||
const importPath = dialogResult.filePaths[0];
|
||||
const raw = fs.readFileSync(importPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { success: false, error: 'Imported file is not a JSON object.' };
|
||||
}
|
||||
|
||||
// Merge over current config so unknown / missing keys keep their
|
||||
// existing values. Then run normalizeConfigTemplates so any
|
||||
// out-of-range field falls back to defaults.
|
||||
const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config);
|
||||
|
||||
// Preserve the existing client_secret if the import stripped it
|
||||
// (export does this on purpose) — the user shouldn't lose creds.
|
||||
if (!merged.client_secret && config.client_secret) {
|
||||
merged.client_secret = config.client_secret;
|
||||
}
|
||||
|
||||
config = merged;
|
||||
saveConfig(config);
|
||||
appendDebugLog('config-import-applied', { source: importPath });
|
||||
return { success: true, filePath: importPath };
|
||||
} catch (e) {
|
||||
appendDebugLog('config-import-failed', String(e));
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
// Video Cutter IPC
|
||||
ipcMain.handle('get-video-info', async (_, filePath: string) => {
|
||||
return await getVideoInfo(filePath);
|
||||
|
||||
@ -109,6 +109,12 @@ contextBridge.exposeInMainWorld('api', {
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
||||
ipcRenderer.invoke('reset-downloaded-vod-ids'),
|
||||
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-config'),
|
||||
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('import-config'),
|
||||
|
||||
// Events
|
||||
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
|
||||
|
||||
3
src/renderer-globals.d.ts
vendored
3
src/renderer-globals.d.ts
vendored
@ -217,6 +217,9 @@ interface ApiBridge {
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
||||
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
||||
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
||||
|
||||
@ -54,6 +54,17 @@ const UI_TEXT_DE = {
|
||||
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Log-Datei oeffnen',
|
||||
backupCardTitle: 'Sicherung & Wartung',
|
||||
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
||||
exportConfig: 'Konfiguration exportieren',
|
||||
importConfig: 'Konfiguration importieren',
|
||||
resetDownloadedIds: 'Downloaded-VODs zuruecksetzen',
|
||||
configExported: 'Konfiguration exportiert.',
|
||||
configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
|
||||
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
|
||||
configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
|
||||
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.',
|
||||
resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.',
|
||||
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
|
||||
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
||||
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
|
||||
@ -194,7 +205,9 @@ const UI_TEXT_DE = {
|
||||
bulkClear: 'Loeschen',
|
||||
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
|
||||
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
||||
alreadyDownloaded: 'Bereits heruntergeladen'
|
||||
alreadyDownloaded: 'Bereits heruntergeladen',
|
||||
hideDownloaded: 'Bereits geladene ausblenden',
|
||||
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'VOD zuschneiden',
|
||||
|
||||
@ -54,6 +54,17 @@ const UI_TEXT_EN = {
|
||||
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Open log file',
|
||||
backupCardTitle: 'Backup & Maintenance',
|
||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
resetDownloadedIds: 'Reset downloaded list',
|
||||
configExported: 'Configuration exported.',
|
||||
configExportFailed: 'Configuration export failed.',
|
||||
configImported: 'Configuration imported. Some changes may need a restart.',
|
||||
configImportFailed: 'Configuration import failed.',
|
||||
resetDownloadedConfirm: 'Reset the downloaded-VODs list? Cards will lose the green check mark, but no files are deleted.',
|
||||
resetDownloadedDone: 'Cleared {count} entries from the downloaded list.',
|
||||
duplicatePreventionLabel: 'Prevent duplicate queue entries',
|
||||
persistQueueLabel: 'Keep queue between app restarts',
|
||||
autoResumeQueueLabel: 'Auto-resume the queue on startup',
|
||||
@ -194,7 +205,9 @@ const UI_TEXT_EN = {
|
||||
bulkClear: 'Clear',
|
||||
bulkAddedToQueue: 'Added {count} VODs to the queue.',
|
||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||
alreadyDownloaded: 'Already downloaded'
|
||||
alreadyDownloaded: 'Already downloaded',
|
||||
hideDownloaded: 'Hide downloaded',
|
||||
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim VOD',
|
||||
|
||||
@ -265,6 +265,60 @@ async function runPreflight(autoFix = false): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConfigToFile(): Promise<void> {
|
||||
const result = await window.api.exportConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
if (toast) toast(UI_TEXT.static.configExported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigFromFile(): Promise<void> {
|
||||
const result = await window.api.importConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||
setLanguage(config.language);
|
||||
}
|
||||
if (typeof renderStreamers === 'function') renderStreamers();
|
||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore — next refresh will catch up */ }
|
||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDownloadedIds(): Promise<void> {
|
||||
if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return;
|
||||
const result = await window.api.resetDownloadedVodIds();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Refresh local config so the badges disappear immediately
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (toast) {
|
||||
toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugLogFile(): Promise<void> {
|
||||
const ok = await window.api.openDebugLogFile();
|
||||
if (!ok) {
|
||||
|
||||
@ -16,6 +16,32 @@ const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
|
||||
const selectedVodUrls = new Set<string>();
|
||||
let vodGridDelegationInitialized = false;
|
||||
|
||||
// Hide-downloaded toggle: when enabled, the VOD grid skips entries whose
|
||||
// vod.id is in config.downloaded_vod_ids. Persisted to localStorage so a
|
||||
// power user who keeps it enabled doesn't have to re-flip it every launch.
|
||||
const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded';
|
||||
let vodHideDownloaded = false;
|
||||
|
||||
function loadPersistedHideDownloaded(): boolean {
|
||||
try { return localStorage.getItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
function persistHideDownloaded(value: boolean): void {
|
||||
try { localStorage.setItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function onVodHideDownloadedChange(): void {
|
||||
const cb = byId<HTMLInputElement>('vodHideDownloadedToggle');
|
||||
vodHideDownloaded = cb.checked;
|
||||
persistHideDownloaded(vodHideDownloaded);
|
||||
if (lastLoadedStreamer) renderVodGridFromCurrentState();
|
||||
}
|
||||
|
||||
function syncVodHideDownloadedToggle(): void {
|
||||
const cb = document.getElementById('vodHideDownloadedToggle') as HTMLInputElement | null;
|
||||
if (cb) cb.checked = vodHideDownloaded;
|
||||
}
|
||||
|
||||
type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc';
|
||||
const VALID_VOD_SORTS: ReadonlyArray<VodSortKey> = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc'];
|
||||
const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
|
||||
@ -503,7 +529,15 @@ function renderVodGridFromCurrentState(): void {
|
||||
}
|
||||
|
||||
const sorted = sortVods(lastLoadedVods, vodSortKey);
|
||||
const filtered = filterVodsByQuery(sorted, vodFilterQuery);
|
||||
const downloadedIdsForFilter = new Set(
|
||||
Array.isArray(config.downloaded_vod_ids)
|
||||
? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string')
|
||||
: []
|
||||
);
|
||||
const sortedAndHidden = vodHideDownloaded
|
||||
? sorted.filter((vod) => !downloadedIdsForFilter.has(vod.id))
|
||||
: sorted;
|
||||
const filtered = filterVodsByQuery(sortedAndHidden, vodFilterQuery);
|
||||
|
||||
if (filtered.length === 0 && vodFilterQuery.trim()) {
|
||||
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
|
||||
|
||||
@ -152,6 +152,13 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
|
||||
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
|
||||
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
|
||||
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
|
||||
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
||||
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
||||
setText('btnImportConfig', UI_TEXT.static.importConfig);
|
||||
setText('btnResetDownloadedIds', UI_TEXT.static.resetDownloadedIds);
|
||||
setText('vodHideDownloadedText', UI_TEXT.vods.hideDownloaded);
|
||||
setTitle('vodHideDownloadedLabel', UI_TEXT.vods.hideDownloadedTitle);
|
||||
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
|
||||
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
|
||||
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
|
||||
|
||||
@ -60,6 +60,10 @@ async function init(): Promise<void> {
|
||||
refreshVodSortSelectLabels();
|
||||
syncVodSortSelect();
|
||||
|
||||
// Restore "hide downloaded" toggle state.
|
||||
vodHideDownloaded = loadPersistedHideDownloaded();
|
||||
syncVodHideDownloadedToggle();
|
||||
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
showTab(loadPersistedActiveTab());
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user