diff --git a/src/main.ts b/src/main.ts index 407f690..fbc9649 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,12 @@ import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; import { writeFileAtomicSync } from './main/infra/fs-atomic'; import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration'; +import { + sanitizeFilenamePart, + formatTwitchDurationFromSeconds, + formatDateWithPattern, + getMergeGroupPhaseText as getMergeGroupPhaseTextCore, +} from './main/infra/format-helpers'; import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend'; import type { DbHandle } from './main/infra/db'; import { @@ -86,14 +92,7 @@ type UpdateCheckSource = 'startup' | 'interval' | 'manual'; type UpdateDownloadSource = 'auto' | 'manual'; function getMergeGroupPhaseText(phase: string): string { - const isEnglish = config.language === 'en'; - switch (phase) { - case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; - case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; - case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; - case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; - default: return phase; - } + return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de'); } // ========================================== @@ -967,36 +966,6 @@ function releaseClaimedFilenamesForItem(itemId: string): void { itemClaimedFilenames.delete(itemId); } -function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { - const cleaned = (input || '') - .replace(/[<>:"|?*\x00-\x1f]/g, '_') - .replace(/[\\/]/g, '_') - .trim(); - return cleaned || fallback; -} - -function formatDateWithPattern(date: Date, pattern: string): string { - const tokenMap: Record = { - yyyy: date.getFullYear().toString(), - yy: date.getFullYear().toString().slice(-2), - MM: (date.getMonth() + 1).toString().padStart(2, '0'), - M: (date.getMonth() + 1).toString(), - dd: date.getDate().toString().padStart(2, '0'), - d: date.getDate().toString(), - HH: date.getHours().toString().padStart(2, '0'), - H: date.getHours().toString(), - hh: date.getHours().toString().padStart(2, '0'), - h: date.getHours().toString(), - mm: date.getMinutes().toString().padStart(2, '0'), - m: date.getMinutes().toString(), - ss: date.getSeconds().toString().padStart(2, '0'), - s: date.getSeconds().toString() - }; - - return pattern - .replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token) - .replace(/\\(.)/g, '$1'); -} function formatSecondsWithPattern(totalSeconds: number, pattern: string): string { const safe = Math.max(0, Math.floor(totalSeconds)); @@ -1714,17 +1683,6 @@ async function ensureTwitchAuth(forceRefresh = false): Promise { return await requestTwitchLogin(); } -function formatTwitchDurationFromSeconds(totalSeconds: number): string { - const seconds = Math.max(0, Math.floor(totalSeconds)); - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - - if (h > 0) return `${h}h${m}m${s}s`; - if (m > 0) return `${m}m${s}s`; - return `${s}s`; -} - // Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit). // 4xx (other than 408/429) are application errors and not retried. function isTransientAxiosError(err: unknown): boolean { diff --git a/src/main/infra/format-helpers.test.ts b/src/main/infra/format-helpers.test.ts new file mode 100644 index 0000000..889ab01 --- /dev/null +++ b/src/main/infra/format-helpers.test.ts @@ -0,0 +1,103 @@ +import { test, expect, describe } from 'vitest'; +import { + sanitizeFilenamePart, + formatTwitchDurationFromSeconds, + formatDateWithPattern, + getMergeGroupPhaseText, +} from './format-helpers'; + +describe('sanitizeFilenamePart', () => { + test('replaces Windows-invalid chars with underscore', () => { + expect(sanitizeFilenamePart('ac:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h'); + }); + test('replaces path separators', () => { + expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c'); + }); + test('strips control chars', () => { + expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c'); + }); + test('trims whitespace', () => { + expect(sanitizeFilenamePart(' hi ')).toBe('hi'); + }); + test('empty falls back to default', () => { + expect(sanitizeFilenamePart('')).toBe('unnamed'); + }); + test('custom fallback', () => { + expect(sanitizeFilenamePart('', 'FB')).toBe('FB'); + }); + test('only-invalid-chars falls back', () => { + expect(sanitizeFilenamePart('////').trim()).not.toBe(''); + // '////' becomes '____' which is non-empty, so no fallback + expect(sanitizeFilenamePart('////')).toBe('____'); + }); +}); + +describe('formatTwitchDurationFromSeconds', () => { + test('0 = 0s', () => { + expect(formatTwitchDurationFromSeconds(0)).toBe('0s'); + }); + test('45 = 45s', () => { + expect(formatTwitchDurationFromSeconds(45)).toBe('45s'); + }); + test('65 = 1m5s', () => { + expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s'); + }); + test('3725 = 1h2m5s', () => { + expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s'); + }); + test('3600 = 1h0m0s', () => { + expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s'); + }); + test('negative clamped to 0', () => { + expect(formatTwitchDurationFromSeconds(-5)).toBe('0s'); + }); + test('NaN clamped to 0', () => { + expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s'); + }); + test('Infinity clamped to 0', () => { + expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s'); + }); +}); + +describe('formatDateWithPattern', () => { + const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07 + + test('yyyy-MM-dd', () => { + expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11'); + }); + test('yy MM dd', () => { + expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11'); + }); + test('HH:mm:ss', () => { + expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07'); + }); + test('combined pattern', () => { + expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07'); + }); + test('backslashes are stripped after token substitution', () => { + // Note: \ does NOT escape the date-token (no negative-lookbehind in regex). + // It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'. + expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X'); + }); +}); + +describe('getMergeGroupPhaseText', () => { + test('known DE phases', () => { + expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen'); + expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...'); + expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt'); + expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...'); + }); + test('known EN phases', () => { + expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD'); + expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...'); + expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part'); + expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...'); + }); + test('unknown phase passes through', () => { + expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown'); + }); + test('unknown language falls back to DE', () => { + expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen'); + }); +}); diff --git a/src/main/infra/format-helpers.ts b/src/main/infra/format-helpers.ts new file mode 100644 index 0000000..13259fd --- /dev/null +++ b/src/main/infra/format-helpers.ts @@ -0,0 +1,78 @@ +// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O. + +const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g; +const FILENAME_PATH_SEP_RE = /[\\/]/g; + +/** + * Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem + * Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup + * nichts uebrig bleibt. + */ +export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { + const cleaned = (input || '') + .replace(FILENAME_INVALID_RE, '_') + .replace(FILENAME_PATH_SEP_RE, '_') + .trim(); + return cleaned || fallback; +} + +/** + * Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder + * NaN-Inputs werden auf 0 geclamt. + */ +export function formatTwitchDurationFromSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + + if (h > 0) return `${h}h${m}m${s}s`; + if (m > 0) return `${m}m${s}s`; + return `${s}s`; +} + +const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g; + +/** + * Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h, + * mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal. + */ +export function formatDateWithPattern(date: Date, pattern: string): string { + const tokenMap: Record = { + yyyy: date.getFullYear().toString(), + yy: date.getFullYear().toString().slice(-2), + MM: (date.getMonth() + 1).toString().padStart(2, '0'), + M: (date.getMonth() + 1).toString(), + dd: date.getDate().toString().padStart(2, '0'), + d: date.getDate().toString(), + HH: date.getHours().toString().padStart(2, '0'), + H: date.getHours().toString(), + hh: date.getHours().toString().padStart(2, '0'), + h: date.getHours().toString(), + mm: date.getMinutes().toString().padStart(2, '0'), + m: date.getMinutes().toString(), + ss: date.getSeconds().toString().padStart(2, '0'), + s: date.getSeconds().toString(), + }; + + return pattern + .replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token) + .replace(/\\(.)/g, '$1'); +} + +export type MergeGroupLanguage = 'de' | 'en'; + +/** + * Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache + * wird vom Caller injiziert. + */ +export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string { + const isEnglish = language === 'en'; + switch (phase) { + case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; + case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; + case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; + case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; + default: return phase; + } +}