UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

576 lines • 29.8 kB
import path from 'node:path'; import { Semaphore } from 'async-mutex'; import { ProgressBarSymbol } from '../../console/progressBar.js'; import ElasticSemaphore from '../../elasticSemaphore.js'; import Defaults from '../../globals/defaults.js'; import KeyedMutex from '../../keyedMutex.js'; import ArrayPoly from '../../polyfill/arrayPoly.js'; import FsPoly, { MoveResult } from '../../polyfill/fsPoly.js'; import ArchiveEntry from '../../types/files/archives/archiveEntry.js'; import Zip from '../../types/files/archives/zip.js'; import File from '../../types/files/file.js'; import { ChecksumBitmask } from '../../types/files/fileChecksums.js'; import Module from '../module.js'; /** * Copy or move output ROM files, if applicable. */ class CandidateWriter extends Module { constructor(options, progressBar) { super(progressBar, CandidateWriter.name); this.filesQueuedForDeletion = []; this.options = options; // This will be the same value globally, but we can't know the value at file import time if (options.getWriterThreads() < CandidateWriter.THREAD_SEMAPHORE.getValue()) { CandidateWriter.THREAD_SEMAPHORE.setValue(options.getWriterThreads()); } } /** * Write & test candidates. */ async write(dat, candidates) { const writtenFiles = candidates.flatMap((candidate) => candidate.getRomsWithFiles().map((romWithFiles) => romWithFiles.getOutputFile())); if (candidates.length === 0) { return { wrote: writtenFiles, moved: [], }; } // Return early if we shouldn't write (are only reporting) if (!this.options.shouldWrite()) { return { wrote: writtenFiles, moved: [], }; } // Filter to only the candidates that actually have matched files (and therefore output) const writableCandidates = candidates.filter((candidate) => candidate.getRomsWithFiles().length > 0); this.progressBar.logTrace(`${dat.getName()}: writing ${writableCandidates.length.toLocaleString()} candidate${writableCandidates.length === 1 ? '' : 's'}`); if (this.options.shouldTest() && !this.options.getOverwrite()) { this.progressBar.setSymbol(ProgressBarSymbol.TESTING); } else { this.progressBar.setSymbol(ProgressBarSymbol.WRITING); } this.progressBar.reset(writableCandidates.length); await Promise.all(writableCandidates.map(async (candidate) => CandidateWriter.THREAD_SEMAPHORE.runExclusive(async () => { this.progressBar.incrementProgress(); this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: writing candidate`); await this.writeCandidate(dat, candidate); this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: done writing candidate`); this.progressBar.incrementDone(); }))); this.progressBar.logTrace(`${dat.getName()}: done writing ${writableCandidates.length.toLocaleString()} candidate${writableCandidates.length === 1 ? '' : 's'}`); const writtenFilePaths = new Set(writtenFiles.map((writtenFile) => writtenFile.getFilePath())); const movedFiles = this.filesQueuedForDeletion // Files that were written should not be eligible for move deletion. This protects against // the same directory being used for both an input and output directory. .filter((fileQueued) => !writtenFilePaths.has(fileQueued.getFilePath())); return { wrote: writtenFiles, moved: movedFiles, }; } async writeCandidate(dat, candidate) { const writeNeeded = candidate .getRomsWithFiles() .some((romWithFiles) => !romWithFiles.getOutputFile().equals(romWithFiles.getInputFile())); if (!writeNeeded) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: input and output files are the same, skipping`); return; } const totalKilobytes = candidate .getRomsWithFiles() .reduce((sum, romWithFiles) => sum + romWithFiles.getInputFile().getSize(), 0) / 1024; await CandidateWriter.FILESIZE_SEMAPHORE.runExclusive(async () => { const waitingMessage = `${candidate.getName()} ...`; this.progressBar.addWaitingMessage(waitingMessage); if (this.options.shouldLink()) { await this.writeLink(dat, candidate); } else { await this.writeZip(dat, candidate); await this.writeRaw(dat, candidate); } this.progressBar.removeWaitingMessage(waitingMessage); }, totalKilobytes); } static async ensureOutputDirExists(outputFilePath) { const outputDir = path.dirname(outputFilePath); if (!(await FsPoly.exists(outputDir))) { await FsPoly.mkdir(outputDir, { recursive: true }); } } /** *********************** * * Zip Writing * * *********************** */ async writeZip(dat, candidate) { // Return no files if there are none to write const inputToOutputZipEntries = candidate .getRomsWithFiles() .filter((romWithFiles) => romWithFiles.getOutputFile() instanceof ArchiveEntry) .map((romWithFiles) => [ romWithFiles.getInputFile(), romWithFiles.getOutputFile(), ]); if (inputToOutputZipEntries.length === 0) { this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: no zip archives to write`); return; } // Prep the single output file const outputZip = inputToOutputZipEntries[0][1].getArchive(); // If the output file already exists, see if we need to do anything if (await FsPoly.exists(outputZip.getFilePath())) { if (!this.options.getOverwrite() && !this.options.getOverwriteInvalid()) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: not overwriting existing zip file`); return; } if (this.options.getOverwriteInvalid()) { const existingTest = await this.testZipContents(dat, candidate, outputZip.getFilePath(), inputToOutputZipEntries.map(([, outputEntry]) => outputEntry)); if (!existingTest) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: not overwriting existing zip file, existing zip has the expected contents`); return; } } } this.progressBar.setSymbol(ProgressBarSymbol.WRITING); let written = false; for (let i = 0; i <= this.options.getWriteRetry(); i += 1) { written = await this.writeZipFile(dat, candidate, outputZip, inputToOutputZipEntries); if (written && !this.options.shouldTest()) { // Successfully written, unknown if valid break; } if (written && this.options.shouldTest()) { const writtenTest = await this.testZipContents(dat, candidate, outputZip.getFilePath(), inputToOutputZipEntries.map((entry) => entry[1])); if (!writtenTest) { // Successfully validated break; } const message = `${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: written zip ${writtenTest}`; if (i < this.options.getWriteRetry()) { this.progressBar.logWarn(`${message}, retrying`); } else { this.progressBar.logError(message); return; // final error, do not continue } } } if (!written) { return; } inputToOutputZipEntries.forEach(([inputRomFile]) => { this.enqueueFileDeletion(inputRomFile); }); } async testZipContents(dat, candidate, zipFilePath, expectedArchiveEntries) { this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: ${zipFilePath}: testing zip`); const expectedEntriesByPath = expectedArchiveEntries.reduce((map, entry) => { map.set(entry.getEntryPath(), entry); return map; }, new Map()); const checksumBitmask = expectedArchiveEntries.reduce((bitmask, entry) => bitmask | entry.getChecksumBitmask(), ChecksumBitmask.CRC32); let archiveEntries; try { archiveEntries = await new Zip(zipFilePath).getArchiveEntries(checksumBitmask); } catch (error) { return `failed to get archive contents: ${error}`; } const actualEntriesByPath = archiveEntries.reduce((map, entry) => { map.set(entry.getEntryPath(), entry); return map; }, new Map()); if (actualEntriesByPath.size !== expectedEntriesByPath.size) { return `has ${actualEntriesByPath.size.toLocaleString()} files, expected ${expectedEntriesByPath.size.toLocaleString()}`; } for (const [entryPath, expectedFile] of expectedEntriesByPath.entries()) { // Check existence if (!actualEntriesByPath.has(entryPath)) { return `is missing the file ${entryPath}`; } // Check checksum const actualFile = actualEntriesByPath.get(entryPath); if (actualFile.getSha256() && expectedFile.getSha256() && actualFile.getSha256() !== expectedFile.getSha256()) { return `has the SHA256 ${actualFile.getSha256()}, expected ${expectedFile.getSha256()}`; } if (actualFile.getSha1() && expectedFile.getSha1() && actualFile.getSha1() !== expectedFile.getSha1()) { return `has the SHA1 ${actualFile.getSha1()}, expected ${expectedFile.getSha1()}`; } if (actualFile.getMd5() && expectedFile.getMd5() && actualFile.getMd5() !== expectedFile.getMd5()) { return `has the MD5 ${actualFile.getMd5()}, expected ${expectedFile.getMd5()}`; } if (actualFile.getCrc32() && expectedFile.getCrc32() && expectedFile.getCrc32() !== '00000000' && actualFile.getCrc32() !== expectedFile.getCrc32()) { return `has the CRC32 ${actualFile.getCrc32()}, expected ${expectedFile.getCrc32()}`; } // Check size if (actualFile.getCrc32() && expectedFile.getCrc32()) { if (!expectedFile.getSize()) { this.progressBar.logWarn(`${dat.getName()}: ${candidate.getName()}: ${expectedFile.toString()}: can't test, expected size is unknown`); continue; } if (actualFile.getSize() !== expectedFile.getSize()) { return `has the file ${entryPath} of size ${actualFile.getSize().toLocaleString()}B, expected ${expectedFile.getSize().toLocaleString()}B`; } } } this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: ${zipFilePath}: test passed`); return undefined; } async writeZipFile(dat, candidate, outputZip, inputToOutputZipEntries) { this.progressBar.logInfo(`${dat.getName()}: ${candidate.getName()}: creating zip archive '${outputZip.getFilePath()}' with the entries:\n${inputToOutputZipEntries.map(([input, output]) => ` '${input.toString()}' (${FsPoly.sizeReadable(input.getSize())}) → '${output.getEntryPath()}'`).join('\n')}`); try { await CandidateWriter.ensureOutputDirExists(outputZip.getFilePath()); await outputZip.createArchive(inputToOutputZipEntries); } catch (error) { this.progressBar.logError(`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: failed to create zip: ${error}`); return false; } this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: wrote ${inputToOutputZipEntries.length.toLocaleString()} archive entr${inputToOutputZipEntries.length === 1 ? 'y' : 'ies'}`); return true; } /** *********************** * * Raw Writing * * *********************** */ async writeRaw(dat, candidate) { const inputToOutputEntries = candidate .getRomsWithFiles() .filter((romWithFiles) => !(romWithFiles.getOutputFile() instanceof ArchiveEntry)) .map((romWithFiles) => [romWithFiles.getInputFile(), romWithFiles.getOutputFile()]); // Return no files if there are none to write if (inputToOutputEntries.length === 0) { // TODO(cemmer): unit test this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: no raw files to write`); return; } // De-duplicate based on the output file. Raw copying archives will produce the same // input->output for every ROM. const uniqueInputToOutputEntries = inputToOutputEntries.filter(ArrayPoly.filterUniqueMapped(([, outputRomFile]) => outputRomFile.toString())); const totalBytes = uniqueInputToOutputEntries .flatMap(([, outputFile]) => outputFile) .reduce((sum, file) => sum + file.getSize(), 0); this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: writing ${FsPoly.sizeReadable(totalBytes)} of ${uniqueInputToOutputEntries.length.toLocaleString()} file${uniqueInputToOutputEntries.length === 1 ? '' : 's'}`); // Group the input->output pairs by the input file's path. The goal is to extract entries from // the same input archive at the same time, to benefit from batch extraction. const uniqueInputToOutputEntriesMap = uniqueInputToOutputEntries.reduce((map, [inputRomFile, outputRomFile]) => { const key = inputRomFile.getFilePath(); if (map.has(key)) { map.get(key)?.push([inputRomFile, outputRomFile]); } else { map.set(key, [[inputRomFile, outputRomFile]]); } return map; }, new Map()); for (const groupedInputToOutput of uniqueInputToOutputEntriesMap.values()) { await Promise.all(groupedInputToOutput.map(async ([inputRomFile, outputRomFile]) => this.writeRawSingle(dat, candidate, inputRomFile, outputRomFile))); } } async writeRawSingle(dat, candidate, inputRomFile, outputRomFile) { // Input and output are the exact same, maybe do nothing if (outputRomFile.equals(inputRomFile)) { const wasMoved = this.options.shouldMove() && (await CandidateWriter.MOVE_MUTEX.runExclusiveForKey(inputRomFile.getFilePath(), () => CandidateWriter.FILE_PATH_MOVES.get(inputRomFile.getFilePath()))) !== undefined; if (!wasMoved) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${outputRomFile.toString()}: input and output file is the same, skipping`); return; } } const outputFilePath = outputRomFile.getFilePath(); // If the output file already exists, see if we need to do anything if (!this.options.getOverwrite() && (await FsPoly.exists(outputFilePath))) { if (!this.options.getOverwrite() && !this.options.getOverwriteInvalid()) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: not overwriting existing file`); return; } if (this.options.getOverwriteInvalid()) { const existingTest = await this.testWrittenRaw(dat, candidate, outputFilePath, outputRomFile); if (!existingTest) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: not overwriting existing file, existing file is what was expected`); return; } } } this.progressBar.setSymbol(ProgressBarSymbol.WRITING); let written; for (let i = 0; i <= this.options.getWriteRetry(); i += 1) { if (this.options.shouldMove()) { written = await this.moveRawFile(dat, candidate, inputRomFile, outputFilePath); } else { written = await this.copyRawFile(dat, candidate, inputRomFile, outputFilePath); } if (written !== undefined && !this.options.shouldTest()) { // Successfully written, unknown if valid break; } if (written === MoveResult.COPIED && this.options.shouldTest()) { // Only test the output file if it was copied, we don't need to test the file if it was // just renamed const writtenTest = await this.testWrittenRaw(dat, candidate, outputFilePath, outputRomFile); if (!writtenTest) { // Successfully validated break; } const message = `${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: written file ${writtenTest}`; if (i < this.options.getWriteRetry()) { this.progressBar.logWarn(`${message}, retrying`); } else { this.progressBar.logError(message); return; // final error, do not continue } } } if (!written) { return; } this.enqueueFileDeletion(inputRomFile); } async moveRawFile(dat, candidate, inputRomFile, outputFilePath) { // Lock the input file, we can't handle concurrent moves return CandidateWriter.MOVE_MUTEX.runExclusiveForKey(inputRomFile.getFilePath(), async () => { const movedFilePath = CandidateWriter.FILE_PATH_MOVES.get(inputRomFile.getFilePath()); if (movedFilePath) { // The file was already moved, we shouldn't move it again return this.copyRawFile(dat, candidate, inputRomFile.withFilePath(movedFilePath), outputFilePath); } if (inputRomFile instanceof ArchiveEntry || inputRomFile.getFileHeader() !== undefined || inputRomFile.getPatch() !== undefined) { // The file can't be moved as-is, it needs to get copied return this.copyRawFile(dat, candidate, inputRomFile, outputFilePath); } this.progressBar.logInfo(`${dat.getName()}: ${candidate.getName()}: moving file '${inputRomFile.toString()}' (${FsPoly.sizeReadable(inputRomFile.getSize())}) → '${outputFilePath}'`); try { await CandidateWriter.ensureOutputDirExists(outputFilePath); const moveResult = await FsPoly.mv(inputRomFile.getFilePath(), outputFilePath); CandidateWriter.FILE_PATH_MOVES.set(inputRomFile.getFilePath(), outputFilePath); return moveResult; } catch (error) { this.progressBar.logError(`${dat.getName()}: ${candidate.getName()}: failed to move file '${inputRomFile.toString()}' → '${outputFilePath}': ${error}`); return undefined; } }); } async copyRawFile(dat, candidate, inputRomFile, outputFilePath) { this.progressBar.logInfo(`${dat.getName()}: ${candidate.getName()}: ${inputRomFile instanceof ArchiveEntry ? 'extracting' : 'copying'} file '${inputRomFile.toString()}' (${FsPoly.sizeReadable(inputRomFile.getSize())}) → '${outputFilePath}'`); try { await CandidateWriter.ensureOutputDirExists(outputFilePath); const tempRawFile = await FsPoly.mktemp(outputFilePath); await inputRomFile.extractAndPatchToFile(tempRawFile); await FsPoly.mv(tempRawFile, outputFilePath); return MoveResult.COPIED; } catch (error) { this.progressBar.logError(`${dat.getName()}: ${candidate.getName()}: failed to ${inputRomFile instanceof ArchiveEntry ? 'extract' : 'copy'} file '${inputRomFile.toString()}' → '${outputFilePath}': ${error}`); return undefined; } } async testWrittenRaw(dat, candidate, outputFilePath, expectedFile) { this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: testing raw file`); // Check checksum let actualFile; try { actualFile = await File.fileOf({ filePath: outputFilePath }, expectedFile.getChecksumBitmask()); } catch (error) { return `failed to parse: ${error}`; } if (actualFile.getSha256() && expectedFile.getSha256() && actualFile.getSha256() !== expectedFile.getSha256()) { return `has the SHA256 ${actualFile.getSha256()}, expected ${expectedFile.getSha256()}`; } if (actualFile.getSha1() && expectedFile.getSha1() && actualFile.getSha1() !== expectedFile.getSha1()) { return `has the SHA1 ${actualFile.getSha1()}, expected ${expectedFile.getSha1()}`; } if (actualFile.getMd5() && expectedFile.getMd5() && actualFile.getMd5() !== expectedFile.getMd5()) { return `has the MD5 ${actualFile.getMd5()}, expected ${expectedFile.getMd5()}`; } if (actualFile.getCrc32() && expectedFile.getCrc32() && expectedFile.getCrc32() !== '00000000' && actualFile.getCrc32() !== expectedFile.getCrc32()) { return `has the CRC32 ${actualFile.getCrc32()}, expected ${expectedFile.getCrc32()}`; } // Check size if (actualFile.getCrc32()) { if (actualFile.getCrc32() && !expectedFile.getSize()) { this.progressBar.logWarn(`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: can't test, expected size is unknown`); return undefined; } if (actualFile.getSize() !== expectedFile.getSize()) { return `is of size ${actualFile.getSize().toLocaleString()}B, expected ${expectedFile.getSize().toLocaleString()}B`; } } this.progressBar.logTrace(`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: test passed`); return undefined; } // Input files may be needed for multiple output files, such as an archive with hundreds of ROMs // in it. That means we need to "move" (delete) files at the very end after all DATs have // finished writing. enqueueFileDeletion(inputRomFile) { if (!this.options.shouldMove()) { return; } this.filesQueuedForDeletion.push(inputRomFile); } /** ************************ * * Link Writing * * ************************ */ async writeLink(dat, candidate) { const inputToOutputEntries = candidate.getRomsWithFiles(); for (const inputToOutputEntry of inputToOutputEntries) { const inputRomFile = inputToOutputEntry.getInputFile(); const outputRomFile = inputToOutputEntry.getOutputFile(); await this.writeLinkSingle(dat, candidate, inputRomFile, outputRomFile); } } async writeLinkSingle(dat, candidate, inputRomFile, outputRomFile) { // Input and output are the exact same, do nothing if (outputRomFile.equals(inputRomFile)) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${outputRomFile.toString()}: input and output file is the same, skipping`); return; } const linkPath = outputRomFile.getFilePath(); let targetPath = path.resolve(inputRomFile.getFilePath()); if (this.options.getSymlink() && this.options.getSymlinkRelative()) { await CandidateWriter.ensureOutputDirExists(linkPath); targetPath = await FsPoly.symlinkRelativePath(targetPath, linkPath); } // If the output file already exists, see if we need to do anything if (await FsPoly.exists(linkPath)) { if (!this.options.getOverwrite() && !this.options.getOverwriteInvalid()) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${linkPath}: not overwriting existing file`); return; } if (this.options.getOverwriteInvalid()) { let existingTest; if (this.options.getSymlink()) { existingTest = await CandidateWriter.testWrittenSymlink(linkPath, targetPath); } else { existingTest = await CandidateWriter.testWrittenHardlink(linkPath, inputRomFile.getFilePath()); } if (!existingTest) { this.progressBar.logDebug(`${dat.getName()}: ${candidate.getName()}: ${linkPath}: not overwriting existing link, existing link is what was expected`); return; } } await FsPoly.rm(linkPath, { force: true }); } this.progressBar.setSymbol(ProgressBarSymbol.WRITING); for (let i = 0; i <= this.options.getWriteRetry(); i += 1) { const written = await this.writeRawLink(dat, candidate, targetPath, linkPath); if (written && !this.options.shouldTest()) { // Successfully written, unknown if valid break; } if (written && this.options.shouldTest()) { let writtenTest; if (this.options.getSymlink()) { writtenTest = await CandidateWriter.testWrittenSymlink(linkPath, targetPath); } else { writtenTest = await CandidateWriter.testWrittenHardlink(linkPath, inputRomFile.getFilePath()); } if (!writtenTest) { // Successfully validated break; } const message = `${dat.getName()}: ${candidate.getName()} ${linkPath}: written link ${writtenTest}`; if (i < this.options.getWriteRetry()) { this.progressBar.logWarn(`${message}, retrying`); } else { this.progressBar.logError(message); return; // final error, do not continue } } } } async writeRawLink(dat, candidate, targetPath, linkPath) { try { await CandidateWriter.ensureOutputDirExists(linkPath); if (this.options.getSymlink()) { this.progressBar.logInfo(`${dat.getName()}: ${candidate.getName()}: creating symlink '${targetPath}' → '${linkPath}'`); await FsPoly.symlink(targetPath, linkPath); } else { this.progressBar.logInfo(`${dat.getName()}: ${candidate.getName()}: creating hard link '${targetPath}' → '${linkPath}'`); await FsPoly.hardlink(targetPath, linkPath); } return true; } catch (error) { this.progressBar.logError(`${dat.getName()}: ${candidate.getName()}: ${linkPath}: failed to link from ${targetPath}: ${error}`); return false; } } static async testWrittenSymlink(linkPath, expectedTargetPath) { if (!(await FsPoly.exists(linkPath))) { return "doesn't exist"; } if (!(await FsPoly.isSymlink(linkPath))) { return 'is not a symlink'; } const existingSourcePath = await FsPoly.readlink(linkPath); if (path.normalize(existingSourcePath) !== path.normalize(expectedTargetPath)) { return `has the target path '${existingSourcePath}', expected '${expectedTargetPath}`; } if (!(await FsPoly.exists(await FsPoly.readlinkResolved(linkPath)))) { return `has the target path '${existingSourcePath}' which doesn't exist`; } return undefined; } static async testWrittenHardlink(linkPath, inputRomPath) { if (!(await FsPoly.exists(linkPath))) { return "doesn't exist"; } const targetInode = await FsPoly.inode(linkPath); const sourceInode = await FsPoly.inode(inputRomPath); if (targetInode !== sourceInode) { return `references a different file than '${inputRomPath}'`; } return undefined; } } // The maximum number of candidates that can be written at once CandidateWriter.THREAD_SEMAPHORE = new Semaphore(Number.MAX_SAFE_INTEGER); // WARN(cemmer): there is an undocumented semaphore max value that can be used, the full // 4,700,372,992 bytes of a DVD+R will cause runExclusive() to never run or return. CandidateWriter.FILESIZE_SEMAPHORE = new ElasticSemaphore(Defaults.MAX_READ_WRITE_CONCURRENT_KILOBYTES); // When moving input files, process input file paths exclusively CandidateWriter.MOVE_MUTEX = new KeyedMutex(1000); // When moving input files, keep track of files that have been moved CandidateWriter.FILE_PATH_MOVES = new Map(); export default CandidateWriter; //# sourceMappingURL=candidateWriter.js.map