JJ_MKVMaker/lib/ui/archive_ui.dart

351 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/copy_center.dart' show diskSpace;
import '../core/archive_ops.dart';
import '../core/file_ops.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 86: 동봉한 7-Zip (Windows: 실행 파일 옆 7zip\7z.exe). 개발 중에는 third_party 의 것. 없으면 null (Android - 아직 없음)
String? bundledSevenZip() {
if (!Platform.isWindows) return null;
for (final f in [
p.join(p.dirname(Platform.resolvedExecutable), '7zip', '7z.exe'),
p.join(Directory.current.path, 'third_party', '7zip', 'windows', '7z.exe'),
]) {
if (File(f).existsSync()) return f;
}
return null;
}
/// 비밀번호 묻기 (보이지 않게 · 작업 기록에 남기지 않음). 취소하면 null
Future<String?> askArchivePassword(BuildContext context, String name, {bool wrong = false}) async {
final ctl = TextEditingController();
try {
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('비밀번호')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('"{0}" 은(는) 비밀번호가 걸려 있습니다.', [name])),
if (wrong) Text(tr('비밀번호가 틀렸습니다'), style: const TextStyle(color: JjColors.danger)),
TextField(
controller: ctl,
autofocus: true,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(labelText: tr('비밀번호'), isDense: true),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, ctl.text), child: Text(tr('확인'))),
],
),
);
} finally {
ctl.dispose();
}
}
/// 진행 창 (취소 단추). [run] 은 진행률 (0~1, 모르면 null) 을 [set] 으로 알리고, 취소는 [cancelled] 로 본다
Future<T> _withProgress<T>(BuildContext context, String title, Future<T> Function(void Function(double?) set, bool Function() cancelled) run) async {
final v = ValueNotifier<double?>(null);
var stop = false;
final nav = Navigator.of(context);
var open = true;
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(title),
content: SizedBox(
width: 380,
child: ValueListenableBuilder<double?>(
valueListenable: v,
builder: (_, x, _) => Column(mainAxisSize: MainAxisSize.min, children: [
LinearProgressIndicator(value: x),
const SizedBox(height: 6),
Text(x == null ? tr('준비하는 중') : '${(x * 100).round()}%', style: const TextStyle(fontSize: 12)),
]),
),
),
actions: [TextButton(onPressed: () => stop = true, child: Text(tr('취소')))],
),
).whenComplete(() => open = false));
try {
return await run((x) => v.value = x, () => stop);
} finally {
if (open) nav.pop();
}
}
/// 86: 압축 풀기 (ZIP · 7z · RAR). 목록을 먼저 읽어 검사 (밖으로 나가는 경로 · 링크 · 크기 · 비밀번호) 한 뒤 확인 창 → 풀기.
/// [names] 가 있으면 그 항목만. 푼 폴더를 돌려준다 (취소 · 실패면 null)
Future<String?> extractArchiveFlow(BuildContext context, AppController c, String path, {String? otherDir, List<String> names = const []}) async {
final kind = archiveKindOf(path);
if (kind == null) return null;
final messenger = ScaffoldMessenger.maybeOf(context);
void say(String t) => messenger?.showSnackBar(SnackBar(content: Text(t)));
final local = isDav(path) ? await vLocalCopy(path, await c.services.storage.tempDirectory()) : path;
if (!context.mounted) return null;
// 7z · RAR: 동봉한 7-Zip 이 있어야 (Android 는 아직 - 막지 않고 다른 앱으로 안내)
final exe = kind == ArchiveKind.zip ? null : bundledSevenZip();
if (kind != ArchiveKind.zip && exe == null) {
final open = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('압축 풀기')),
content: Text(tr('이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('다른 앱으로 열기'))),
],
),
);
if (open == true) await c.services.shell.openWith(local, choose: true);
return null;
}
// 목록 (머리까지 암호가 걸린 7z 는 비밀번호부터)
String? password;
List<ArchiveEntry> entries;
while (true) {
try {
entries = kind == ArchiveKind.zip ? await listZip(local) : await list7z(exe!, local, password: password);
break;
} on ArchivePasswordException catch (e) {
if (!context.mounted) return null;
password = await askArchivePassword(context, vBasename(path), wrong: e.wrong);
if (password == null) return null;
} catch (e) {
say(trf('압축을 읽지 못했습니다: {0}', [e]));
return null;
}
}
final plan = planExtract(entries, names: names);
if (!context.mounted) return null;
// 풀 곳 (WebDAV 폴더로는 풀지 않음 - 이 기기의 폴더만)
final here = vDirname(path);
final base = vBasename(path).replaceAll(RegExp(r'\.[^.]+$'), '');
final choices = <(String, String)>[
if (!isDav(here)) (vJoin(here, base), trf('압축 옆 새 폴더 "{0}"', [base])),
if (!isDav(here)) (here, tr('압축이 있는 폴더')),
if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더')),
];
if (choices.isEmpty) {
say(tr('WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.'));
return null;
}
var dest = choices.first.$1;
var conflict = switch (c.settings.copyConflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename };
Future<int?> free(String d) async {
var x = d;
while (!Directory(x).existsSync() && p.dirname(x) != x) {
x = p.dirname(x);
}
return (await diskSpace(x))?.$1;
}
final frees = {for (final (d, _) in choices) d: await free(d)};
if (!context.mounted) return null;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
final f = frees[dest];
final short = f != null && plan.totalSize > f;
return AlertDialog(
scrollable: true,
title: Text(trf('압축 풀기 · {0}', [vBasename(path)])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('파일 {0}개 · {1}', [plan.files.length, formatSize(plan.totalSize)])),
if (plan.unsafe.isNotEmpty) ...[
const SizedBox(height: 8),
Text(trf('밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}',
[plan.unsafe.length, plan.unsafe.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]),
style: const TextStyle(color: JjColors.danger, fontSize: 12)),
],
if (plan.badNames.isNotEmpty) ...[
const SizedBox(height: 6),
Text(trf('풀 수 없는 이름 {0}개 (건너뜀): {1}',
[plan.badNames.length, plan.badNames.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]),
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)),
],
if (plan.links.isNotEmpty) ...[
const SizedBox(height: 6),
Text(trf('링크 {0}개는 만들지 않고 건너뜁니다: {1}', [plan.links.length, plan.links.take(5).join(', ')]),
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)),
],
if (plan.encrypted) ...[
const SizedBox(height: 6),
Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
],
const SizedBox(height: 10),
Wrap(spacing: 6, runSpacing: 4, children: [
for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: dest == d, onSelected: (_) => set(() => dest = d)),
]),
Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 6),
Row(children: [
Text(tr('같은 이름이 있으면'), style: const TextStyle(fontSize: 13)),
const SizedBox(width: 8),
DropdownButton<NameConflict>(
value: conflict,
items: [
DropdownMenuItem(value: NameConflict.rename, child: Text(tr('이름 바꾸기'))),
DropdownMenuItem(value: NameConflict.skip, child: Text(tr('건너뛰기'))),
DropdownMenuItem(value: NameConflict.overwrite, child: Text(tr('덮어쓰기'))),
],
onChanged: (v) => set(() => conflict = v!),
),
]),
// 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠)
if (conflict == NameConflict.overwrite)
Text(
c.settings.copySkipNewer
? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).')
: tr('덮어쓴 파일은 되돌릴 수 없습니다.'),
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)),
// 관리자 판단: 풀린 크기가 빈 공간보다 크면 풀기 전에 알리고 묻는다 (압축 폭탄 · 공간 부족)
if (short)
Text(trf('풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?', [formatSize(plan.totalSize), formatSize(f)]),
style: const TextStyle(color: JjColors.danger)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
onPressed: plan.files.isEmpty ? null : () => Navigator.pop(ctx, true),
child: Text(short ? tr('그래도 풀기') : tr('풀기')),
),
],
);
},
),
);
if (go != true || !context.mounted) return null;
// 비밀번호가 걸린 파일인데 아직 모르면 묻는다
if (plan.encrypted && (password == null || password.isEmpty)) {
password = await askArchivePassword(context, vBasename(path));
if (password == null || !context.mounted) return null;
}
while (true) {
try {
final pw = password;
final r = await _withProgress(context, trf('압축 푸는 중 · {0}', [vBasename(path)]), (set, cancelled) async {
if (kind == ArchiveKind.zip) {
return extractZipPlan(local, dest, plan,
conflict: conflict,
skipNewer: c.settings.copySkipNewer,
password: pw,
cancelled: cancelled,
onProgress: (d, t) => set(t == 0 ? null : d / t));
}
return extract7zPlan(exe!, local, dest, plan,
conflict: conflict, skipNewer: c.settings.copySkipNewer, password: pw, cancelled: cancelled, onProgress: set);
});
say([
trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]),
if (r.skippedExisting.isNotEmpty) trf('같은 이름이라 건너뜀 {0}개', [r.skippedExisting.length]),
if (r.renamed.isNotEmpty) trf('이름을 바꿔 풂 {0}개', [r.renamed.length]),
if (r.keptNewer.isNotEmpty) trf('{0}개는 더 새 파일이라 그대로 둠', [r.keptNewer.length]),
if (plan.unsafe.isNotEmpty) trf('막은 경로 {0}개', [plan.unsafe.length]),
if (plan.links.isNotEmpty) trf('건너뛴 링크 {0}개', [plan.links.length]),
if (plan.badNames.isNotEmpty) trf('풀 수 없는 이름 {0}개', [plan.badNames.length]),
].join(' · '));
return dest;
} on ArchivePasswordException catch (e) {
if (!context.mounted) return null;
password = await askArchivePassword(context, vBasename(path), wrong: e.wrong);
if (password == null || !context.mounted) return null;
} on ArchiveCancelled {
say(tr('취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).'));
return null;
} catch (e) {
say(trf('풀지 못했습니다: {0}', [e]));
return null;
}
}
}
/// 86: ZIP 으로 묶기 - 이름 · 압축 정도 (설정으로 기억) · 넣을 곳. 링크 · junction 은 넣지 않는다. 만든 ZIP 경로 (취소 · 실패면 null)
Future<String?> createZipFlow(BuildContext context, AppController c, List<String> sources, {required String dir, String? otherDir}) async {
final messenger = ScaffoldMessenger.maybeOf(context);
void say(String t) => messenger?.showSnackBar(SnackBar(content: Text(t)));
if (sources.isEmpty) return null;
if (sources.any(isDav) || isDav(dir)) {
say(tr('WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.'));
return null;
}
final name = TextEditingController(
text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir));
var level = c.settings.zipLevel;
var where = dir;
final choices = <(String, String)>[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))];
String target() {
var n = name.text.trim();
if (n.isEmpty) n = 'archive';
if (!n.toLowerCase().endsWith('.zip')) n = '$n.zip';
var t = p.join(where, n);
for (var i = 2; File(t).existsSync(); i++) {
t = p.join(where, '${p.basenameWithoutExtension(n)} ($i).zip');
}
return t;
}
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(trf('ZIP 으로 묶기 · {0}개', [sources.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(
controller: name,
decoration: InputDecoration(labelText: tr('ZIP 이름'), suffixText: '.zip', isDense: true),
onChanged: (_) => set(() {}),
),
const SizedBox(height: 10),
Wrap(spacing: 6, children: [
for (final (v, label) in [(1, tr('빠르게')), (6, tr('보통')), (9, tr('작게'))])
ChoiceChip(label: Text(label), selected: level == v, onSelected: (_) => set(() => level = v)),
]),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 4, children: [
for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: where == d, onSelected: (_) => set(() => where = d)),
]),
Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('묶기'))),
],
),
),
);
final zip = target();
name.dispose();
if (go != true || !context.mounted) return null;
if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level);
if (!context.mounted) return null;
try {
final (n, links) = await _withProgress(context, trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]),
(set, cancelled) => createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)));
say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · '));
return zip;
} on ArchiveCancelled {
say(tr('취소했습니다. 만들다 만 ZIP 은 남지 않습니다.'));
} catch (e) {
say(trf('ZIP 을 만들지 못했습니다: {0}', [e]));
}
return null;
}