diff --git a/lib/log-mode.js b/lib/log-mode.js index 81f4e57..42a61cc 100644 --- a/lib/log-mode.js +++ b/lib/log-mode.js @@ -97,7 +97,21 @@ return out; } - const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, VALID_MODES }; + function isManagedUploadLogFileName(fileName, args) { + const value = String(fileName || ''); + const options = args && typeof args === 'object' ? args : {}; + const baseName = String(options.baseName || 'fileuploader'); + const ext = String(options.ext || '.log'); + const normalizedValue = value.toLowerCase(); + const normalizedBaseName = baseName.toLowerCase(); + const normalizedExt = ext.toLowerCase(); + if (!normalizedExt || !normalizedValue.endsWith(normalizedExt)) return false; + if (stripModeStampFromFileName(normalizedValue) === `${normalizedBaseName}${normalizedExt}`) return true; + const stem = normalizedValue.slice(0, -normalizedExt.length); + return /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?$/.test(stem); + } + + const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, isManagedUploadLogFileName, VALID_MODES }; if (typeof module !== 'undefined' && module.exports) { module.exports = api; diff --git a/main.js b/main.js index 88e8c64..636dcbe 100644 --- a/main.js +++ b/main.js @@ -813,7 +813,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. A 6-digit random is // appended as a cheap hedge against same-minute restart collisions. -const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode'); +const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName, isManagedUploadLogFileName } = require('./lib/log-mode'); const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000))); let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly let _activeLogPath = null; @@ -3015,37 +3015,50 @@ ipcMain.handle('online-backup:restore', async (_event, key) => { } }); -ipcMain.handle('read-own-upload-log', () => { - // Read all log files (base + daily logs) and return parsed entries - const entries = []; +ipcMain.handle('read-own-upload-log', async () => { + const entries = new Map(); const basePath = getBaseLogFilePath(); const dir = path.dirname(basePath); const ext = path.extname(basePath); const name = path.basename(basePath, ext); - // Collect all matching log files (base + daily variants) - const logFiles = []; - try { - for (const file of fs.readdirSync(dir)) { - if (file.startsWith(name) && file.endsWith(ext)) { - logFiles.push(path.join(dir, file)); - } - } - } catch {} - if (logFiles.length === 0 && fs.existsSync(basePath)) { - logFiles.push(basePath); - } - - for (const logPath of logFiles) { + const activeTarget = _resolveUploadLogTarget(); + const directories = new Set([dir]); + if (activeTarget?.path) directories.add(path.dirname(activeTarget.path)); + const desktop = getSafeDesktopDir(); + if (desktop) directories.add(desktop); + try { directories.add(app.getPath('userData')); } catch {} + const logFiles = new Set(); + for (const directory of directories) { try { - const content = fs.readFileSync(logPath, 'utf-8'); - for (const line of content.split('\n')) { - const parsed = parseUploadLogLine(line); - if (parsed) entries.push(parsed); + for (const file of fs.readdirSync(directory)) { + if ( + isManagedUploadLogFileName(file, { baseName: name, ext }) + || isManagedUploadLogFileName(file, { baseName: 'fileuploader', ext: '.log' }) + ) { + logFiles.add(path.join(directory, file)); + } } } catch {} } - return entries; + if (activeTarget?.path && fs.existsSync(activeTarget.path)) logFiles.add(activeTarget.path); + if (fs.existsSync(basePath)) logFiles.add(basePath); + + for (const logPath of logFiles) { + try { + const content = await fs.promises.readFile(logPath, 'utf-8'); + for (const line of content.split('\n')) { + const parsed = parseUploadLogLine(line); + if (!parsed) continue; + const key = `${parsed.hoster.toLowerCase()}\u0000${parsed.fileName.toLowerCase()}`; + const previous = entries.get(key); + const timestamp = Number.isFinite(parsed.ts) ? parsed.ts : -Infinity; + const previousTimestamp = Number.isFinite(previous?.ts) ? previous.ts : -Infinity; + if (!previous || timestamp >= previousTimestamp) entries.set(key, parsed); + } + } catch {} + } + return [...entries.values()]; }); ipcMain.handle('import-upload-log', async () => { diff --git a/renderer/app.js b/renderer/app.js index 1b0af66..0b5ee96 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -599,9 +599,13 @@ async function evaluateAutomationCandidates(files, options = {}) { .map(value => String(value || '').trim()) .filter(Boolean))); const { history, uploadLog } = options.evidenceSnapshot || await loadAutomationEvidenceSnapshot(); + const completedPaths = [..._completedUploadKeys].map(key => { + const separator = key.lastIndexOf('|'); + return separator > 0 ? key.slice(0, separator) : ''; + }).filter(Boolean); const processed = window.AutomationControl.classifyProcessedCandidates({ candidates: matched, - queuePaths: [...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path)], + queuePaths: [...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path), ...completedPaths], historyRows: flattenAutomationHistoryRows(history), uploadLogRows: uploadLog }); diff --git a/tests/log-mode.test.js b/tests/log-mode.test.js index 4becf1e..0899f3d 100644 --- a/tests/log-mode.test.js +++ b/tests/log-mode.test.js @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode'); +const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, isManagedUploadLogFileName } = require('../lib/log-mode'); // --- normalizeLogMode --- @@ -88,6 +88,21 @@ test('resolveLogFileName: unknown mode is treated as single', () => { ); }); +test('managed upload-log discovery includes session logs and excludes unrelated logs', () => { + assert.equal(typeof isManagedUploadLogFileName, 'function'); + const options = { baseName: 'fileuploader', ext: '.log' }; + assert.equal(isManagedUploadLogFileName('fileuploader.log', options), true); + assert.equal(isManagedUploadLogFileName('fileuploader-2026-08-27.log', options), true); + assert.equal(isManagedUploadLogFileName('fileuploader-session-2026-08-27_05-40-59-1234.log', options), true); + assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.log', options), true); + assert.equal(isManagedUploadLogFileName('FILEUPLOADER-2026-08-27.LOG', options), true); + assert.equal(isManagedUploadLogFileName('27-08-2026-MDU-SESSION-05-40-599797.LOG', options), true); + assert.equal(isManagedUploadLogFileName('upload-audit.log', options), false); + assert.equal(isManagedUploadLogFileName('account-rotation.log', options), false); + assert.equal(isManagedUploadLogFileName('upload-debug.log', options), false); + assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.txt', options), false); +}); + // --- stripModeStampFromFileName --- const { stripModeStampFromFileName } = require('../lib/log-mode'); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 1fc7343..d002351 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -222,6 +222,57 @@ test('packages every Electron preload referenced by the main process', () => { assert.equal(packageJson.build.win.signAndEditExecutable, false); }); +test('read-own-upload-log discovers base daily session and both fallback directories without synchronous reads', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const blockStart = mainSource.indexOf("ipcMain.handle('read-own-upload-log'"); + const blockEnd = mainSource.indexOf("\nipcMain.handle('import-upload-log'", blockStart); + assert.notEqual(blockStart, -1); + assert.notEqual(blockEnd, -1); + const handlers = new Map(); + const configured = 'C:\\configured'; + const desktop = 'C:\\desktop'; + const userData = 'C:\\user-data'; + const entriesByDirectory = new Map([ + [configured, ['custom.txt', 'custom-2026-08-27.txt', '27-08-2026-mdu-session-05-40-111111.txt', 'upload-audit.log']], + [desktop, ['FILEUPLOADER-2026-08-26.LOG', '26-08-2026-MDU-SESSION-05-40-222222.LOG', 'account-rotation.log']], + [userData, ['fileuploader-2026-08-25.log', '25-08-2026-mdu-session-05-40-333333.log', 'upload-debug.log']] + ]); + const fileNames = new Map(); + for (const [directory, names] of entriesByDirectory) { + for (const name of names) { + if (name.includes('audit') || name.includes('rotation') || name.includes('debug')) continue; + fileNames.set(path.win32.join(directory, name), `${name}.mkv`); + } + } + const fakeFs = { + readdirSync: directory => entriesByDirectory.get(directory) || [], + existsSync: filePath => fileNames.has(filePath), + readFileSync: () => { throw new Error('synchronous read forbidden'); }, + promises: { + readFile: async filePath => require('../lib/upload-log').formatUploadLogLine( + new Date(2026, 7, 27, 5, 40, 0), + 'voe.sx', + 'https://voe.sx/e/test', + fileNames.get(filePath) + ) + } + }; + vm.runInNewContext(mainSource.slice(blockStart, blockEnd), { + _resolveUploadLogTarget: () => ({ path: path.win32.join(configured, '27-08-2026-mdu-session-05-40-111111.txt') }), + app: { getPath: name => name === 'desktop' ? desktop : userData }, + fs: fakeFs, + getBaseLogFilePath: () => path.win32.join(configured, 'custom.txt'), + getSafeDesktopDir: () => desktop, + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + isManagedUploadLogFileName: require('../lib/log-mode').isManagedUploadLogFileName, + parseUploadLogLine: require('../lib/upload-log').parseUploadLogLine, + path: path.win32 + }); + + const entries = await handlers.get('read-own-upload-log')(); + assert.deepEqual([...entries.map(entry => entry.fileName)].sort(), [...fileNames.values()].sort()); +}); + test('exposes managed online backup operations through narrow IPC boundaries', () => { const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8'); const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 8d06c08..a573b55 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -685,6 +685,57 @@ contextBridge.exposeInMainWorld('api', { acceptedNames: historyEvaluation.candidates.map(file => file.name).sort(), resultingJobs: historyEvaluation.summary.resultingJobs }; + _completedUploadKeys.clear(); + const completedFile = { path: 'C:\\history\\completed-in-session.mkv', name: 'completed-in-session.mkv', size: 1 }; + config.globalSettings.removeFromQueueOnDone = true; + config.globalSettings.folderMonitor = { + enabled: true, + folderPath: 'C:\\history', + hosters: ['doodstream.com'], + autoStart: false, + queueLimitJobs: 15000, + paused: false + }; + const completedJob = { + id: 'completed-in-session', + file: completedFile.path, + fileName: completedFile.name, + hoster: 'doodstream.com', + status: 'queued', + bytesTotal: 1, + automationAdmission: true + }; + queueJobs = [completedJob]; + selectedFiles = []; + uploading = false; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false, history: [], uploadLog: [] }); + handleProgress({ + jobId: completedJob.id, + fileName: completedJob.fileName, + hoster: completedJob.hoster, + status: 'done', + bytesUploaded: 1, + bytesTotal: 1, + progress: 1, + result: { download_url: 'https://doodstream.com/d/completed-in-session' } + }); + _doneRemovalCoalescer?.drainSync(); + automationEvidenceSnapshotGeneration++; + automationEvidenceSnapshotCache = null; + const removedAfterDone = !queueJobs.some(job => job.id === completedJob.id); + const completedKeyPresent = _completedUploadKeys.has(completedFile.path + '|doodstream.com'); + const completedResult = await handleFolderMonitorFiles([completedFile]); + const completedProbe = await window.api.getAutomationProbeState(); + const completedEvidence = { + removedAfterDone, + completedKeyPresent, + admittedFiles: completedResult.admittedFiles.length, + matchingQueueJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(completedFile.path)).length, + startOrInjectCalls: completedProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length + }; + _completedUploadKeys.clear(); + config.globalSettings.removeFromQueueOnDone = false; config.globalSettings.folderMonitor = { enabled: true, folderPath: 'C:\\\\pending', @@ -2020,7 +2071,7 @@ contextBridge.exposeInMainWorld('api', { startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length }; - return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, evidenceSnapshotDrain, separatedEventEvidence, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, pauseMarker, resumeQueue, paused }; + return { dry, manualTest, historyEvidence, completedEvidence, pendingDedup, parallelAdmission, evidenceSnapshotDrain, separatedEventEvidence, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, pauseMarker, resumeQueue, paused }; })()`; const automationControlCenterScript = `(async () => { const waitFor = async predicate => { @@ -2799,6 +2850,13 @@ app.whenReady().then(async () => { acceptedNames: ['aborted.mkv', 'all-failed.mkv', 'error.mkv', 'same-name.mkv', 'same-name.mkv', 'skipped.mkv'], resultingJobs: 6 }); + assert.deepEqual(result.automationPipeline.completedEvidence, { + removedAfterDone: true, + completedKeyPresent: true, + admittedFiles: 0, + matchingQueueJobs: 0, + startOrInjectCalls: 0 + }); assert.deepEqual(result.automationPipeline.pendingDedup, { evaluatedNames: ['new.mkv'], pendingPaths: ['c:/pending/overlap.mkv'],