/// 약속: 실시간 동기화 쌍의 "맞출 것" 계산 (rsync 가 할 일을 미리 보기). [SyncDiff.of] 는 보낼 것 (상대 경로) 과 지울 것 ("− 경로") /// 을 [limit] 개까지 준다 - 옵션의 거르기 ([RsyncFilter]) 를 따르고, 받는 쪽에서 지키는 것은 지우지 않는다. 한쪽이라도 dav:// 면 WebDAV 판. /// 파일 · 폴더를 읽기만 한다 (바꾸지 않음). 원본을 읽지 못한 폴더는 세지 않는다. library; import 'dart:io'; import 'package:path/path.dart' as p; import '../../core/file_ops.dart'; import '../../core/rsync_filter.dart'; import '../../core/vfs.dart'; import '../settings.dart'; class SyncDiff { /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게): /// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만. /// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고, /// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다. /// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음) static Future _gone(String path, String r, bool isDir, RsyncFilter filter, List out, int limit, [List? excluded]) async { if (out.length >= limit) return; // 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다 if (filter.protects(r, isDir: isDir)) return; Future mark() async { // --delete-excluded 때문에 지워지는 것: 보내는 쪽에서 거른 것 · 그런 것이 든 폴더 if (excluded != null && filter.deleteExcluded && (filter.excluded(r, isDir: isDir) || (isDir && await _hasExcluded(path, r, filter, sender: true)))) { excluded.add(r); } } if (!isDir || !filter.protectsAny || !await _hasExcluded(path, r, filter)) { out.add('− $r'); await mark(); return; } List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(path); } catch (_) { return; } for (final e in items) { final name = vBasename(e.path); if (name.endsWith('.jjsync') || isAppTempName(name)) continue; await _gone(e.path, '$r/$name', e.isDir, filter, out, limit, excluded); } } /// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지 static Future _hasExcluded(String dir, String rel, RsyncFilter filter, {bool sender = false}) async { List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(dir); } catch (_) { return false; } for (final e in items) { final r = '$rel/${vBasename(e.path)}'; if (sender ? filter.excluded(r, isDir: e.isDir) : filter.protects(r, isDir: e.isDir)) return true; if (e.isDir && await _hasExcluded(e.path, r, filter, sender: sender)) return true; } return false; } /// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone]) static Future> of(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none, List? excluded}) async { if (isDav(x.source) || isDav(x.target)) return _ofDav(x, limit: limit, filter: filter, excluded: excluded); final out = []; Future walk(String src, String dst, String rel) async { if (out.length >= limit) return; final names = {}; List items; try { items = await Directory(src).list(followLinks: false).toList(); } catch (_) { return; // 원본을 읽지 못함: 이 폴더의 "지울 것" 은 세지 않는다 } for (final e in items) { if (out.length >= limit) return; final name = p.basename(e.path); if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다 final r = rel.isEmpty ? name : '$rel/$name'; // 10/20 (15, 28 의 장면으로 찾음): rsync 쌍은 옵션의 거르기를 따른다 - 예전에는 rsync 가 보내지 않는 것을 // 맞출 것으로 늘 세고, 지우지 않는 것 (받는 쪽의 거른 것) 을 지울지 물었다 if (filter.excluded(r, isDir: e is Directory)) continue; names.add(name); final t = p.join(dst, name); if (e is Directory) { await walk(e.path, t, r); } else if (e is File) { final st = await e.stat(); final tf = File(t); if (!await tf.exists()) { out.add(r); continue; } final ts = await tf.stat(); if (ts.size != st.size || ts.modified.difference(st.modified).inSeconds.abs() > 2) out.add(r); } } if (x.delete && await Directory(dst).exists()) { await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { final name = p.basename(e.path); if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit, excluded); } } } if (await Directory(x.source).exists()) await walk(x.source, x.target, ''); return out; } /// [of] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면 static Future> _ofDav(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none, List? excluded}) async { final out = []; Future walk(String src, String dst, String rel) async { if (out.length >= limit) return; final there = {}; try { for (final e in await vList(dst)) { there[vBasename(e.path)] = e; } } catch (_) {} // 대상 폴더가 아직 없음 final names = {}; List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(src, strict: true); } catch (_) { return; // 원본을 읽지 못함: "지울 것" 을 세지 않는다 } for (final e in items) { if (out.length >= limit) return; final name = vBasename(e.path); if (isAppTempName(name)) continue; // 78 보충 final r = rel.isEmpty ? name : '$rel/$name'; if (filter.excluded(r, isDir: e.isDir)) continue; // 관리자 지시 (10/19): 맞추기 (_mirrorV) 와 같은 거르기 names.add(name); if (e.isDir) { await walk(e.path, vJoin(dst, name), r); continue; } final t = there[name]; // M1: 받는 쪽이 같은 이름의 폴더면 지우기 포함일 때만 바뀐다 (아니면 건너뜀 - 기다리는 것으로 세지 않음) if (t != null && t.isDir && !x.delete) continue; if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) { out.add(r); } } if (x.delete) { for (final t in there.entries) { final name = t.key; if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded); } } } if (await vExists(x.source)) await walk(x.source, x.target, ''); return out; } }