fix: reconcile native extraction outputs
Use the validated native archive plan to recognize exact files after successful WinRAR and 7-Zip runs even when progress output contains no parseable completion lines. Preserve extractor-specific rename targets across subst mappings, keep 7-Zip directory paths when RAR flat mode is active, and reject colliding RAR flat targets before extraction.
This commit is contained in:
+183
-14
@@ -2412,6 +2412,8 @@ export function parseNativeArchiveEntryList(command: string, output: string): st
|
|||||||
|
|
||||||
type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean };
|
type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean };
|
||||||
|
|
||||||
|
type NativeEntryPreflightResult = ExtractSpawnResult & { entries: NativeArchiveEntryCandidate[] };
|
||||||
|
|
||||||
function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] {
|
function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] {
|
||||||
const lines = String(output || "").split(/\r?\n/);
|
const lines = String(output || "").split(/\r?\n/);
|
||||||
if (isRarNativeCommand(command)) {
|
if (isRarNativeCommand(command)) {
|
||||||
@@ -2472,6 +2474,34 @@ function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeArchiveEntriesForOutputMode(
|
||||||
|
command: string,
|
||||||
|
entries: readonly NativeArchiveEntryCandidate[],
|
||||||
|
flatMode: boolean
|
||||||
|
): NativeArchiveEntryCandidate[] {
|
||||||
|
if (!flatMode || !isRarNativeCommand(command)) {
|
||||||
|
return [...entries];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !entry.isDirectory)
|
||||||
|
.map((entry) => ({
|
||||||
|
entryPath: path.posix.basename(entry.entryPath.replace(/\\/g, "/").replace(/\/$/, "")),
|
||||||
|
isDirectory: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNativeFlatArchiveEntryCandidates(
|
||||||
|
command: string,
|
||||||
|
entries: readonly string[],
|
||||||
|
targetDir: string
|
||||||
|
): void {
|
||||||
|
const candidates = entries.map((entryPath) => ({
|
||||||
|
entryPath,
|
||||||
|
isDirectory: /[\\/]$/.test(entryPath)
|
||||||
|
}));
|
||||||
|
validateNativeArchiveTargetPlan(nativeArchiveEntriesForOutputMode(command, candidates, true), targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchJvmOutputEvent(
|
function dispatchJvmOutputEvent(
|
||||||
state: JvmParseState,
|
state: JvmParseState,
|
||||||
onOutput: ((event: ExtractOutputEvent) => void) | undefined,
|
onOutput: ((event: ExtractOutputEvent) => void) | undefined,
|
||||||
@@ -2528,8 +2558,9 @@ async function runNativeEntryPreflight(
|
|||||||
targetDir: string,
|
targetDir: string,
|
||||||
password: string,
|
password: string,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
timeoutMs: number
|
timeoutMs: number,
|
||||||
): Promise<ExtractSpawnResult> {
|
flatMode: boolean
|
||||||
|
): Promise<NativeEntryPreflightResult> {
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
const result = await runExtractCommand(
|
const result = await runExtractCommand(
|
||||||
command,
|
command,
|
||||||
@@ -2539,7 +2570,7 @@ async function runNativeEntryPreflight(
|
|||||||
timeoutMs
|
timeoutMs
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return result;
|
return { ...result, entries: [] };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
|
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
|
||||||
@@ -2547,18 +2578,126 @@ async function runNativeEntryPreflight(
|
|||||||
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
||||||
}
|
}
|
||||||
validateNativeArchiveTargetPlan(entries, targetDir);
|
validateNativeArchiveTargetPlan(entries, targetDir);
|
||||||
return result;
|
if (flatMode && isRarNativeCommand(command)) {
|
||||||
|
validateNativeArchiveTargetPlan(nativeArchiveEntriesForOutputMode(command, entries, true), targetDir);
|
||||||
|
}
|
||||||
|
return { ...result, entries };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
missingCommand: false,
|
missingCommand: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
timedOut: false,
|
timedOut: false,
|
||||||
errorText: cleanErrorText(String(error))
|
errorText: cleanErrorText(String(error)),
|
||||||
|
entries: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function existingNativeOutputKeys(
|
||||||
|
command: string,
|
||||||
|
entries: readonly NativeArchiveEntryCandidate[],
|
||||||
|
targetDir: string,
|
||||||
|
conflictMode: ConflictMode,
|
||||||
|
flatMode: boolean
|
||||||
|
): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
|
const extractsFlat = flatMode && isRarNativeCommand(command);
|
||||||
|
for (const candidate of entries) {
|
||||||
|
if (candidate.isDirectory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const archiveEntryPath = candidate.entryPath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||||
|
const entryPath = extractsFlat ? path.posix.basename(archiveEntryPath) : archiveEntryPath;
|
||||||
|
const outputPath = path.resolve(targetDir, ...entryPath.split("/"));
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(outputPath);
|
||||||
|
if (stat.isFile() && !stat.isSymbolicLink()) {
|
||||||
|
keys.add(pathSetKey(outputPath));
|
||||||
|
if (mode === "rename") {
|
||||||
|
const pattern = nativeRenameOutputPattern(command, path.basename(outputPath));
|
||||||
|
for (const entry of fs.readdirSync(path.dirname(outputPath), { withFileTypes: true })) {
|
||||||
|
if (entry.isFile() && pattern.test(entry.name)) {
|
||||||
|
keys.add(pathSetKey(path.join(path.dirname(outputPath), entry.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeRenameOutputPattern(command: string, fileName: string): RegExp {
|
||||||
|
const parsed = path.parse(fileName);
|
||||||
|
const suffix = extractorCommandKind(command) === "seven_zip" ? "_\\d+" : "\\(\\d+\\)";
|
||||||
|
return new RegExp(`^${escapeRegex(parsed.name)}${suffix}${escapeRegex(parsed.ext)}$`, "i");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileNativeExtractOutputs(
|
||||||
|
command: string,
|
||||||
|
entries: readonly { entryPath: string; isDirectory: boolean }[],
|
||||||
|
archivePath: string,
|
||||||
|
targetDir: string,
|
||||||
|
conflictMode: ConflictMode,
|
||||||
|
existingBefore: ReadonlySet<string>,
|
||||||
|
flatMode = false
|
||||||
|
): ExtractOutputEvent[] {
|
||||||
|
validateNativeArchiveTargetPlan(entries, targetDir);
|
||||||
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
|
const extractsFlat = flatMode && isRarNativeCommand(command);
|
||||||
|
const scope = new PackageOutputScope([targetDir]);
|
||||||
|
const events: ExtractOutputEvent[] = [];
|
||||||
|
for (const candidate of entries) {
|
||||||
|
if (candidate.isDirectory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const archiveEntryPath = candidate.entryPath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||||
|
const entryPath = extractsFlat ? path.posix.basename(archiveEntryPath) : archiveEntryPath;
|
||||||
|
const baseOutputPath = path.resolve(targetDir, ...entryPath.split("/"));
|
||||||
|
const existed = existingBefore.has(pathSetKey(baseOutputPath));
|
||||||
|
let outputPath = baseOutputPath;
|
||||||
|
if (mode === "rename" && existed) {
|
||||||
|
const pattern = nativeRenameOutputPattern(command, path.basename(baseOutputPath));
|
||||||
|
let renamedCandidates: string[] = [];
|
||||||
|
try {
|
||||||
|
renamedCandidates = fs.readdirSync(path.dirname(baseOutputPath), { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
||||||
|
.map((entry) => path.join(path.dirname(baseOutputPath), entry.name))
|
||||||
|
.filter((candidatePath) => !existingBefore.has(pathSetKey(candidatePath)));
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
if (renamedCandidates.length !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[outputPath] = renamedCandidates;
|
||||||
|
}
|
||||||
|
const event: ExtractOutputEvent = {
|
||||||
|
version: 1,
|
||||||
|
archivePath: path.resolve(archivePath),
|
||||||
|
entryPath,
|
||||||
|
outputPath,
|
||||||
|
state: "complete",
|
||||||
|
disposition: mode === "rename" && existed
|
||||||
|
? "renamed"
|
||||||
|
: mode === "skip" && existed
|
||||||
|
? "skipped"
|
||||||
|
: mode === "overwrite" && existed
|
||||||
|
? "overwritten"
|
||||||
|
: "written"
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (event.disposition === "skipped" || scope.add(event)) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseNativeExtractOutput(
|
export function parseNativeExtractOutput(
|
||||||
command: string,
|
command: string,
|
||||||
line: string,
|
line: string,
|
||||||
@@ -2614,6 +2753,24 @@ export function parseNativeExtractOutput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function remapNativeSubstOutput(
|
||||||
|
event: ExtractOutputEvent,
|
||||||
|
effectiveTargetDir: string,
|
||||||
|
targetDir: string
|
||||||
|
): ExtractOutputEvent {
|
||||||
|
const relativePath = path.relative(path.resolve(effectiveTargetDir), path.resolve(event.outputPath));
|
||||||
|
if (!relativePath
|
||||||
|
|| relativePath === ".."
|
||||||
|
|| relativePath.startsWith(`..${path.sep}`)
|
||||||
|
|| path.isAbsolute(relativePath)) {
|
||||||
|
throw new Error(`Ungültiger subst-Ausgabepfad: ${event.outputPath}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
outputPath: path.resolve(targetDir, relativePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function failDaemonOutputCallback(req: DaemonRequest): void {
|
function failDaemonOutputCallback(req: DaemonRequest): void {
|
||||||
if (daemonCurrentRequest !== req || !req.parseState.outputError || req.terminationStarted) {
|
if (daemonCurrentRequest !== req || !req.parseState.outputError || req.terminationStarted) {
|
||||||
return;
|
return;
|
||||||
@@ -2695,12 +2852,20 @@ async function runExternalExtractInner(
|
|||||||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
||||||
let createErrorText = "";
|
let createErrorText = "";
|
||||||
let createErrorPassword = "";
|
let createErrorPassword = "";
|
||||||
const runNativeAttempt = async (args: string[], password: string): Promise<ExtractSpawnResult> => {
|
const runNativeAttempt = async (args: string[], password: string, flatMode = false): Promise<ExtractSpawnResult> => {
|
||||||
const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs);
|
const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs, flatMode);
|
||||||
if (!preflight.ok) {
|
if (!preflight.ok) {
|
||||||
return preflight;
|
return preflight;
|
||||||
}
|
}
|
||||||
const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput);
|
const existingBefore = existingNativeOutputKeys(command, preflight.entries, targetDir, conflictMode, flatMode);
|
||||||
|
const reportedOutputs = new Set<string>();
|
||||||
|
const emitNativeOutput = (event: ExtractOutputEvent): void => {
|
||||||
|
if (event.state === "complete" && event.disposition !== "skipped") {
|
||||||
|
reportedOutputs.add(pathSetKey(event.outputPath));
|
||||||
|
}
|
||||||
|
onOutput?.(event);
|
||||||
|
};
|
||||||
|
const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, emitNativeOutput);
|
||||||
const result = await runExtractCommand(command, args, (chunk) => {
|
const result = await runExtractCommand(command, args, (chunk) => {
|
||||||
outputs.push(chunk);
|
outputs.push(chunk);
|
||||||
const parsed = parseProgressPercent(chunk);
|
const parsed = parseProgressPercent(chunk);
|
||||||
@@ -2714,6 +2879,13 @@ async function runExternalExtractInner(
|
|||||||
}
|
}
|
||||||
}, signal, timeoutMs);
|
}, signal, timeoutMs);
|
||||||
outputs.finish(result.ok ? "complete" : "partial");
|
outputs.finish(result.ok ? "complete" : "partial");
|
||||||
|
if (result.ok) {
|
||||||
|
for (const event of reconcileNativeExtractOutputs(command, preflight.entries, archivePath, targetDir, conflictMode, existingBefore, flatMode)) {
|
||||||
|
if (!reportedOutputs.has(pathSetKey(event.outputPath))) {
|
||||||
|
onOutput?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2726,7 +2898,7 @@ async function runExternalExtractInner(
|
|||||||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||||
const result = await runNativeAttempt(args, password);
|
const result = await runNativeAttempt(args, password, true);
|
||||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||||
@@ -2824,7 +2996,7 @@ async function runExternalExtractInner(
|
|||||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||||
const result = await runNativeAttempt(args, password);
|
const result = await runNativeAttempt(args, password, true);
|
||||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||||
@@ -2935,10 +3107,7 @@ async function runExternalExtract(
|
|||||||
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||||
}
|
}
|
||||||
const legacyOnOutput = subst && onOutput
|
const legacyOnOutput = subst && onOutput
|
||||||
? (event: ExtractOutputEvent): void => onOutput({
|
? (event: ExtractOutputEvent): void => onOutput(remapNativeSubstOutput(event, effectiveTargetDir, targetDir))
|
||||||
...event,
|
|
||||||
outputPath: path.resolve(targetDir, ...event.entryPath.split("/"))
|
|
||||||
})
|
|
||||||
: onOutput;
|
: onOutput;
|
||||||
|
|
||||||
const command = await resolveExtractorCommand(archivePath);
|
const command = await resolveExtractorCommand(archivePath);
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ import {
|
|||||||
orderExtractorCandidatesForArchive,
|
orderExtractorCandidatesForArchive,
|
||||||
parseNativeExtractOutput,
|
parseNativeExtractOutput,
|
||||||
parseNativeArchiveEntryList,
|
parseNativeArchiveEntryList,
|
||||||
|
remapNativeSubstOutput,
|
||||||
|
reconcileNativeExtractOutputs,
|
||||||
resolveExtractorBackendModeForArchive,
|
resolveExtractorBackendModeForArchive,
|
||||||
resolveExtractorBackendMode,
|
resolveExtractorBackendMode,
|
||||||
shouldFallbackLegacyRarToJvm,
|
shouldFallbackLegacyRarToJvm,
|
||||||
validateNativeArchiveEntryCandidates,
|
validateNativeArchiveEntryCandidates,
|
||||||
|
validateNativeFlatArchiveEntryCandidates,
|
||||||
} from "../src/main/extractor";
|
} from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@@ -1731,6 +1734,56 @@ describe("extractor", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a native renamed filename when remapping a subst output", () => {
|
||||||
|
const targetDir = "C:\\Downloads\\Extracted";
|
||||||
|
const event = remapNativeSubstOutput({
|
||||||
|
version: 1,
|
||||||
|
archivePath: "C:\\Downloads\\release.rar",
|
||||||
|
entryPath: "episode.mkv",
|
||||||
|
outputPath: "Z:\\episode(2).mkv",
|
||||||
|
state: "complete",
|
||||||
|
disposition: "renamed"
|
||||||
|
}, "Z:\\", targetDir);
|
||||||
|
|
||||||
|
expect(event.outputPath).toBe(path.resolve(targetDir, "episode(2).mkv"));
|
||||||
|
expect(event.entryPath).toBe("episode.mkv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps native 7-Zip output directories when a previous RAR requested flat mode", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-seven-flat-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
const outputPath = path.join(targetDir, "folder", "episode.mkv");
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, "video");
|
||||||
|
|
||||||
|
const events = reconcileNativeExtractOutputs(
|
||||||
|
"7z.exe",
|
||||||
|
[{ entryPath: "folder/episode.mkv", isDirectory: false }],
|
||||||
|
path.join(root, "release.7z"),
|
||||||
|
targetDir,
|
||||||
|
"overwrite",
|
||||||
|
new Set(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects colliding RAR flat-mode basenames before extraction", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rar-flat-collision-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
expect(() => validateNativeFlatArchiveEntryCandidates("Rar.exe", [
|
||||||
|
"folder-a/episode.mkv",
|
||||||
|
"folder-b/episode.mkv"
|
||||||
|
], targetDir)).toThrow(/Kollision/i);
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["Entpacke", "Extrayendo", "Extraction"])("parses verified native RAR candidates without depending on the %s locale verb", (verb) => {
|
it.each(["Entpacke", "Extrayendo", "Extraction"])("parses verified native RAR candidates without depending on the %s locale verb", (verb) => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-locale-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-locale-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -1744,6 +1797,72 @@ describe("extractor", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconciles verified native outputs when WinRAR emits no parseable completion line", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-reconcile-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
const archivePath = path.join(root, "release.part1.rar");
|
||||||
|
const outputPath = path.join(targetDir, "folder", "episode.mkv");
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, "video");
|
||||||
|
|
||||||
|
const events = reconcileNativeExtractOutputs(
|
||||||
|
"Rar.exe",
|
||||||
|
[{ entryPath: "folder/", isDirectory: true }, { entryPath: "folder/episode.mkv", isDirectory: false }],
|
||||||
|
archivePath,
|
||||||
|
targetDir,
|
||||||
|
"overwrite",
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([{
|
||||||
|
version: 1,
|
||||||
|
archivePath: path.resolve(archivePath),
|
||||||
|
entryPath: "folder/episode.mkv",
|
||||||
|
outputPath,
|
||||||
|
state: "complete",
|
||||||
|
disposition: "written"
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Rar.exe", "episode(1).mkv", "episode(2).mkv"],
|
||||||
|
["7z.exe", "episode_1.mkv", "episode_2.mkv"]
|
||||||
|
])("reconciles the newly renamed %s output without claiming an older collision", (command, firstRenamedName, newRenamedName) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rename-reconcile-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
const archivePath = path.join(root, "release.rar");
|
||||||
|
const basePath = path.join(targetDir, "episode.mkv");
|
||||||
|
const firstRenamedPath = path.join(targetDir, firstRenamedName);
|
||||||
|
const newRenamedPath = path.join(targetDir, newRenamedName);
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
fs.writeFileSync(basePath, "foreign-base");
|
||||||
|
fs.writeFileSync(firstRenamedPath, "foreign-renamed");
|
||||||
|
const existingBefore = new Set([basePath, firstRenamedPath].map((filePath) => (
|
||||||
|
process.platform === "win32" ? path.resolve(filePath).toLowerCase() : path.resolve(filePath)
|
||||||
|
)));
|
||||||
|
fs.writeFileSync(newRenamedPath, "owned");
|
||||||
|
|
||||||
|
const events = reconcileNativeExtractOutputs(
|
||||||
|
command,
|
||||||
|
[{ entryPath: "episode.mkv", isDirectory: false }],
|
||||||
|
archivePath,
|
||||||
|
targetDir,
|
||||||
|
"rename",
|
||||||
|
existingBefore
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
entryPath: "episode.mkv",
|
||||||
|
outputPath: newRenamedPath,
|
||||||
|
state: "complete",
|
||||||
|
disposition: "renamed"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["file.mkv:stream", "file.mkv"],
|
["file.mkv:stream", "file.mkv"],
|
||||||
["name.", "name"],
|
["name.", "name"],
|
||||||
|
|||||||
Reference in New Issue
Block a user