From 3b3ce6386e1cc1af73f1e59a1022b4d5cccc50df Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:31:47 +0900 Subject: [PATCH] =?UTF-8?q?live=5Fsync=20=EB=82=98=EB=88=84=EA=B8=B0=20(69?= =?UTF-8?q?2=20=E2=86=92=20465):=20=EB=A7=9E=EC=B6=9C=20=EA=B2=83=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20(SyncDiff)=20=C2=B7=20rsync=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=EC=9D=B8=EC=88=98=20(SyncCommands)=20?= =?UTF-8?q?=EB=A5=BC=20lib/app/sync/=20=EB=AA=A8=EB=93=88=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveSync.diff → SyncDiff.of, LiveSync.rsyncArgsFor · robocopyArgsFor → SyncCommands.rsyncArgs · robocopyArgs (시험 5곳 고침) - 약속 시험: SyncDiff.of (보낼 것 · 지울 것 · 거르기 · 한도 · 바꾸지 않음) Co-Authored-By: Claude Opus 5.5 --- lib/app/live_sync.dart | 254 ++---------------------------- lib/app/sync/sync_commands.dart | 95 +++++++++++ lib/app/sync/sync_diff.dart | 168 ++++++++++++++++++++ test/live_sync_args_test.dart | 18 +-- test/mirror_safety_test.dart | 3 +- test/monitor_test.dart | 12 +- test/sync_diff_contract_test.dart | 44 ++++++ test/sync_tools_test.dart | 3 +- test/webdav_test.dart | 3 +- 9 files changed, 342 insertions(+), 258 deletions(-) create mode 100644 lib/app/sync/sync_commands.dart create mode 100644 lib/app/sync/sync_diff.dart create mode 100644 test/sync_diff_contract_test.dart diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index b49de28..c8e2d5f 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -3,59 +3,22 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - import 'package:path/path.dart' as p; import '../core/cron_window.dart'; import '../core/file_ops.dart'; -import '../core/sync_preview.dart' show isRsyncDeleteOption, isSourceRemovingOption; import '../core/sync_tools.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; -import '../platform/windows/rsync_installer.dart'; import 'app_controller.dart'; import 'settings.dart'; import '../core/rsync_filter.dart'; import '../core/rsync_launch.dart'; +import 'sync/sync_commands.dart'; +import 'sync/sync_diff.dart'; -/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. -/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) -/// - Android: 네이티브 라이브러리 폴더의 librsync.so (tool/build_rsync_android.sh, APK 의 jniLibs) -String? bundledRsync; +export 'sync/sync_commands.dart' show bundledRsync, findBundledRsync, rsyncExecutable, copyMethodAvailable; -Future findBundledRsync() async { - String? path; - if (Platform.isWindows) { - path = p.join(p.dirname(Platform.resolvedExecutable), 'rsync', 'rsync.exe'); - } else if (Platform.isAndroid) { - try { - final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); - if (dir != null) path = p.join(dir, 'librsync.so'); - } catch (_) {} - } - bundledRsync = path != null && File(path).existsSync() ? path : null; -} - -/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null). -/// - 직접 지정: 그 파일 -/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것 -Future rsyncExecutable(AppSettings s) async { - if (s.copy.rsyncSource == 'custom') { - return s.copy.rsyncPath.isNotEmpty && File(s.copy.rsyncPath).existsSync() ? s.copy.rsyncPath : null; - } - final b = bundledRsync; - if (b != null && File(b).existsSync()) return b; - if (!Platform.isWindows) return null; - return RsyncInstaller.installedPath(); -} - -/// 이 기기에서 쓸 수 있는 방법 -bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) { - CopyMethod.builtin => true, - CopyMethod.robocopy => Platform.isWindows, - CopyMethod.rsync => Platform.isWindows || s.copy.rsyncSource == 'custom' || bundledRsync != null, - }; /// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. /// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다. @@ -170,9 +133,9 @@ class LiveSync extends ChangeNotifier { ? RsyncFilter.parse(old) : RsyncFilter.none; List gone(List d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)]; - final a = gone(await diff(x, limit: 100000, filter: now)); + final a = gone(await SyncDiff.of(x, limit: 100000, filter: now)); if (a.isEmpty) return false; - final b = gone(await diff(x, limit: 100000, filter: before)); + final b = gone(await SyncDiff.of(x, limit: 100000, filter: before)); return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/'))); } @@ -195,7 +158,7 @@ class LiveSync extends ChangeNotifier { Future<(List, List)> previewDeletes(LiveSyncPair x) async { final excl = []; final del = [ - for (final d in await diff(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl)) + for (final d in await SyncDiff.of(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2) ]; return (del, excl); @@ -207,158 +170,6 @@ class LiveSync extends ChangeNotifier { await syncNow(x); } - /// 받는 쪽에만 있는 [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> diff(LiveSyncPair x, - {int limit = 500, RsyncFilter filter = RsyncFilter.none, List? excluded}) async { - if (isDav(x.source) || isDav(x.target)) return _diffV(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; - } - - /// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면 - static Future> _diffV(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; - } Future refreshPending(LiveSyncPair x) async { final k = keyOf(x); @@ -371,7 +182,7 @@ class LiveSync extends ChangeNotifier { problems[k] = SourceUnreadableException(x.source, cause: e); } try { - pending[k] = await diff(_pendingPair(x), filter: _filterOf(x)); + pending[k] = await SyncDiff.of(_pendingPair(x), filter: _filterOf(x)); await _countOnlyTarget(_pendingPair(x)); } catch (_) {} notifyListeners(); @@ -388,7 +199,7 @@ class LiveSync extends ChangeNotifier { } // 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것 try { - onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000, filter: _filterOf(x))) if (d.startsWith('− ')) d].length; + onlyTarget[k] = [for (final d in await SyncDiff.of(x.copyWith(delete: true), limit: 10000, filter: _filterOf(x))) if (d.startsWith('− ')) d].length; } catch (_) { onlyTarget.remove(k); // 세지 못하면 예전 글 } @@ -557,7 +368,7 @@ class LiveSync extends ChangeNotifier { if (ask) { final excl = []; final del = [ - for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2) + for (final d in await SyncDiff.of(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2) ]; if (del.isEmpty) { final now = x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x)); @@ -594,7 +405,7 @@ class LiveSync extends ChangeNotifier { : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); problems.remove(k); emptyDeletes.remove(k); - pending[k] = await diff(_pendingPair(asked), filter: _filterOf(asked)); + pending[k] = await SyncDiff.of(_pendingPair(asked), filter: _filterOf(asked)); await _countOnlyTarget(_pendingPair(asked)); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); } on SourceUnreadableException catch (e) { @@ -610,7 +421,7 @@ class LiveSync extends ChangeNotifier { ); if (e.empty) { try { - emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000, filter: _filterOf(asked))) if (d.startsWith('− ')) d.substring(2)]; + emptyDeletes[k] = [for (final d in await SyncDiff.of(asked, limit: 100000, filter: _filterOf(asked))) if (d.startsWith('− ')) d.substring(2)]; } catch (_) {} } else { emptyDeletes.remove(k); @@ -629,7 +440,7 @@ class LiveSync extends ChangeNotifier { Future _rsync(LiveSyncPair x, AppSettings s) async { final exe = await rsyncExecutable(s); if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)')); - final args = rsyncArgsFor(x, s, windows: Platform.isWindows); + final args = SyncCommands.rsyncArgs(x, s, windows: Platform.isWindows); await Directory(x.target).create(recursive: true); // P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch]) final launch = await prepareRsyncLaunch(args); @@ -644,48 +455,9 @@ class LiveSync extends ChangeNotifier { return RsyncOutput().feed('${r.stdout}\n').length; } - /// rsync 인수 (실시간 동기화). 지우기는 쌍의 "지우기 포함" 으로만 - 설정의 rsync 옵션에 --delete · --del 이 있어도 - /// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97) - static List rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [ - // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 - // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs) - ...rsyncFileOptionArgs([ - for (final o in splitOptions(s.copy.rsyncOptions)) - if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o, - ], windows: windows), - if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) - if (s.copy.bandwidthKBps > 0) '--bwlimit=${s.copy.bandwidthKBps}', - if (x.delete) '--delete', - // 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창 - // (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름) - if (x.delete && splitOptions(s.copy.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', - ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 - '${toCygwinPath(x.source, windows: windows)}/', - '${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20) - ]; - - /// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97 - static List robocopyArgsFor(LiveSyncPair x, AppSettings s) { - final opts = [ - for (final o in splitOptions(s.copy.robocopyOptions)) - if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o, - ]; - return [ - x.source, - x.target, - ...opts, - if (!opts.any((o) => o.toUpperCase() == '/E')) '/E', - // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions) - if (!robocopyLinkOption(opts)) '/XJ', - if (x.delete) '/PURGE', - ...appTempRobocopyExcludes, // 78 보충 - if (s.copy.bandwidthKBps > 0) '/IPG:${robocopyIpg(s.copy.bandwidthKBps)}', - '/BYTES', '/NJH', '/NJS', '/NDL', '/NP', - ]; - } Future _robocopy(LiveSyncPair x, AppSettings s) async { - final r = await Process.run('robocopy', robocopyArgsFor(x, s)); + final r = await Process.run('robocopy', SyncCommands.robocopyArgs(x, s)); if (!robocopyOk(r.exitCode)) throw ProcessException('robocopy', const [], '${r.stdout}'.trim(), r.exitCode); return RobocopyOutput().feed('${r.stdout}\n').length; } diff --git a/lib/app/sync/sync_commands.dart b/lib/app/sync/sync_commands.dart new file mode 100644 index 0000000..a79d661 --- /dev/null +++ b/lib/app/sync/sync_commands.dart @@ -0,0 +1,95 @@ +/// 약속: 실시간 동기화가 부르는 도구 - 앱에 들어 있는 rsync 찾기 · 지금 설정으로 쓸 rsync · 쓸 수 있는 복사 방법 · +/// 쌍마다 rsync · robocopy 인수 ([SyncCommands]). 지우기 (--delete · /PURGE) 는 쌍의 "지우기 포함" 으로만 넣는다 (97), +/// 원본을 지우는 옵션은 넣지 않는다 (107). +library; + +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; + +import '../../core/file_ops.dart'; +import '../../core/sync_preview.dart' show isRsyncDeleteOption, isSourceRemovingOption; +import '../../core/sync_tools.dart'; +import '../../platform/windows/rsync_installer.dart'; +import '../settings.dart'; + +/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. +/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) +/// - Android: 네이티브 라이브러리 폴더의 librsync.so (tool/build_rsync_android.sh, APK 의 jniLibs) +String? bundledRsync; + +Future findBundledRsync() async { + String? path; + if (Platform.isWindows) { + path = p.join(p.dirname(Platform.resolvedExecutable), 'rsync', 'rsync.exe'); + } else if (Platform.isAndroid) { + try { + final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); + if (dir != null) path = p.join(dir, 'librsync.so'); + } catch (_) {} + } + bundledRsync = path != null && File(path).existsSync() ? path : null; +} + +/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null). +/// - 직접 지정: 그 파일 +/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것 +Future rsyncExecutable(AppSettings s) async { + if (s.copy.rsyncSource == 'custom') { + return s.copy.rsyncPath.isNotEmpty && File(s.copy.rsyncPath).existsSync() ? s.copy.rsyncPath : null; + } + final b = bundledRsync; + if (b != null && File(b).existsSync()) return b; + if (!Platform.isWindows) return null; + return RsyncInstaller.installedPath(); +} + +/// 이 기기에서 쓸 수 있는 방법 +bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) { + CopyMethod.builtin => true, + CopyMethod.robocopy => Platform.isWindows, + CopyMethod.rsync => Platform.isWindows || s.copy.rsyncSource == 'custom' || bundledRsync != null, + }; + +class SyncCommands { + /// rsync 인수 (실시간 동기화). 지우기는 쌍의 "지우기 포함" 으로만 - 설정의 rsync 옵션에 --delete · --del 이 있어도 + /// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97) + static List rsyncArgs(LiveSyncPair x, AppSettings s, {required bool windows}) => [ + // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 + // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs) + ...rsyncFileOptionArgs([ + for (final o in splitOptions(s.copy.rsyncOptions)) + if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o, + ], windows: windows), + if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) + if (s.copy.bandwidthKBps > 0) '--bwlimit=${s.copy.bandwidthKBps}', + if (x.delete) '--delete', + // 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창 + // (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름) + if (x.delete && splitOptions(s.copy.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', + ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 + '${toCygwinPath(x.source, windows: windows)}/', + '${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20) + ]; + + /// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97 + static List robocopyArgs(LiveSyncPair x, AppSettings s) { + final opts = [ + for (final o in splitOptions(s.copy.robocopyOptions)) + if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o, + ]; + return [ + x.source, + x.target, + ...opts, + if (!opts.any((o) => o.toUpperCase() == '/E')) '/E', + // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions) + if (!robocopyLinkOption(opts)) '/XJ', + if (x.delete) '/PURGE', + ...appTempRobocopyExcludes, // 78 보충 + if (s.copy.bandwidthKBps > 0) '/IPG:${robocopyIpg(s.copy.bandwidthKBps)}', + '/BYTES', '/NJH', '/NJS', '/NDL', '/NP', + ]; + } +} diff --git a/lib/app/sync/sync_diff.dart b/lib/app/sync/sync_diff.dart new file mode 100644 index 0000000..01b79ef --- /dev/null +++ b/lib/app/sync/sync_diff.dart @@ -0,0 +1,168 @@ +/// 약속: 실시간 동기화 쌍의 "맞출 것" 계산 (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; + } +} diff --git a/test/live_sync_args_test.dart b/test/live_sync_args_test.dart index 160b130..16f2438 100644 --- a/test/live_sync_args_test.dart +++ b/test/live_sync_args_test.dart @@ -1,33 +1,33 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/sync/sync_commands.dart'; import 'package:jj_mkvmaker/app/settings.dart'; /// 97: 실시간 동기화의 지우기는 쌍의 "지우기 포함" 으로만 (설정 옵션의 --delete · --del · /MIR · /PURGE 는 빼고) void main() { test('rsync: 설정 옵션의 지우기는 빼고, 쌍이 지우기 포함일 때만 --delete 하나', () { final s = AppSettings()..copy.rsyncOptions = '-avPog --delete --del --delete-after'; - final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false); + final off = SyncCommands.rsyncArgs(LiveSyncPair('/a', '/b'), s, windows: false); expect(off.where((o) => o.startsWith('--del')), isEmpty); - final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); + final on = SyncCommands.rsyncArgs(LiveSyncPair('/a', '/b', delete: true), s, windows: false); expect(on.where((o) => o.startsWith('--del')), ['--delete']); }); test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () { final s = AppSettings()..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; - final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false); + final off = SyncCommands.rsyncArgs(LiveSyncPair('/a', '/b'), s, windows: false); expect(off.where((o) => o.startsWith('--del')), isEmpty); - final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); + final on = SyncCommands.rsyncArgs(LiveSyncPair('/a', '/b', delete: true), s, windows: false); expect(on.where((o) => o.startsWith('--del')), ['--delete', '--delete-excluded']); expect(on, contains('--exclude=*.tmp')); }); test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () { final s = AppSettings()..copy.robocopyOptions = '/MIR /PURGE /R:2'; - final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList(); + final off = SyncCommands.robocopyArgs(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList(); expect(off, isNot(contains('/MIR'))); expect(off, isNot(contains('/PURGE'))); expect(off, contains('/E')); - final on = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b', delete: true), s).map((o) => o.toUpperCase()).toList(); + final on = SyncCommands.robocopyArgs(LiveSyncPair(r'C:\a', r'D:\b', delete: true), s).map((o) => o.toUpperCase()).toList(); expect(on.where((o) => o == '/PURGE'), hasLength(1)); }); @@ -35,9 +35,9 @@ void main() { final s = AppSettings() ..copy.rsyncOptions = '-avPog --remove-source-files --remove-sent-files' ..copy.robocopyOptions = '/E /MOV /MOVE'; - final r = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); + final r = SyncCommands.rsyncArgs(LiveSyncPair('/a', '/b', delete: true), s, windows: false); expect(r.where((o) => o.startsWith('--remove')), isEmpty); - final w = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:', r'D:', delete: true), s).map((o) => o.toUpperCase()).toList(); + final w = SyncCommands.robocopyArgs(LiveSyncPair(r'C:', r'D:', delete: true), s).map((o) => o.toUpperCase()).toList(); expect(w, isNot(contains('/MOV'))); expect(w, isNot(contains('/MOVE'))); }); diff --git a/test/mirror_safety_test.dart b/test/mirror_safety_test.dart index 8a42daa..4f28c57 100644 --- a/test/mirror_safety_test.dart +++ b/test/mirror_safety_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/sync/sync_diff.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; @@ -55,7 +56,7 @@ void main() { await live.syncNow(pair); expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤'); expect(count(), 2); - expect(await LiveSync.diff(pair), isEmpty); + expect(await SyncDiff.of(pair), isEmpty); live.dispose(); }); diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 0638bb1..7cbc96b 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -6,6 +6,8 @@ import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/copy_center.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show transferProblem; import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/sync/sync_commands.dart'; +import 'package:jj_mkvmaker/app/sync/sync_diff.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/cron_window.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; @@ -155,7 +157,7 @@ void main() { // 일정: 지금이 아닌 시간 (지금 시 + 2) final off = (DateTime.now().hour + 2) % 24; final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true); - expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt'])); + expect(await SyncDiff.of(pair), unorderedEquals(['a.txt', 'sub/b.txt'])); expect(LiveSync.activeNow(pair), isFalse); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c)..start(); @@ -169,7 +171,7 @@ void main() { await live.syncNow(pair); expect(live.pending[LiveSync.keyOf(pair)], isEmpty); File(p.join(target, 'extra.txt')).writeAsStringSync('x'); - expect(await LiveSync.diff(pair), ['− extra.txt']); + expect(await SyncDiff.of(pair), ['− extra.txt']); live.dispose(); }); @@ -179,11 +181,11 @@ void main() { File(p.join(src, '~jjren_abc_0')).writeAsStringSync('x'); Directory(target).createSync(); File(p.join(target, '~jjback_abc_1')).writeAsStringSync('x'); - final pending = await LiveSync.diff(pair); + final pending = await SyncDiff.of(pair); expect(pending.where((x) => x.contains('~jj')), isEmpty, reason: '$pending'); - final args = LiveSync.rsyncArgsFor(pair, c.settings, windows: true); + final args = SyncCommands.rsyncArgs(pair, c.settings, windows: true); expect(args, containsAll(['--exclude=~jjren_*', '--exclude=~jjback_*'])); - expect(LiveSync.robocopyArgsFor(pair, c.settings), containsAll(['/XF', '~jjren_*', '~jjback_*'])); + expect(SyncCommands.robocopyArgs(pair, c.settings), containsAll(['/XF', '~jjren_*', '~jjback_*'])); }); }); diff --git a/test/sync_diff_contract_test.dart b/test/sync_diff_contract_test.dart new file mode 100644 index 0000000..0c30431 --- /dev/null +++ b/test/sync_diff_contract_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/app/sync/sync_diff.dart'; +import 'package:jj_mkvmaker/core/rsync_filter.dart'; +import 'package:path/path.dart' as p; + +/// live_sync 를 나눈 SyncDiff 의 약속 (실제 폴더로): 보낼 것 · 지울 것 ("− ") · 거르기 · 개수 한도 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_diff_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + void put(String path, String text) => File(p.join(tmp.path, path)) + ..createSync(recursive: true) + ..writeAsStringSync(text); + + test('약속 SyncDiff.of: 새 것 · 크기가 다른 것은 보낼 것, 지우기 포함이면 받는 쪽에만 있는 것은 "− ", 거른 것은 세지 않음', () async { + put('src/new.txt', 'n'); + put('src/same.txt', 'same'); + put('src/changed.txt', 'longer'); + put('src/sub/a.log', 'skip'); + put('dst/same.txt', 'same'); + put('dst/changed.txt', 'x'); + put('dst/only.txt', 'gone'); + final now = DateTime.now(); + for (final f in ['src/same.txt', 'dst/same.txt']) { + File(p.join(tmp.path, f)).setLastModifiedSync(now); + } + final pair = LiveSyncPair(p.join(tmp.path, 'src'), p.join(tmp.path, 'dst')); + final send = await SyncDiff.of(pair, filter: RsyncFilter.parse('--exclude=*.log')); + expect(send.toSet(), {'new.txt', 'changed.txt'}, reason: '같은 것 · 거른 것 (*.log) 은 빠지고, 지우기 없음'); + final mirror = await SyncDiff.of(pair.copyWith(delete: true)); + expect(mirror, contains('− only.txt')); + expect(mirror, contains('sub/a.log'), reason: '거르기가 없으면 보낸다'); + expect((await SyncDiff.of(pair.copyWith(delete: true), limit: 1)).length, 1); + expect(File(p.join(tmp.path, 'dst', 'only.txt')).existsSync(), isTrue, reason: '계산만 하고 바꾸지 않는다'); + }); + + test('약속 SyncDiff.of: 원본이 없으면 빈 목록', () async { + expect(await SyncDiff.of(LiveSyncPair(p.join(tmp.path, 'none'), p.join(tmp.path, 'dst'), delete: true)), isEmpty); + }); +} diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index ea2f747..ea66410 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/sync/sync_commands.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/transfer_job.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; @@ -117,7 +118,7 @@ void main() { expect(rc('/E /SJ'), isNot(contains('/XJ'))); expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']); expect([robocopySkipsLinks(['/E']), robocopySkipsLinks(['/E', '/SJ'])], [true, false]); - final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..copy.robocopyOptions = '/E'); + final live = SyncCommands.robocopyArgs(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..copy.robocopyOptions = '/E'); expect(live, contains('/XJ')); }); diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 6aec386..3aa7602 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/sync/sync_diff.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; @@ -157,7 +158,7 @@ void main() { final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); - expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); + expect(await SyncDiff.of(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); await live.syncNow(pair); expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');