JJ_MKVMaker/lib/core/files/local_transfer.dart

285 lines
11 KiB
Dart

import 'dart:io';
import 'package:path/path.dart' as p;
import '../../l10n/tr.dart';
import '../vfs.dart';
import 'path_rules.dart';
import 'transfer_control.dart';
import 'transfer_guard.dart';
// 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기.
// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더. 출력: 만든 경로들 · 복사한 파일 수.
// 오류: 취소 → [FileOpCancelled] (만들다 만 파일은 지움) · 폴더를 자기 안으로 → [FileSystemException] ·
// 맞추기에서 원본 폴더를 읽지 못함 → [SourceUnreadableException] (그 폴더에서는 아무것도 지우지 않음). 그 밖의 파일 오류는 그대로.
// WebDAV 등이 섞이면 any_transfer 를 쓴다 (고르는 것은 [FileOps]).
/// 전체 크기 (진행률용)
Future<int> localTotalSize(List<String> paths) async {
var total = 0;
for (final s in paths) {
final t = FileSystemEntity.typeSync(s, followLinks: false);
if (t == FileSystemEntityType.directory) {
await for (final e in Directory(s).list(recursive: true, followLinks: false).handleError((_) {})) {
if (e is File) {
try {
total += await e.length();
} catch (_) {}
}
}
} else if (t == FileSystemEntityType.file) {
total += await File(s).length();
}
}
return total;
}
/// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 [TransferControl.conflict] 대로). 만든 경로들을 돌려준다.
Future<List<String>> localCopy(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final total = await localTotalSize(sources);
var done = 0;
final made = <String>[];
// 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게
Future<void> copyFile(File f, String finalTarget) async {
c.check();
final target = '$finalTarget.jjpart';
final out = File(target).openWrite();
try {
try {
await for (final chunk in f.openRead()) {
c.check();
out.add(chunk);
done += chunk.length;
onProgress?.call(f.path, done, total);
c.onBytes?.call(chunk.length);
await c.throttle(chunk.length);
}
} finally {
await out.close();
}
c.check();
} catch (_) {
// 취소 · 실패: 만들다 만 파일을 남기지 않는다
try {
await File(target).delete();
} catch (_) {}
rethrow;
}
// 덮어쓰기: 새 파일을 다 쓴 뒤에야 옛 파일을 지운다 (도중에 실패하면 옛 파일이 남게)
if (File(finalTarget).existsSync()) await File(finalTarget).delete();
await File(target).rename(finalTarget);
try {
await File(finalTarget).setLastModified(await f.lastModified());
} catch (_) {}
c.onFileDone?.call(f.path);
}
Future<void> copyAny(String src, String target, {bool inner = false}) async {
if (FileSystemEntity.isDirectorySync(src)) {
await Directory(target).create(recursive: true);
await for (final e in Directory(src).list(followLinks: false)) {
await copyAny(e.path, p.join(target, p.basename(e.path)), inner: true);
}
} else {
// 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다
if (inner && c.skipNewerInMerge && await isNewerThere(src, target)) {
c.keptNewer.add(src);
return;
}
await copyFile(File(src), target);
}
}
for (final s in sources) {
if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) {
throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s);
}
final target = await c.targetFor(s, destDir);
if (target == null) continue;
await copyAny(s, target);
made.add(target);
}
return made;
}
/// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다
Future<void> _deleteExcept(String dir, Set<String> keep) async {
Future<bool> walk(Directory d) async {
var empty = true;
await for (final e in d.list(followLinks: false)) {
if (e is Directory) {
if (await walk(e)) {
await e.delete();
} else {
empty = false;
}
} else if (keep.contains(p.normalize(e.path))) {
empty = false;
} else {
await e.delete();
}
}
return empty;
}
if (await walk(Directory(dir))) await Directory(dir).delete();
}
/// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지
Future<bool> isNewerThere(String src, String target) async {
final t = File(target);
if (!await t.exists()) return false;
return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2)));
}
/// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만)
Future<int> countNewerInside(String src, String dst, {int limit = 5000}) async {
var n = 0, seen = 0;
Future<void> walk(String s, String d) async {
await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) {
if (seen++ > limit) return;
final t = p.join(d, p.basename(e.path));
if (e is Directory) {
if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t);
} else if (await isNewerThere(e.path, t)) {
n++;
}
}
}
if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst);
return n;
}
/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제
Future<List<String>> localMove(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final made = <String>[];
for (final s in sources) {
c.check();
if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s);
if (samePath(p.dirname(s), destDir)) {
made.add(s); // 이미 그 폴더에 있음
continue;
}
final target = await c.targetFor(s, destDir);
if (target == null) continue;
final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound;
if (exists && FileSystemEntity.isDirectorySync(s)) {
// 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제
final keptBefore = c.keptNewer.length;
made.addAll(await localCopy(c, [s], destDir, onProgress: onProgress));
c.check();
final kept = c.keptNewer.sublist(keptBefore);
if (kept.isEmpty) {
await deletePaths(c, [s]);
} else {
// 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다
await _deleteExcept(s, kept.map(p.normalize).toSet());
}
continue;
}
try {
final t = FileSystemEntity.typeSync(s);
// 덮어쓰기 (파일): 이름 바꾸기가 옛 파일을 바로 바꿔 놓는다 (다른 드라이브면 아래 복사가 덮어씀)
if (t == FileSystemEntityType.directory) {
await Directory(s).rename(target);
} else {
await File(s).rename(target);
}
made.add(target);
} on FileSystemException {
// 다른 드라이브 · 저장 장치: 복사 후 삭제
final copied = await localCopy(c, [s], destDir, onProgress: onProgress);
await deletePaths(c, [s]);
made.addAll(copied);
}
}
return made;
}
/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다 (로컬끼리).
/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다.
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다. 지우기 전 원본 확인 (ensureSourceForDelete) 은 부르는 쪽이 한다.
Future<int> localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
var n = 0;
Future<void> walk(String src, String dst) async {
c.check();
await Directory(dst).create(recursive: true);
final names = <String>{};
// 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다
List<FileSystemEntity> items;
try {
items = await Directory(src).list(followLinks: false).toList();
} catch (e) {
throw SourceUnreadableException(src, cause: e);
}
for (final e in items) {
final name = p.basename(e.path);
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다
names.add(name);
final target = p.join(dst, name);
if (e is Directory) {
await walk(e.path, target);
} else if (e is File) {
final st = await e.stat();
final t = File(target);
final exists = await t.exists();
final same = exists &&
(await t.length()) == st.size &&
((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2);
if (same) {
c.onSettled?.call(e.path, st.size, st.modified);
continue;
}
if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue;
final tmp = '$target.jjsync';
final out = File(tmp).openWrite();
try {
await for (final chunk in e.openRead()) {
c.check();
out.add(chunk);
c.onBytes?.call(chunk.length);
await c.throttle(chunk.length);
}
} finally {
await out.close();
}
await File(tmp).rename(target);
try {
await File(target).setLastModified(st.modified);
} catch (_) {}
n++;
c.onFileDone?.call(e.path);
c.onSettled?.call(e.path, st.size, st.modified);
}
}
if (delete) {
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) {
await e.delete(recursive: true);
}
}
}
}
await walk(srcDir, dstDir);
return n;
}
/// 지우기 (로컬 · WebDAV). 없는 것은 그냥 지나간다. 항목마다 취소를 본다
Future<void> deletePaths(TransferControl c, List<String> paths) async {
for (final s in paths) {
c.check();
if (isDav(s)) {
await vDelete(s);
continue;
}
final t = FileSystemEntity.typeSync(s, followLinks: false);
if (t == FileSystemEntityType.directory) {
await Directory(s).delete(recursive: true);
} else if (t != FileSystemEntityType.notFound) {
await File(s).delete();
}
}
}