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 anyTotalSize(List paths) async { var total = 0; Future 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 _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>(); 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> anyCopy(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { final total = await anyTotalSize(sources); var done = 0; final made = []; Future 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.keepNewer(skipNewer: true, targetModified: t.modified, 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> anyMove(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { final made = []; 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 _deleteExcept(String dir, Set 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 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 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 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 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 = {}; 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; }