diff --git a/lib/log-mode.js b/lib/log-mode.js index b3b876c..2bb7987 100644 --- a/lib/log-mode.js +++ b/lib/log-mode.js @@ -75,7 +75,29 @@ return `${base}${ext}`; } - const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, VALID_MODES }; + /** + * Reverse of resolveLogFileName: given a full filename like + * "fileuploader-2026-06-03.log" or + * "fileuploader-session-2026-06-03_18-16-20-8132.log", strip the mode-stamp + * so the bare base ("fileuploader.log") remains. Used when persisting an + * auto-resolved fallback path back into config — otherwise the saved path + * would keep growing a new stamp on every reload. + */ + function stripModeStampFromFileName(fileName) { + if (!fileName || typeof fileName !== 'string') return fileName; + // Order matters: session first (longer, more specific) before daily. + // Both regexes are anchored to $ with no nested/ambiguous quantifiers, so + // matching is linear — the eslint security warning is precautionary. + // eslint-disable-next-line security/detect-unsafe-regex + const sessionRe = /-session-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/; + // eslint-disable-next-line security/detect-unsafe-regex + const dailyRe = /-\d{4}-\d{2}-\d{2}(\.[^.]+)?$/; + let out = fileName.replace(sessionRe, (m, ext) => ext || ''); + out = out.replace(dailyRe, (m, ext) => ext || ''); + return out; + } + + const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, VALID_MODES }; if (typeof module !== 'undefined' && module.exports) { module.exports = api; diff --git a/main.js b/main.js index 4b0b8b4..e28f245 100644 --- a/main.js +++ b/main.js @@ -245,7 +245,7 @@ function getBaseLogFilePath() { // 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 { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = 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; @@ -398,16 +398,18 @@ function _persistFallbackLogPath(workingPath) { try { const cfg = configStore.load(); const gs = cfg.globalSettings || {}; - // If daily-log is on, workingPath has a date suffix (fileuploader-YYYY-MM-DD.log). - // Strip that before saving so the base path rolls forward to tomorrow's - // file correctly — otherwise the next day's getLogFilePath would append - // another date onto the already-dated base. + const mode = gs.logMode || 'single'; + // Strip the mode-specific suffix so logFilePath stores the BARE base path. + // Otherwise daily would compound into "...-2026-06-03-2026-06-04.log" and + // session would compound a second session-stamp onto the first — which split + // a session's lines across two files (the first few before _persistFallback + // ran, the rest after, into the doubly-stamped path). gated on logMode (the + // legacy `sessionLog` field is no longer the source of truth). let toSave = workingPath; - if (gs.sessionLog) { + if (mode === 'daily' || mode === 'session') { const dir = path.dirname(workingPath); const base = path.basename(workingPath); - const stripped = base.replace(/-\d{4}-\d{2}-\d{2}(\.[^.]+)$/, '$1'); - toSave = path.join(dir, stripped); + toSave = path.join(dir, stripModeStampFromFileName(base)); } if (gs.logFilePath === toSave) return; gs.logFilePath = toSave; diff --git a/tests/log-mode.test.js b/tests/log-mode.test.js index b686afc..86cf29f 100644 --- a/tests/log-mode.test.js +++ b/tests/log-mode.test.js @@ -78,6 +78,50 @@ test('resolveLogFileName: unknown mode is treated as single', () => { ); }); +// --- stripModeStampFromFileName --- + +const { stripModeStampFromFileName } = require('../lib/log-mode'); + +test('stripModeStampFromFileName: leaves bare names alone', () => { + assert.equal(stripModeStampFromFileName('fileuploader.log'), 'fileuploader.log'); + assert.equal(stripModeStampFromFileName('fileuploader'), 'fileuploader'); +}); + +test('stripModeStampFromFileName: strips a daily YYYY-MM-DD suffix', () => { + assert.equal(stripModeStampFromFileName('fileuploader-2026-06-03.log'), 'fileuploader.log'); +}); + +test('stripModeStampFromFileName: strips a session-stamp suffix (with and without pid)', () => { + assert.equal( + stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20-8132.log'), + 'fileuploader.log' + ); + assert.equal( + stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20.log'), + 'fileuploader.log' + ); +}); + +test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => { + // This is the exact bug shape: persist the resolved path, then on next call + // re-resolve from the saved base — must produce the same file, not a doubled + // session-stamped one. The fix is the strip; this test guards against + // regressing _persistFallbackLogPath into the 3.3.35 bug. + const sessionId = '2026-06-03_18-16-20-8132'; + const dailyDate = new Date(2026, 5, 3); + for (const mode of ['daily', 'session']) { + const date = mode === 'daily' ? dailyDate : new Date(); + const initial = resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date, sessionId }); + const stripped = stripModeStampFromFileName(initial); + // After strip, the base should be back to the bare name. + assert.equal(stripped, 'fileuploader.log', `${mode}: strip should produce bare base`); + // Re-resolving from the bare base gives the same final filename — no doubling. + const reBase = stripped.replace(/\.log$/, ''); + const second = resolveLogFileName({ baseName: reBase, ext: '.log', mode, date, sessionId }); + assert.equal(second, initial, `${mode}: round-trip must be idempotent`); + } +}); + // --- format helpers --- test('formatDateStamp: zero-pads month and day', () => {