JJ_MKVMaker/lib/core/archive_ops.dart

546 lines
25 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import 'file_ops.dart' show NameConflict, nameProblem;
import '../services/platform_info.dart';
import 'files/same_file.dart';
/// 86: 압축 종류 (확장자로)
enum ArchiveKind { zip, sevenZip, rar }
ArchiveKind? archiveKindOf(String path) => switch (p.extension(path).toLowerCase()) {
'.zip' || '.cbz' => ArchiveKind.zip,
'.7z' || '.cb7' => ArchiveKind.sevenZip,
'.rar' || '.cbr' => ArchiveKind.rar,
_ => null,
};
/// 압축 안의 항목 하나 (이름은 압축 안 그대로 - 나누는 글자는 '/' 로 맞춤)
class ArchiveEntry {
final String name;
final int size;
final bool isDir;
final bool isLink;
final bool encrypted;
/// 7-Zip 에 넘길 원래 이름 (Windows 의 7z 는 '\' 로 보여 준다)
final String raw;
/// 압축 안 파일의 바뀐 날 (모르면 null) - 덮어쓰기에서 받는 쪽이 더 새것인지 볼 때
final DateTime? modified;
const ArchiveEntry(this.name, {this.size = 0, this.isDir = false, this.isLink = false, this.encrypted = false, String? raw, this.modified})
: raw = raw ?? name;
}
/// 비밀번호가 필요하거나 틀림
class ArchivePasswordException implements Exception {
final bool wrong;
const ArchivePasswordException({this.wrong = false});
@override
String toString() => wrong ? tr('비밀번호가 틀렸습니다') : tr('비밀번호가 필요합니다');
}
/// 86 (보안 · 관리자 판단): 풀면 대상 폴더 밖으로 나가는 이름인지 - 까닭 (괜찮으면 null).
/// ../ · 절대 경로 (/ · \) · 드라이브 문자 (C:) · UNC (\\서버) · 이름 안의 ':' (Windows 의 다른 데이터 흐름) 을 막는다 (zip slip)
String? unsafeArchivePath(String name) {
final n = name.replaceAll('\\', '/');
if (n.startsWith('//')) return tr('네트워크 경로 (UNC)');
if (n.startsWith('/')) return tr('절대 경로');
if (RegExp(r'^[A-Za-z]:').hasMatch(n)) return tr('드라이브 경로');
final parts = n.split('/');
if (parts.any((s) => s == '..')) return tr('바깥 폴더 (../)');
if (parts.any((s) => s.contains(':'))) return tr('쓸 수 없는 글자 (:)');
if (parts.every((s) => s.isEmpty || s == '.')) return tr('빈 이름');
return null;
}
/// 풀기 전 검사 결과
class ExtractPlan {
/// 풀 파일 (폴더는 파일을 풀며 만든다)
final List<ArchiveEntry> files;
/// 막은 것: (이름, 까닭)
final List<(String, String)> unsafe;
/// 만들지 않고 건너뛰는 링크
final List<String> links;
/// 이 기기에서 쓸 수 없는 이름 (Windows: CON · NUL · ? * " < > | · 끝 점 · 빈칸 …) - (이름, 까닭), 건너뜀
final List<(String, String)> badNames;
/// 풀린 크기 합 (바이트)
final int totalSize;
/// 비밀번호가 걸린 항목이 있음
final bool encrypted;
const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted, [this.badNames = const []]);
}
/// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만
/// 15 지적 · 관리자 판단: 리눅스 · 맥에서 만든 압축의 이름이 Windows 에서 쓸 수 없으면 (78 의 nameProblem) 미리 걸러 건너뛴다
ExtractPlan planExtract(List<ArchiveEntry> entries, {List<String> names = const [], bool? windowsRules}) {
final win = windowsRules ?? PlatformInfo.host.isWindows;
bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/'));
final files = <ArchiveEntry>[];
final unsafe = <(String, String)>[];
final links = <String>[];
final badNames = <(String, String)>[];
var size = 0;
var enc = false;
for (final e in entries) {
if (!wanted(e.name)) continue;
final bad = unsafeArchivePath(e.name);
if (bad != null) {
unsafe.add((e.name, bad));
continue;
}
if (e.isLink) {
links.add(e.name);
continue;
}
if (e.isDir) continue;
// 이름의 각 부분이 이 기기에서 쓸 수 있는지 (풀다가 실패하거나 7-Zip 이 다른 이름으로 바꾸지 않게)
final why = [for (final s in e.name.split('/')) if (s.isNotEmpty && s != '.') nameProblem(s, windowsRules: win)].nonNulls.firstOrNull;
if (why != null) {
badNames.add((e.name, why));
continue;
}
files.add(e);
size += e.size;
enc |= e.encrypted;
}
return ExtractPlan(files, unsafe, links, size, enc, badNames);
}
/// 풀 때 [dest] 안의 자리 ([name] 은 [unsafeArchivePath] 를 지난 것)
String extractTarget(String dest, String name) =>
p.joinAll([dest, ...name.replaceAll('\\', '/').split('/').where((s) => s.isNotEmpty && s != '.')]);
/// 같은 이름이면 "이름 (2).확장자" …
String _freeName(String path) {
if (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound) return path;
final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path);
for (var n = 2;; n++) {
final c = p.join(dir, '$base ($n)$ext');
if (FileSystemEntity.typeSync(c) == FileSystemEntityType.notFound) return c;
}
}
/// 푼 결과
class ExtractResult {
int done = 0;
final skippedExisting = <String>[];
final renamed = <String>[];
/// 덮어쓰기인데 받는 쪽이 더 새것이라 그대로 둔 것 (229 와 같은 규칙)
final keptNewer = <String>[];
}
/// 덮어쓰기에서 건너뛸지: 받는 쪽 파일이 압축 안의 것보다 2초 넘게 새것
bool _targetNewer(String target, DateTime? modified) {
try {
return SameFile.keepNewer(skipNewer: true, targetModified: File(target).lastModifiedSync(), modified: modified);
} catch (_) {
return false;
}
}
/// ZIP 의 MS-DOS 날짜 · 시각 (지역 시각) → DateTime (0 이면 null)
DateTime? dosDateTime(int date, int time) {
if (date == 0) return null;
return DateTime(1980 + (date >> 9), (date >> 5) & 0xF, date & 0x1F, time >> 11, (time >> 5) & 0x3F, (time & 0x1F) * 2);
}
/// 취소 (풀기 · 묶기 도중)
class ArchiveCancelled implements Exception {
const ArchiveCancelled();
@override
String toString() => tr('취소했습니다');
}
// ───────── ZIP (archive 패키지, MIT) ─────────
/// ZIP 목록 - 가운데 목록 (central directory) 을 직접 읽어 비밀번호 · 링크를 안다
/// (archive 패키지의 항목에는 비밀번호 여부가 없고, 비밀번호 없이 읽으면 암호 글자가 그대로 나온다)
Future<List<ArchiveEntry>> listZip(String path) async {
final input = InputFileStream(path);
try {
final dir = ZipDirectory()..read(input);
return [
for (final h in dir.fileHeaders)
ArchiveEntry(h.filename.replaceAll('\\', '/'),
size: h.uncompressedSize,
isDir: h.filename.endsWith('/') || h.filename.endsWith('\\'),
// 유닉스 파일 종류 (상위 16비트) 가 심볼릭 링크 (0xA000)
isLink: (h.externalFileAttributes >> 16) & 0xF000 == 0xA000,
encrypted: h.generalPurposeBitFlag & 0x1 != 0,
modified: dosDateTime(h.lastModifiedFileDate, h.lastModifiedFileTime)),
];
} finally {
await input.close();
}
}
/// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로
/// zip_aes: ZIP 비밀번호를 UTF-8 바이트로 (한 바이트 = 한 글자). archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서
/// 한글 등이 깨지고 7-Zip (UTF-8) 과 맞지 않았다 - UTF-8 바이트를 글자로 넣으면 archive 가 그 바이트를 그대로 쓴다
String zipPasswordBytes(String password) => String.fromCharCodes(utf8.encode(password));
/// ZIP 풀기 ([_extractZipPlanWith]). 비밀번호에 영문 밖 글자가 있으면 UTF-8 (7-Zip · 이 앱이 만든 것) 로 먼저,
/// 틀리면 예전 방식 (이 앱의 예전 판 · archive 그대로) 으로 한 번 더
Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
{NameConflict conflict = NameConflict.rename,
bool skipNewer = false,
String? password,
void Function(int done, int total)? onProgress,
bool Function()? cancelled}) async {
final tries = [
if (password != null && password.isNotEmpty) zipPasswordBytes(password),
if (password != null && password.isNotEmpty && zipPasswordBytes(password) != password) password,
if (password == null || password.isEmpty) password,
];
for (var i = 0;; i++) {
try {
return await _extractZipPlanWith(path, dest, plan,
conflict: conflict, skipNewer: skipNewer, password: tries[i], onProgress: onProgress, cancelled: cancelled);
} on ArchivePasswordException catch (e) {
if (!e.wrong || i + 1 >= tries.length) rethrow;
}
}
}
Future<ExtractResult> _extractZipPlanWith(String path, String dest, ExtractPlan plan,
{NameConflict conflict = NameConflict.rename,
bool skipNewer = false,
String? password,
void Function(int done, int total)? onProgress,
bool Function()? cancelled}) async {
// 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다
if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException();
final input = InputFileStream(path);
final r = ExtractResult();
try {
// verify: CRC 를 맞춰 보아 비밀번호가 틀리면 (풀린 내용이 다르면) 실패로
final a = ZipDecoder().decodeStream(input, password: password, verify: plan.encrypted);
final want = {for (final e in plan.files) e.name};
final modified = {for (final e in plan.files) e.name: e.modified};
final todo = [for (final f in a.files) if (f.isFile && want.contains(f.name.replaceAll('\\', '/'))) f];
for (final f in todo) {
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
var target = extractTarget(dest, f.name);
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) {
if (conflict == NameConflict.skip) {
r.skippedExisting.add(f.name);
continue;
}
if (conflict == NameConflict.rename) {
target = _freeName(target);
r.renamed.add(f.name);
}
// 관리자 판단: 덮어쓰기에도 "받는 쪽이 더 새것이면 건너뛰기" (229)
if (conflict == NameConflict.overwrite && skipNewer && _targetNewer(target, modified[f.name.replaceAll('\\', '/')])) {
r.keptNewer.add(f.name);
continue;
}
}
await Directory(p.dirname(target)).create(recursive: true);
final bytes = f.readBytes();
if (bytes == null) continue;
// 반쯤 쓴 파일이 완성된 이름으로 남지 않게 (.jjpart 에 쓴 뒤 이름 바꾸기)
final part = File('$target.jjpart');
await part.writeAsBytes(bytes, flush: true);
if (await File(target).exists()) await File(target).delete();
await part.rename(target);
r.done++;
onProgress?.call(r.done, todo.length);
}
} on ArchivePasswordException {
rethrow;
} catch (e) {
// archive 는 틀린 비밀번호를 ArchiveException · 그냥 Exception ("password error") 으로 알린다
if (plan.encrypted && (e is ArchiveException || '$e'.toLowerCase().contains('password'))) {
throw const ArchivePasswordException(wrong: true);
}
rethrow;
} finally {
await input.close();
}
return r;
}
/// ZIP 만들기: [sources] (파일 · 폴더) 를 [zipPath] 로. 링크 · junction 은 넣지 않는다 (밖을 가리키는 것을 따라가지 않게).
/// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
Future<(int, int)> createZip(List<String> sources, String zipPath,
{int level = 6, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async {
// 넣을 파일 (상대 이름) - 링크는 세기만
final files = <(String, String)>[];
var links = 0;
for (final s in sources) {
final base = p.dirname(s);
final t = FileSystemEntity.typeSync(s, followLinks: false);
if (t == FileSystemEntityType.link) {
links++;
} else if (t == FileSystemEntityType.file) {
files.add((s, p.relative(s, from: base)));
} else if (t == FileSystemEntityType.directory) {
await for (final e in Directory(s).list(recursive: true, followLinks: false)) {
if (e is Link || FileSystemEntity.isLinkSync(e.path)) {
links++;
} else if (e is File) {
files.add((e.path, p.relative(e.path, from: base)));
}
}
}
}
final part = '$zipPath.jjpart';
// zip_aes: [password] 가 있으면 archive 의 AES-256 (WinZip AE) - 파일마다 메모리에 올려 암호화하므로 큰 파일은 [createZipAes] 가 막는다
final enc = ZipFileEncoder(password: password == null || password.isEmpty ? null : zipPasswordBytes(password))..create(part, level: level);
var done = false;
try {
for (final (i, (path, rel)) in files.indexed) {
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
await enc.addFile(File(path), rel.replaceAll('\\', '/'), level);
onProgress?.call(i + 1, files.length);
}
await enc.close();
done = true;
await File(part).rename(zipPath);
} finally {
if (!done) {
try {
await enc.close();
} catch (_) {}
try {
await File(part).delete();
} catch (_) {}
}
}
return (files.length, links);
}
// ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ─────────
/// zip_aes: 영문 밖 글자가 든 비밀번호로 (archive, 메모리에서) 암호화할 수 있는 파일 하나의 크기 한도
const aesInMemoryLimit = 512 * 1024 * 1024;
/// zip_aes: 영문 밖 글자가 든 비밀번호인데 [aesInMemoryLimit] 넘는 파일이 있음
class ZipPasswordTooBigException implements Exception {
final String name;
const ZipPasswordTooBigException(this.name);
@override
String toString() => trf('비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.', [name]);
}
/// zip_aes (관리자 지시 10/17): 비밀번호 ZIP (AES-256) 을 동봉 7-Zip 으로 만든다 (Windows 7z.exe · Android lib7zz).
/// archive 패키지의 AES 는 파일마다 통째로 메모리에 올려 암호화해 큰 동영상에서 메모리가 모자랄 수 있어 7-Zip 으로 (흘려 가며).
/// 넣을 파일은 [createZip] 과 같게 앱이 고른다 (링크 · junction 빼고, 원본 폴더 기준 상대 이름). 비밀번호는 표준 입력으로만
/// (명령 줄 · 작업 기록 · 프로세스 목록에 남지 않게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
Future<(int, int)> createZipAes(String exe, List<String> sources, String zipPath,
{required String password, int level = 6, void Function(double v)? onProgress, bool Function()? cancelled}) async {
if (password.isEmpty) throw ArgumentError('password');
// Windows 7-Zip 은 표준 입력으로 받은 비밀번호에 영문 밖 글자 (한글 등) 가 있으면 "매개 변수가 틀립니다" 로 실패한다
// (코드 페이지를 바꿔도 같음, 10/17 시험). 그때는 archive 의 AES-256 으로 - 파일을 통째로 메모리에 올리므로 큰 파일은 막고 알린다 (내 판단)
if (password.codeUnits.any((c) => c < 0x20 || c > 0x7e)) {
for (final s in sources) {
for (final f in [
if (FileSystemEntity.isFileSync(s)) File(s),
if (FileSystemEntity.isDirectorySync(s)) ...Directory(s).listSync(recursive: true, followLinks: false).whereType<File>(),
]) {
if (f.lengthSync() > aesInMemoryLimit) throw ZipPasswordTooBigException(p.basename(f.path));
}
}
return createZip(sources, zipPath,
level: level, password: password, cancelled: cancelled, onProgress: (d, t) => onProgress?.call(t == 0 ? 0 : d / t));
}
// 원본 폴더마다 (상대 이름이 그 폴더 기준이 되게) 넣을 파일 목록
final groups = <String, List<String>>{};
var links = 0, count = 0;
for (final s in sources) {
final base = p.dirname(s);
final t = FileSystemEntity.typeSync(s, followLinks: false);
if (t == FileSystemEntityType.link) {
links++;
} else if (t == FileSystemEntityType.file) {
groups.putIfAbsent(base, () => []).add(p.relative(s, from: base));
} else if (t == FileSystemEntityType.directory) {
await for (final e in Directory(s).list(recursive: true, followLinks: false)) {
if (e is Link || FileSystemEntity.isLinkSync(e.path)) {
links++;
} else if (e is File) {
groups.putIfAbsent(base, () => []).add(p.relative(e.path, from: base));
}
}
}
}
for (final l in groups.values) {
count += l.length;
}
final part = '$zipPath.jjpart';
final tmp = await Directory.systemTemp.createTemp('jj_7za_');
var done = false;
try {
var g = 0;
for (final MapEntry(key: base, value: rels) in groups.entries) {
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
final list = File(p.join(tmp.path, 'list$g.txt'));
await list.writeAsString(rels.join('\n'), encoding: utf8);
final before = g;
final (code, out, err) = await _run7z(
exe,
['a', '-tzip', '-mem=AES256', '-mcu=on', '-mx=${level.clamp(0, 9)}', '-p', '-bsp1', '-bb0', '-y', part, '@${list.path}'],
password: password,
confirm: true,
workingDirectory: base,
cancelled: cancelled, onLine: (l) {
final m = RegExp(r'(\d+)%').firstMatch(l);
if (m != null) onProgress?.call((before + int.parse(m[1]!) / 100) / groups.length);
});
if (code != 0) throw ProcessException(exe, const ['a'], err.trim().isEmpty ? out.trim() : err.trim(), code);
g++;
}
if (count == 0) throw FileSystemException(tr('묶을 파일이 없습니다'), sources.first);
done = true;
await File(part).rename(zipPath);
} finally {
if (!done) {
try {
await File(part).delete();
} catch (_) {}
}
try {
await tmp.delete(recursive: true);
} catch (_) {}
}
return (count, links);
}
/// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점
List<ArchiveEntry> parse7zList(String out) {
final entries = <ArchiveEntry>[];
for (final block in out.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) {
final m = <String, String>{};
for (final line in block.split('\n')) {
final i = line.indexOf(' = ');
if (i > 0) m[line.substring(0, i).trim()] = line.substring(i + 3);
}
final raw = m['Path'];
if (raw == null || raw.isEmpty) continue;
final attr = m['Attributes'] ?? '';
final link = (m['Symbolic Link'] ?? '').isNotEmpty ||
(m['Hard Link'] ?? '').isNotEmpty ||
RegExp(r'(^|\s)-?l[r-][w-][x-]').hasMatch(attr) ||
attr.contains('REPARSE');
entries.add(ArchiveEntry(raw.replaceAll('\\', '/'),
raw: raw,
size: int.tryParse(m['Size'] ?? '') ?? 0,
modified: DateTime.tryParse((m['Modified'] ?? '').replaceFirst(' ', 'T')),
isDir: (m['Folder'] ?? '') == '+' || RegExp(r'^[A-Z_]*D').hasMatch(attr),
isLink: link,
encrypted: (m['Encrypted'] ?? '') == '+'));
}
return entries;
}
/// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단)
Future<(int, String, String)> _run7z(String exe, List<String> args,
{String? password, void Function(String line)? onLine, bool Function()? cancelled, String? workingDirectory, bool confirm = false}) async {
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args], workingDirectory: workingDirectory);
// -sccUTF-8 이면 7-Zip 은 표준 입력도 UTF-8 로 읽는다 - Dart 의 기본 (시스템 코드 페이지, 한국어 Windows 는 CP949) 으로 보내면
// 한글 비밀번호가 틀렸다고 나왔다 (관리자 지시로 10/18 시험: UTF-8 + -sccUTF-8 이면 7z · RAR 이 한글 비밀번호로 풀림)
pr.stdin.encoding = utf8;
// 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다)
pr.stdin.writeln(password ?? '');
// 만들 때는 "다시 넣기" 도 묻는다 (zip_aes)
if (confirm) pr.stdin.writeln(password ?? '');
await pr.stdin.close();
final out = StringBuffer(), err = StringBuffer();
// 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다)
final a = pr.stdout.transform(const Utf8Decoder(allowMalformed: true)).forEach((s) {
out.write(s);
if (onLine != null) {
for (final l in s.split(RegExp(r'[\r\n\b]+'))) {
if (l.trim().isNotEmpty) onLine(l);
}
}
});
final b = pr.stderr.transform(const Utf8Decoder(allowMalformed: true)).forEach(err.write);
Timer? watch;
if (cancelled != null) {
watch = Timer.periodic(const Duration(milliseconds: 200), (_) {
if (cancelled()) pr.kill();
});
}
final code = await pr.exitCode;
watch?.cancel();
await a.catchError((_) {});
await b.catchError((_) {});
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
return (code, '$out', '$err');
}
bool _wrongPassword(String text) {
final t = text.toLowerCase();
return t.contains('wrong password') || t.contains('can not open encrypted') || t.contains('cannot open encrypted');
}
/// 7z · RAR 목록 (머리까지 암호가 걸렸으면 [password] 없이 [ArchivePasswordException])
Future<List<ArchiveEntry>> list7z(String exe, String path, {String? password}) async {
final (code, out, err) = await _run7z(exe, ['l', '-slt', '-ba', path], password: password);
if (code != 0) {
if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty);
throw ProcessException(exe, const ['l'], err.trim().isEmpty ? out.trim() : err.trim(), code);
}
return parse7zList(out);
}
/// 7z · RAR 풀기 - [plan] 의 파일만 (목록 파일로 넘김, 링크 · 막은 것은 빠짐). 같은 이름은 [conflict] 대로
Future<ExtractResult> extract7zPlan(String exe, String path, String dest, ExtractPlan plan,
{NameConflict conflict = NameConflict.rename,
bool skipNewer = false,
String? password,
void Function(double v)? onProgress,
bool Function()? cancelled}) async {
final r = ExtractResult();
// 관리자 판단: 덮어쓰기 (-aoa) 에도 받는 쪽이 더 새 파일은 목록에서 빼서 그대로 둔다 (229)
if (conflict == NameConflict.overwrite && skipNewer) {
final keep = [for (final e in plan.files) if (_targetNewer(extractTarget(dest, e.name), e.modified)) e];
r.keptNewer.addAll(keep.map((e) => e.name));
plan = ExtractPlan([for (final e in plan.files) if (!keep.contains(e)) e], plan.unsafe, plan.links, plan.totalSize, plan.encrypted, plan.badNames);
}
if (plan.files.isEmpty) return r;
final tmp = await Directory.systemTemp.createTemp('jj_7z_');
try {
final list = File(p.join(tmp.path, 'list.txt'));
await list.writeAsString(plan.files.map((e) => e.raw).join('\n'), encoding: utf8);
final existing = [for (final e in plan.files) if (FileSystemEntity.typeSync(extractTarget(dest, e.name)) != FileSystemEntityType.notFound) e.name];
final ao = switch (conflict) { NameConflict.overwrite => '-aoa', NameConflict.skip => '-aos', _ => '-aou' };
final (code, out, err) = await _run7z(exe, ['x', path, '-o$dest', ao, '-bsp1', '-bb0', '@${list.path}'],
password: password, cancelled: cancelled, onLine: (l) {
final m = RegExp(r'(\d+)%').firstMatch(l);
if (m != null) onProgress?.call(int.parse(m[1]!) / 100);
});
if (code != 0) {
if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty);
throw ProcessException(exe, const ['x'], err.trim().isEmpty ? out.trim() : err.trim(), code);
}
if (conflict == NameConflict.skip) {
r.skippedExisting.addAll(existing);
} else if (conflict == NameConflict.rename) {
r.renamed.addAll(existing);
}
r.done = plan.files.length - (conflict == NameConflict.skip ? existing.length : 0);
return r;
} finally {
try {
await tmp.delete(recursive: true);
} catch (_) {}
}
}