JJ_MKVMaker/lib/core/files/any_transfer.dart

274 lines
11 KiB
Dart

import 'dart:async';
import 'dart:io';
import '../../l10n/tr.dart';
import '../rsync_filter.dart';
import '../vfs.dart';
import 'path_rules.dart';
import 'transfer_control.dart';
import 'transfer_guard.dart';
import 'same_file.dart';
// 약속 (나누기 file_ops): 어느 저장소든 (로컬 · WebDAV · SFTP · FTP 등 vfs 가 아는 곳) 의 복사 · 이동 · 맞추기 · 크기.
// 한쪽이라도 원격이면 [FileOps] 가 이쪽을 부른다. 같은 서버 안의 이동은 서버가 옮긴다.
// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더 (맞추기는 [RsyncFilter] 거르기도). 출력: 만든 경로들 · 보낸 파일 수.
// 오류: 취소 → [FileOpCancelled] (만들던 파일은 지움) · 원본 없음 · 자기 안으로 → [FileSystemException] ·
// 맞추기에서 원본을 읽지 못함 → [SourceUnreadableException]. 원격 오류 (DavException 등) 는 그대로.
/// 전체 크기 (진행률용)
Future<int> anyTotalSize(List<String> paths) async {
var total = 0;
Future<void> walk(String path) async {
final st = await vStat(path);
if (st == null) return;
if (!st.isDir) {
total += st.size;
return;
}
for (final e in await vList(path)) {
if (e.isDir) {
await walk(e.path);
} else {
total += e.size;
}
}
}
for (final s in paths) {
await walk(s);
}
return total;
}
/// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행)
Future<void> _copyFile(TransferControl c, String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async {
c.check();
final input = await vOpenRead(src);
final relay = StreamController<List<int>>();
final pump = () async {
try {
await for (final chunk in input) {
if (c.cancelled) {
relay.addError(const FileOpCancelled());
break;
}
relay.add(chunk);
c.onBytes?.call(chunk.length);
onChunk?.call(chunk.length);
await c.throttle(chunk.length);
}
} catch (e, st) {
relay.addError(e, st);
} finally {
await relay.close();
}
}();
try {
await vWrite(target, relay.stream, length: size, modified: modified);
} finally {
await pump;
}
if (c.cancelled) {
try {
await vDelete(target);
} catch (_) {}
throw const FileOpCancelled();
}
c.onFileDone?.call(src);
}
/// [sources] 를 [destDir] 로 복사. 만든 경로들을 돌려준다
Future<List<String>> anyCopy(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final total = await anyTotalSize(sources);
var done = 0;
final made = <String>[];
Future<void> copyAny(String src, String target, {bool inner = false}) async {
c.check();
final st = await vStat(src);
if (st == null) throw FileSystemException(tr('원본이 없습니다'), src);
if (st.isDir) {
await vMkdirs(target);
for (final e in await vList(src)) {
await copyAny(e.path, vJoin(target, vBasename(e.path)), inner: true);
}
} else {
// 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 (WebDAV 도 로컬과 같게)
if (inner && c.skipNewerInMerge) {
final t = await vStat(target);
if (t != null && !t.isDir && SameFile.targetNewer(t.modified, st.modified)) {
c.keptNewer.add(src);
return;
}
}
await _copyFile(c, src, target, st.size, st.modified, (n) {
done += n;
onProgress?.call(src, done, total);
});
}
}
for (final s in sources) {
if (isSameOrInside(destDir, s) && await vIsDir(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;
}
/// 이동: 같은 WebDAV 서버 안이면 서버가 옮기고, 아니면 복사한 뒤 원본을 지운다
Future<List<String>> anyMove(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(vDirname(s), destDir)) {
made.add(s);
continue;
}
final target = await c.targetFor(s, destDir);
if (target == null) continue;
final exists = await vExists(target);
// 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - 덮어쓸 폴더는 합쳐야 하니 복사로
if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server && !(exists && await vIsDir(s))) {
if (exists) await vDelete(target);
await vRename(s, target);
made.add(target);
continue;
}
final keptBefore = c.keptNewer.length;
made.addAll(await anyCopy(c, [s], destDir, onProgress: onProgress));
final kept = c.keptNewer.sublist(keptBefore);
if (kept.isEmpty) {
await vDelete(s);
} else {
// 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다
await _deleteExcept(s, kept.toSet());
}
}
return made;
}
/// 229: [dir] (로컬 · WebDAV) 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도)
Future<bool> _deleteExcept(String dir, Set<String> keep) async {
var empty = true;
for (final e in await vList(dir)) {
if (e.isDir) {
if (!await _deleteExcept(e.path, keep)) empty = false;
} else if (keep.contains(e.path)) {
empty = false;
} else {
await vDelete(e.path);
}
}
if (empty) await vDelete(dir);
return empty;
}
/// 맞추기: [srcDir] 의 내용을 [dstDir] 에. [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만.
/// [filter]: rsync 옵션의 거르기 (미리 보기 sync_preview 와 같은 규칙). 지우기 전 원본 확인은 부르는 쪽이 한다
Future<int> anyMirror(TransferControl c, String srcDir, String dstDir,
{bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
var n = 0;
// 받는 쪽 시각이 올린 때인 곳은 원격이 낄 때만 (로컬끼리는 크기 + ±2초) - 미리 보기와 같은 판단 (F1 · F2)
final same = SameFile.forAppSync(remote: isDav(srcDir) || isDav(dstDir), move: strict);
// 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다
// (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다
Future<bool> hasExcluded(String dir, String rel) async {
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(dir);
} catch (_) {
return false;
}
for (final x in items) {
final r = '$rel/${vBasename(x.path)}';
if (filter.protects(r, isDir: x.isDir)) return true;
if (x.isDir && await hasExcluded(x.path, r)) return true;
}
return false;
}
Future<void> deleteInside(String dir, String rel) async {
for (final x in await vList(dir)) {
c.check();
final name = vBasename(x.path);
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
final r = '$rel/$name';
if (filter.protects(r, isDir: x.isDir)) continue;
if (x.isDir && await hasExcluded(x.path, r)) {
await deleteInside(x.path, r);
} else {
await vDelete(x.path);
}
}
}
Future<void> walk(String src, String dst, String rel) async {
c.check();
await vMkdirs(dst);
final there = {for (final e in await vList(dst)) vBasename(e.path): e};
final names = <String>{};
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(src, strict: true);
} catch (e) {
throw SourceUnreadableException(src, cause: e);
}
for (final e in items) {
final name = vBasename(e.path);
if (isAppTempName(name)) continue; // 78 보충
final r = rel.isEmpty ? name : '$rel/$name';
// 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded)
if (filter.excluded(r, isDir: e.isDir)) continue;
names.add(name);
final target = vJoin(dst, name);
// 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게)
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue;
if (e.isDir) {
await walk(e.path, target, r);
continue;
}
final t = there[name];
// 같은 파일 판단은 SameFile 하나 (미리 보기와 같음): 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로.
// 214: 이동 ([strict]) 은 거의 같은 시각일 때만 같다고 본다 (아니면 다시 보내고 지운다 - 내용이 다른데 지우지 않게)
if (t != null && !t.isDir && same.same(size: e.size, modified: e.modified, targetSize: t.size, targetModified: t.modified)) {
c.onSettled?.call(e.path, e.size, e.modified);
continue;
}
if (update && t != null && SameFile.targetNewer(t.modified, e.modified)) continue;
// M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만).
// 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다
if (t != null && t.isDir) {
if (!delete) {
c.dirConflicts.add(target);
continue;
}
await vDelete(target);
}
await _copyFile(c, e.path, target, e.size, e.modified, null);
n++;
c.onSettled?.call(e.path, e.size, e.modified);
}
if (delete) {
for (final t in there.entries) {
final name = t.key;
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
c.check();
final r = rel.isEmpty ? name : '$rel/$name';
// 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움)
if (filter.protects(r, isDir: t.value.isDir)) continue;
if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) {
await deleteInside(t.value.path, r);
continue;
}
await vDelete(t.value.path);
}
}
}
await walk(srcDir, dstDir, '');
return n;
}