import 'dart:io' show FileSystemEntity; import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException; import 'rsync_filter.dart'; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; export 'sync_tools.dart' show isSourceRemovingOption; import 'vfs.dart'; /// rsync (Rsync 화면의 → · ← · ⇄) 를 실행하기 전에 무엇이 바뀌는지 미리 보기 (72). enum PreviewAction { /// 받는 쪽에 없어 새로 복사 add, /// 받는 쪽에도 있지만 달라서 덮어씀 update, /// 받는 쪽에만 있음 (지우기 없이 그대로 남음) onlyTarget, /// 받는 쪽에만 있어 지워짐 (--delete) delete, /// 229: 받는 쪽이 더 새것이라 건너뜀 (-u · 환경 설정 "받는 쪽이 더 새것이면 건너뛰기") skipNewer, /// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214) skipLink, } class PreviewItem { /// 원본 폴더 기준 상대 경로 ("/" 구분) final String rel; final PreviewAction action; /// true = 왼쪽 → 오른쪽, false = 오른쪽 → 왼쪽 final bool toRight; final bool isDir; final int size; const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0, this.newer = false}); /// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀) final bool newer; } /// rsync 의 지우기 옵션인지: --delete · --delete-before/during/delay/after/excluded/missing-args · --del (별칭) /// (97: --del 을 놓치면 확인 · 잠금 없이 지운다) bool isRsyncDeleteOption(String o) => o == '--del' || o.startsWith('--delete') || o.startsWith('--del='); /// rsync 옵션에 지우기 (--delete 계열) 가 있는지 bool optionsDelete(String options) => splitOptions(options).any(isRsyncDeleteOption); /// 옵션 글을 낱말로 나누되 따옴표는 그대로 둔 원래 글 조각 (106: --exclude="My Folder" 가 갈라지지 않게) List rawOptionTokens(String s) { final out = []; final cur = StringBuffer(); String? quote; for (final ch in s.split('')) { if (quote != null) { cur.write(ch); if (ch == quote) quote = null; } else if (ch == '"' || ch == "'") { quote = ch; cur.write(ch); } else if (ch.trim().isEmpty) { if (cur.isNotEmpty) out.add(cur.toString()); cur.clear(); } else { cur.write(ch); } } if (cur.isNotEmpty) out.add(cur.toString()); return out; } /// 지우기 옵션을 뺀 rsync 옵션 (⇄ 의 두 번째 ← 처럼 지우면 안 되는 실행). 다른 옵션은 따옴표까지 원래 글 그대로 String withoutDeleteOptions(String options) => rawOptionTokens(options) .where((raw) => !splitOptions(raw).any(isRsyncDeleteOption)) .join(' '); /// 178: 옵션 글에서 원본을 지우는 옵션을 뺀다 (원본 지우기는 [원본 파일 지우기] · [이동] 으로만 - 보이는 것과 실행이 같게) String withoutSourceRemovingOptions(String options) => rawOptionTokens(options) .where((raw) => !splitOptions(raw).any(isSourceRemovingOption)) .join(' '); /// 옵션 글에 원본을 지우는 옵션이 들어 있는지 (환경 설정의 rsync 옵션 등) bool optionsRemoveSource(String options) => splitOptions(options).any(isSourceRemovingOption); /// rsync 옵션에 -u (받는 쪽이 더 새 파일은 건너뜀) 가 있는지 bool optionsUpdate(String options) => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). /// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)", /// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다. /// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다). /// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움) Future> previewSync(String src, String dst, {required bool toRight, bool delete = false, bool update = false, bool skipLinks = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async { final out = []; // 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김) Future deleteInside(String dir, String rel) async { List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(dir); } catch (_) { return; } for (final x in items) { if (out.length >= limit) return; final name = vBasename(x.path); if (isAppTempName(name)) continue; final r = '$rel/$name'; if (filter.excluded(r, isDir: x.isDir)) continue; if (x.isDir && await _hasExcluded(x.path, r, filter)) { await deleteInside(x.path, r); } else { out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: x.isDir, size: x.size)); } } } Future walk(String s, String d, String rel) async { if (out.length >= limit) return; final there = {}; try { for (final e in await vList(d)) { there[vBasename(e.path)] = e; } } catch (_) {} // 받는 쪽 폴더가 아직 없음 List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(s, strict: true); } catch (e) { // 맨 위 원본을 읽지 못하면 비교 실패 (93: 지우는 실행을 막는다). 안쪽 폴더는 그 폴더의 "지워짐" 만 세지 않는다 if (rel.isEmpty) throw SourceUnreadableException(src, cause: e); return; } final names = {}; for (final e in items) { if (out.length >= limit) return; final name = vBasename(e.path); if (isPartialFile(name) || isAppTempName(name)) continue; final r = rel.isEmpty ? name : '$rel/$name'; // 177: 거른 것은 보내지 않는다 - 받는 쪽의 같은 이름은 아래에서 (지우지 않음 / --delete-excluded) if (filter.excluded(r, isDir: e.isDir)) continue; names.add(name); final t = there[name]; if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) { if (skipLinks) { out.add(PreviewItem(r, PreviewAction.skipLink, toRight: toRight, isDir: e.isDir)); } else if (t == null) { out.add(PreviewItem(r, PreviewAction.add, toRight: toRight)); } continue; } if (e.isDir) { if (t == null) out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, isDir: true)); await walk(e.path, vJoin(d, name), r); continue; } if (t == null) { out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); } else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) { // -u: 받는 쪽이 더 새 것이면 건너뜀. 229: 건너뛰는 것도 · 더 새 것을 덮어쓰는 것도 목록에 따로 final newer = t.modified.isAfter(e.modified.add(const Duration(seconds: 2))); if (update && newer) { out.add(PreviewItem(r, PreviewAction.skipNewer, toRight: toRight, size: e.size)); continue; } out.add(PreviewItem(r, PreviewAction.update, toRight: toRight, size: e.size, newer: newer)); } } for (final e in there.values) { final name = vBasename(e.path); // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음) if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue; final r = rel.isEmpty ? name : '$rel/$name'; if (filter.excluded(r, isDir: e.isDir)) { // 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다) if (delete && filter.deleteExcluded) { out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: e.isDir, size: e.size)); } continue; } // 177 (28 확인): 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고 // 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만 if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) { await deleteInside(e.path, r); continue; } out.add(PreviewItem(r, delete ? PreviewAction.delete : PreviewAction.onlyTarget, toRight: toRight, isDir: e.isDir, size: e.size)); } } await walk(src, dst, ''); return out; } /// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다) Future _hasExcluded(String dir, String rel, RsyncFilter filter) 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 name = vBasename(x.path); if (isPartialFile(name) || isAppTempName(name)) continue; final r = '$rel/$name'; if (filter.excluded(r, isDir: x.isDir)) return true; if (x.isDir && await _hasExcluded(x.path, r, filter)) return true; } return false; } /// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고, /// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만). /// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음) Future> previewBoth(String left, String right, {bool delete = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async { final a = await previewSync(left, right, toRight: true, update: true, delete: delete, filter: filter, limit: limit); final b = await previewSync(right, left, toRight: false, update: true, filter: filter, limit: limit); final gone = {for (final x in a) if (x.action == PreviewAction.delete) x.rel}; final seen = {}; return [ for (final x in a) if (x.action == PreviewAction.delete && seen.add(x.rel)) x, for (final x in [...a, ...b]) if ((x.action == PreviewAction.add || x.action == PreviewAction.update) && !gone.any((g) => x.rel == g || x.rel.startsWith('$g/')) && seen.add(x.rel)) x, ]; }