diff --git a/lib/config-store.js b/lib/config-store.js index 228dbaa..0b2760b 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const secretStore = require('./secret-store'); +const { normalizeLogMode } = require('./log-mode'); const HOSTER_SETTINGS_DEFAULTS = { retries: 3, @@ -56,7 +57,11 @@ const DEFAULTS = { alwaysOnTop: false, shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart logFilePath: '', - sessionLog: false, + sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load + // NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge + // would seed logMode='single' for every load, which would beat (and silently + // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in + // load() sets logMode after the merge, looking at the saved-only data. resumeQueueOnLaunch: true, parallelUploadCount: 0, // 0 = use per-hoster limits only scaleParallelUploads: false, @@ -145,7 +150,11 @@ class ConfigStore { const backupPath = this.filePath + '.bak'; try { data = this._readAndParse(backupPath); } catch {} } - if (!data) return JSON.parse(JSON.stringify(DEFAULTS)); + if (!data) { + const fresh = JSON.parse(JSON.stringify(DEFAULTS)); + fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings); + return fresh; + } // Migrate old single-object format to array format for (const [name, val] of Object.entries(data.hosters || {})) { @@ -207,13 +216,20 @@ class ConfigStore { globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) }; } } + // Normalize logMode at this single boundary. Legacy sessionLog: true + // means *daily* (the old field was named after a misnomer); see log-mode.js. + // Downstream readers consume logMode only and must NOT derive from + // sessionLog at call sites. + globalSettings.logMode = normalizeLogMode(globalSettings); const result = { hosters, hosterSettings, globalSettings, history: data.history || [] }; // Decrypt credentials stored with safeStorage so the rest of the app // keeps working with plaintext in memory. secretStore.decryptCredentials(result); return result; } catch { - return JSON.parse(JSON.stringify(DEFAULTS)); + const fresh = JSON.parse(JSON.stringify(DEFAULTS)); + fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings); + return fresh; } } diff --git a/lib/log-mode.js b/lib/log-mode.js new file mode 100644 index 0000000..b3b876c --- /dev/null +++ b/lib/log-mode.js @@ -0,0 +1,85 @@ +// Log-file mode resolution for fileuploader.log: +// - "single" → one file: fileuploader.log +// - "daily" → per-day: fileuploader-YYYY-MM-DD.log +// - "session" → per-launch: fileuploader-session-YYYY-MM-DD_HH-MM-SS-.log +// +// Pure functions only — no fs, no Date.now() at call time — so they unit-test +// cleanly and the main.js call sites pass in `new Date()` + the session stamp. +// +// MIGRATION TRAP this lib protects against: the legacy boolean was named +// `sessionLog` but actually toggled *daily* mode. A naive rename would silently +// flip every per-day user onto per-session. normalizeLogMode below maps the +// legacy `sessionLog: true` to "daily", NOT "session". Read logMode everywhere +// downstream; do not derive from sessionLog at call sites. +// +// Loaded both as CommonJS (main.js, tests) and as a browser global +// (renderer/app.js via index.html script tag) so a single implementation backs +// runtime and tests — same pattern as queue-prune.js / queue-dedup.js. + +(function (root) { + 'use strict'; + + const VALID_MODES = new Set(['single', 'daily', 'session']); + + function normalizeLogMode(globalSettings) { + const gs = globalSettings && typeof globalSettings === 'object' ? globalSettings : {}; + if (typeof gs.logMode === 'string' && VALID_MODES.has(gs.logMode)) { + return gs.logMode; + } + // Legacy boolean migration: sessionLog *named* like "session" but actually + // implemented "daily" — preserve daily users on the migration path. + if (gs.sessionLog === true) return 'daily'; + return 'single'; + } + + function _two(n) { return String(n).padStart(2, '0'); } + + function formatDateStamp(date) { + return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`; + } + + function formatSessionStamp(date, pid) { + const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`; + const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`; + // PID disambiguates a same-second close→reopen — a human can't but two + // automated runs might. Cheap belt to a suspenders-not-required problem. + const pidStr = pid !== undefined && pid !== null ? `-${pid}` : ''; + return `${d}_${t}${pidStr}`; + } + + /** + * Compute the log filename for the given mode + clock. + * @param {Object} args + * @param {string} args.baseName e.g. "fileuploader" + * @param {string} args.ext e.g. ".log" + * @param {string} args.mode "single" | "daily" | "session" + * @param {Date} args.date current timestamp + * @param {string} [args.sessionId] required when mode === "session" + * @returns {string} the bare filename (no directory) + */ + function resolveLogFileName(args) { + const a = args || {}; + const base = String(a.baseName || 'fileuploader'); + const ext = String(a.ext || '.log'); + const mode = VALID_MODES.has(a.mode) ? a.mode : 'single'; + if (mode === 'single') return `${base}${ext}`; + if (mode === 'daily') { + const date = a.date instanceof Date ? a.date : new Date(); + return `${base}-${formatDateStamp(date)}${ext}`; + } + // session + const sid = a.sessionId && String(a.sessionId).trim(); + if (sid) return `${base}-session-${sid}${ext}`; + // Defensive: if a session-id wasn't passed, fall back to single rather + // than emit a malformed name. main.js always supplies one. + return `${base}${ext}`; + } + + const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, VALID_MODES }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else if (root) { + root.LogMode = api; + } +})(typeof window !== 'undefined' ? window : this); diff --git a/main.js b/main.js index a34ab83..4b0b8b4 100644 --- a/main.js +++ b/main.js @@ -240,40 +240,41 @@ function getBaseLogFilePath() { return customPath || getDefaultLogFilePath(); } -// Daily log: one file per day, reused across sessions on the same day -let _dailyLogPath = null; -let _dailyLogDate = null; +// Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session. +// The session-id is stamped ONCE at main-process startup so every write of a +// given session lands in the same file. A close→reopen of the app starts a new +// main process, so a new SESSION_ID, so a new session file. PID is appended as +// a cheap hedge against same-second restart collisions. +const { resolveLogFileName, formatSessionStamp, formatDateStamp } = require('./lib/log-mode'); +const SESSION_ID = formatSessionStamp(new Date(), process.pid); +let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly +let _activeLogPath = null; function getLogFilePath() { const config = configStore.load(); - const useDailyLog = config && config.globalSettings && config.globalSettings.sessionLog; - if (!useDailyLog) return getBaseLogFilePath(); - + const mode = (config && config.globalSettings && config.globalSettings.logMode) || 'single'; + const base = getBaseLogFilePath(); + const dir = path.dirname(base); + const ext = path.extname(base); + const name = path.basename(base, ext); const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); - const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; - - // Reuse path if same day, otherwise generate new - if (_dailyLogDate !== today) { - const base = getBaseLogFilePath(); - const dir = path.dirname(base); - const ext = path.extname(base); - const name = path.basename(base, ext); - _dailyLogPath = path.join(dir, `${name}-${today}${ext}`); - _dailyLogDate = today; + // Cache key changes when the user toggles mode mid-run OR when the daily date + // rolls over at midnight — so the cached path can't be served stale. + const datePart = mode === 'daily' ? formatDateStamp(now) : ''; + const key = `${mode}|${datePart}|${SESSION_ID}|${base}`; + if (_activeLogKey !== key) { + _activeLogPath = path.join(dir, resolveLogFileName({ baseName: name, ext, mode, date: now, sessionId: SESSION_ID })); + _activeLogKey = key; } - return _dailyLogPath; + return _activeLogPath; } function buildFallbackLogName(dir) { - // Match the daily-log naming when enabled, so fallback files stay consistent. + // Match the active log-mode's naming so the fallback file is consistent with + // what the primary write would have produced. const config = configStore.load(); - const useDailyLog = config && config.globalSettings && config.globalSettings.sessionLog; - if (!useDailyLog) return path.join(dir, 'fileuploader.log'); - const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); - const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; - return path.join(dir, `fileuploader-${today}.log`); + const mode = (config && config.globalSettings && config.globalSettings.logMode) || 'single'; + return path.join(dir, resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date: new Date(), sessionId: SESSION_ID })); } function getSafeDesktopDir() { @@ -305,7 +306,10 @@ function _invalidateUploadLogTargetCache() { function _resolveUploadLogTarget() { const primary = getLogFilePath(); - const key = `${primary}|${_dailyLogDate || ''}`; + // The primary path already encodes the mode + date/session, so it changes + // when the user toggles mode, daily rolls at midnight, or this is a new + // process — cache invalidates naturally on path change. + const key = primary; if (_cachedUploadLogKey === key && _cachedUploadLogTarget) return _cachedUploadLogTarget; const commit = (t) => { diff --git a/renderer/app.js b/renderer/app.js index 472aa0d..8d52918 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2552,8 +2552,13 @@ function renderSettings() {
- - + + + Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.
`; @@ -2897,7 +2902,10 @@ async function saveSettings(options = {}) { const globalSettings = { ...(config.globalSettings || {}), logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), - sessionLog: !!document.getElementById('sessionLogInput')?.checked, + logMode: (() => { + const v = document.getElementById('logModeInput')?.value; + return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single'; + })(), resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, diff --git a/renderer/index.html b/renderer/index.html index 8b0df96..78769af 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -332,6 +332,7 @@ + diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 86b2af0..17c4198 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -56,6 +56,28 @@ describe('ConfigStore', () => { assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123'); }); + it('default logMode is "single"', () => { + const config = store.load(); + assert.equal(config.globalSettings.logMode, 'single'); + }); + + it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => { + // Write a config with the legacy boolean only (what an existing user has). + await store.save({ globalSettings: { sessionLog: true } }); + const config = store.load(); + // The misnamed legacy field MUST map to daily — mapping to "session" would + // silently change every per-day user's behaviour on upgrade. + assert.equal(config.globalSettings.logMode, 'daily'); + }); + + it('logMode round-trips for all three values', async () => { + for (const mode of ['single', 'daily', 'session']) { + await store.save({ globalSettings: { logMode: mode } }); + const config = store.load(); + assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`); + } + }); + it('load merges with defaults for missing hosters', () => { // Write partial config in old single-object format (triggers migration) fs.writeFileSync(store.filePath, JSON.stringify({ diff --git a/tests/log-mode.test.js b/tests/log-mode.test.js new file mode 100644 index 0000000..b686afc --- /dev/null +++ b/tests/log-mode.test.js @@ -0,0 +1,96 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode'); + +// --- normalizeLogMode --- + +test('normalizeLogMode: default for empty/null/undefined is "single"', () => { + assert.equal(normalizeLogMode(), 'single'); + assert.equal(normalizeLogMode(null), 'single'); + assert.equal(normalizeLogMode({}), 'single'); +}); + +test('normalizeLogMode: explicit logMode wins for all three valid values', () => { + assert.equal(normalizeLogMode({ logMode: 'single' }), 'single'); + assert.equal(normalizeLogMode({ logMode: 'daily' }), 'daily'); + assert.equal(normalizeLogMode({ logMode: 'session' }), 'session'); +}); + +test('regression: legacy sessionLog:true maps to "daily", NOT "session"', () => { + // The legacy boolean field was named after a misnomer — it actually toggled + // per-day logging. Mapping it to "session" would silently flip every existing + // per-day user onto per-session, which is exactly the bug the migration trap + // exists to prevent. + assert.equal(normalizeLogMode({ sessionLog: true }), 'daily'); +}); + +test('normalizeLogMode: sessionLog:false / missing maps to "single"', () => { + assert.equal(normalizeLogMode({ sessionLog: false }), 'single'); +}); + +test('normalizeLogMode: explicit logMode beats the legacy sessionLog field', () => { + // Once a user picks a mode in 3.3.35+, the legacy boolean must NOT override. + assert.equal(normalizeLogMode({ logMode: 'session', sessionLog: true }), 'session'); + assert.equal(normalizeLogMode({ logMode: 'single', sessionLog: true }), 'single'); +}); + +test('normalizeLogMode: invalid logMode strings fall through to single (or legacy if present)', () => { + assert.equal(normalizeLogMode({ logMode: 'lolnope' }), 'single'); + assert.equal(normalizeLogMode({ logMode: '' }), 'single'); + assert.equal(normalizeLogMode({ logMode: 'lolnope', sessionLog: true }), 'daily'); +}); + +// --- resolveLogFileName --- + +test('resolveLogFileName: single mode → bare basename + ext', () => { + assert.equal( + resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'single' }), + 'fileuploader.log' + ); +}); + +test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => { + const d = new Date(2026, 4, 28); // May 28, 2026 — month is 0-indexed + assert.equal( + resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'daily', date: d }), + 'fileuploader-2026-05-28.log' + ); +}); + +test('resolveLogFileName: session mode → fileuploader-session-.log', () => { + assert.equal( + resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }), + 'fileuploader-session-2026-05-28_22-44-52-12345.log' + ); +}); + +test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => { + assert.equal( + resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }), + 'fileuploader.log' + ); +}); + +test('resolveLogFileName: unknown mode is treated as single', () => { + assert.equal( + resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'lolnope' }), + 'fileuploader.log' + ); +}); + +// --- format helpers --- + +test('formatDateStamp: zero-pads month and day', () => { + assert.equal(formatDateStamp(new Date(2026, 0, 3)), '2026-01-03'); + assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31'); +}); + +test('formatSessionStamp: produces YYYY-MM-DD_HH-MM-SS-pid', () => { + const d = new Date(2026, 4, 28, 7, 9, 5); + assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345'); +}); + +test('formatSessionStamp: omits the pid suffix when none provided', () => { + const d = new Date(2026, 4, 28, 22, 44, 52); + assert.equal(formatSessionStamp(d), '2026-05-28_22-44-52'); +});