Files
Multi-Hoster-Upload/lib/import-preflight.js
T
Sucukdeluxe d9c66fe20f
CI / verify (push) Canceled after 0s
fix: make automation completion evidence durable
Persist successful watched-file uploads in a dedicated fsync-backed ledger before exposing completion to the renderer. Match entries by normalized full path, hoster, size, and modification time so restart reconciliation skips unchanged completed files while changed files and explicit manual retries remain available.

Remove restored queue ghosts from the ledger even when history and user upload logging are unavailable. Preserve per-hoster partial completion, capture missing file metadata asynchronously, fail closed on corrupted or unwritable evidence, and keep local persistence failures outside automatic upload retries.

Stream managed upload logs with bounded lines, bytes, files, directories, and result counts. Include numbered rotations, reject unconfirmed rows, share concurrent scans through a generation-safe cache, invalidate after successful appends, avoid synchronous configuration and directory reads, and close streams on every path.
2026-08-27 15:14:43 +02:00

179 lines
7.0 KiB
JavaScript

(function initImportPreflight(root, factory) {
const api = typeof module === 'object' && module.exports
? factory(require('path'))
: factory({
normalize: value => String(value).replace(/[\\/]+/g, '/'),
basename: value => String(value).split(/[\\/]/).pop() || ''
});
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.ImportPreflight = api;
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path) {
function normalizePathValue(value) {
let text = String(value ?? '').trim();
if (!text) return '';
const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i);
if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`;
else {
const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/);
if (driveNamespace) text = text.slice(driveNamespace[0].length);
}
return path.normalize(text);
}
function normalizeEntry(value) {
const source = value && typeof value === 'object' ? value : {};
const filePath = normalizePathValue(typeof value === 'string' ? value : source.path);
const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim();
return {
path: filePath,
name: sourceName || path.basename(filePath),
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null,
mtimeMs: Number.isFinite(Number(source.mtimeMs)) ? Number(source.mtimeMs) : null
};
}
function createPathKey(value, caseInsensitive) {
const normalized = normalizePathValue(value);
return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized;
}
async function mapWithConcurrency(items, concurrency, operation) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await operation(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
function unavailableReason(result) {
if (!result || result.exists === false) return 'missing';
if (result.readable === false) return 'unreadable';
const size = Number(result.size);
if (!Number.isFinite(size) || size <= 0) return 'empty';
return '';
}
async function inspectReadableImportPath(filePath, openPath) {
let fileHandle = null;
try {
fileHandle = await openPath(filePath, 'r');
const fileStat = await fileHandle.stat();
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size, mtimeMs: fileStat.mtimeMs };
return { exists: true, readable: true, size: fileStat.size, mtimeMs: fileStat.mtimeMs };
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
return { exists: true, readable: false };
} finally {
if (fileHandle) {
try {
await fileHandle.close();
} catch {}
}
}
}
async function inspectImportEntries(entries, options = {}) {
const input = Array.isArray(entries) ? entries : [];
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : [])
.map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive))
.filter(Boolean));
const duplicates = [];
const unavailable = [];
const unique = [];
for (const value of input) {
const entry = normalizeEntry(value);
if (!entry.path) {
unavailable.push({ ...entry, reason: 'missing' });
continue;
}
const key = createPathKey(entry.path, caseInsensitive);
if (existing.has(key)) {
duplicates.push(entry);
continue;
}
existing.add(key);
unique.push(entry);
}
const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8));
const inspectPath = typeof options.inspectPath === 'function'
? options.inspectPath
: async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size });
const inspected = await mapWithConcurrency(unique, concurrency, async entry => {
try {
const result = await inspectPath(entry.path, entry);
const reason = unavailableReason(result);
const mtimeMs = Number.isFinite(Number(result?.mtimeMs)) ? Number(result.mtimeMs) : entry.mtimeMs;
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0, mtimeMs }, reason };
return { entry: { ...entry, size: Number(result.size), mtimeMs }, reason: '' };
} catch (error) {
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
}
});
const accepted = [];
for (const result of inspected) {
if (result.reason) unavailable.push({ ...result.entry, reason: result.reason });
else accepted.push(result.entry);
}
return {
candidateCount: input.length,
duplicateCount: duplicates.length,
unavailableCount: unavailable.length,
acceptedCount: accepted.length,
accepted,
duplicates,
unavailable
};
}
function normalizeSelectedHosters(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map(value => String(value ?? '').trim())
.filter(Boolean)));
}
function isImportPairEligible(file, hoster, hosterSettings = {}) {
const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb);
return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024);
}
function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) {
return normalizeSelectedHosters(selectedHosters)
.filter(hoster => isImportPairEligible(file, hoster, hosterSettings));
}
function summarizeImportPlan(input = {}) {
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
const selectedHosters = normalizeSelectedHosters(input.selectedHosters);
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
let jobCount = 0;
for (const file of accepted) jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
return {
candidateCount: Number(inspection.candidateCount) || 0,
duplicateCount: Number(inspection.duplicateCount) || 0,
unavailableCount: Number(inspection.unavailableCount) || 0,
acceptedCount: accepted.length,
targetCount: selectedHosters.length,
jobCount,
sizeLimitedJobCount: accepted.length * selectedHosters.length - jobCount
};
}
return {
getEligibleImportHosters,
inspectImportEntries,
inspectReadableImportPath,
isImportPairEligible,
summarizeImportPlan
};
});