live_sync 나누기 (692 → 465): 맞출 것 계산 (SyncDiff) · rsync 찾기와 인수 (SyncCommands) 를 lib/app/sync/ 모듈로

- LiveSync.diff → SyncDiff.of, LiveSync.rsyncArgsFor · robocopyArgsFor → SyncCommands.rsyncArgs · robocopyArgs (시험 5곳 고침)
- 약속 시험: SyncDiff.of (보낼 것 · 지울 것 · 거르기 · 한도 · 바꾸지 않음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 03:31:47 +09:00
parent db38d0ddbf
commit 3b3ce6386e
9 changed files with 342 additions and 258 deletions

View File

@ -3,59 +3,22 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../core/cron_window.dart'; import '../core/cron_window.dart';
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/sync_preview.dart' show isRsyncDeleteOption, isSourceRemovingOption;
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../platform/windows/rsync_installer.dart';
import 'app_controller.dart'; import 'app_controller.dart';
import 'settings.dart'; import 'settings.dart';
import '../core/rsync_filter.dart'; import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart'; import '../core/rsync_launch.dart';
import 'sync/sync_commands.dart';
import 'sync/sync_diff.dart';
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. export 'sync/sync_commands.dart' show bundledRsync, findBundledRsync, rsyncExecutable, copyMethodAvailable;
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
/// - Android: 네이티브 라이브러리 폴더의 librsync.so (tool/build_rsync_android.sh, APK 의 jniLibs)
String? bundledRsync;
Future<void> 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<String>('nativeLibDir');
if (dir != null) path = p.join(dir, 'librsync.so');
} catch (_) {}
}
bundledRsync = path != null && File(path).existsSync() ? path : null;
}
/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null).
/// - 직접 지정: 그 파일
/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것
Future<String?> 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 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. /// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다.
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다. /// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다.
@ -170,9 +133,9 @@ class LiveSync extends ChangeNotifier {
? RsyncFilter.parse(old) ? RsyncFilter.parse(old)
: RsyncFilter.none; : RsyncFilter.none;
List<String> gone(List<String> d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)]; List<String> gone(List<String> 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; 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/'))); return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/')));
} }
@ -195,7 +158,7 @@ class LiveSync extends ChangeNotifier {
Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async { Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
final excl = <String>[]; final excl = <String>[];
final del = [ 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) if (d.startsWith('− ')) d.substring(2)
]; ];
return (del, excl); return (del, excl);
@ -207,158 +170,6 @@ class LiveSync extends ChangeNotifier {
await syncNow(x); await syncNow(x);
} }
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다.
/// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음)
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
[List<String>? excluded]) async {
if (out.length >= limit) return;
// 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다
if (filter.protects(r, isDir: isDir)) return;
Future<void> 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<bool> _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<List<String>> diff(LiveSyncPair x,
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded);
final out = <String>[];
Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return;
final names = <String>{};
List<FileSystemEntity> 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<List<String>> _diffV(LiveSyncPair x,
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
final out = <String>[];
Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return;
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
try {
for (final e in await vList(dst)) {
there[vBasename(e.path)] = e;
}
} catch (_) {} // 대상 폴더가 아직 없음
final names = <String>{};
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<void> refreshPending(LiveSyncPair x) async { Future<void> refreshPending(LiveSyncPair x) async {
final k = keyOf(x); final k = keyOf(x);
@ -371,7 +182,7 @@ class LiveSync extends ChangeNotifier {
problems[k] = SourceUnreadableException(x.source, cause: e); problems[k] = SourceUnreadableException(x.source, cause: e);
} }
try { try {
pending[k] = await diff(_pendingPair(x), filter: _filterOf(x)); pending[k] = await SyncDiff.of(_pendingPair(x), filter: _filterOf(x));
await _countOnlyTarget(_pendingPair(x)); await _countOnlyTarget(_pendingPair(x));
} catch (_) {} } catch (_) {}
notifyListeners(); notifyListeners();
@ -388,7 +199,7 @@ class LiveSync extends ChangeNotifier {
} }
// 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것 // 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것
try { 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 (_) { } catch (_) {
onlyTarget.remove(k); // 세지 못하면 예전 글 onlyTarget.remove(k); // 세지 못하면 예전 글
} }
@ -557,7 +368,7 @@ class LiveSync extends ChangeNotifier {
if (ask) { if (ask) {
final excl = <String>[]; final excl = <String>[];
final del = [ 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) { if (del.isEmpty) {
final now = x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x)); 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])); : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
problems.remove(k); problems.remove(k);
emptyDeletes.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)); await _countOnlyTarget(_pendingPair(asked));
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
} on SourceUnreadableException catch (e) { } on SourceUnreadableException catch (e) {
@ -610,7 +421,7 @@ class LiveSync extends ChangeNotifier {
); );
if (e.empty) { if (e.empty) {
try { 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 (_) {} } catch (_) {}
} else { } else {
emptyDeletes.remove(k); emptyDeletes.remove(k);
@ -629,7 +440,7 @@ class LiveSync extends ChangeNotifier {
Future<int> _rsync(LiveSyncPair x, AppSettings s) async { Future<int> _rsync(LiveSyncPair x, AppSettings s) async {
final exe = await rsyncExecutable(s); final exe = await rsyncExecutable(s);
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)')); 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); await Directory(x.target).create(recursive: true);
// P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch]) // P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
final launch = await prepareRsyncLaunch(args); final launch = await prepareRsyncLaunch(args);
@ -644,48 +455,9 @@ class LiveSync extends ChangeNotifier {
return RsyncOutput().feed('${r.stdout}\n').length; return RsyncOutput().feed('${r.stdout}\n').length;
} }
/// rsync 인수 (실시간 동기화). 지우기는 쌍의 "지우기 포함" 으로만 - 설정의 rsync 옵션에 --delete · --del 이 있어도
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
static List<String> 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<String> 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<int> _robocopy(LiveSyncPair x, AppSettings s) async { Future<int> _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); if (!robocopyOk(r.exitCode)) throw ProcessException('robocopy', const [], '${r.stdout}'.trim(), r.exitCode);
return RobocopyOutput().feed('${r.stdout}\n').length; return RobocopyOutput().feed('${r.stdout}\n').length;
} }

View File

@ -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<void> 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<String>('nativeLibDir');
if (dir != null) path = p.join(dir, 'librsync.so');
} catch (_) {}
}
bundledRsync = path != null && File(path).existsSync() ? path : null;
}
/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null).
/// - 직접 지정: 그 파일
/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것
Future<String?> 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<String> 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<String> 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',
];
}
}

168
lib/app/sync/sync_diff.dart Normal file
View File

@ -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<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
[List<String>? excluded]) async {
if (out.length >= limit) return;
// 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다
if (filter.protects(r, isDir: isDir)) return;
Future<void> 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<bool> _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<List<String>> of(LiveSyncPair x,
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
if (isDav(x.source) || isDav(x.target)) return _ofDav(x, limit: limit, filter: filter, excluded: excluded);
final out = <String>[];
Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return;
final names = <String>{};
List<FileSystemEntity> 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<List<String>> _ofDav(LiveSyncPair x,
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
final out = <String>[];
Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return;
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
try {
for (final e in await vList(dst)) {
there[vBasename(e.path)] = e;
}
} catch (_) {} // 대상 폴더가 아직 없음
final names = <String>{};
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;
}
}

View File

@ -1,33 +1,33 @@
import 'package:flutter_test/flutter_test.dart'; 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'; import 'package:jj_mkvmaker/app/settings.dart';
/// 97: 실시간 동기화의 지우기는 쌍의 "지우기 포함" 으로만 (설정 옵션의 --delete · --del · /MIR · /PURGE 는 빼고) /// 97: 실시간 동기화의 지우기는 쌍의 "지우기 포함" 으로만 (설정 옵션의 --delete · --del · /MIR · /PURGE 는 빼고)
void main() { void main() {
test('rsync: 설정 옵션의 지우기는 빼고, 쌍이 지우기 포함일 때만 --delete 하나', () { test('rsync: 설정 옵션의 지우기는 빼고, 쌍이 지우기 포함일 때만 --delete 하나', () {
final s = AppSettings()..copy.rsyncOptions = '-avPog --delete --del --delete-after'; 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); 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']); expect(on.where((o) => o.startsWith('--del')), ['--delete']);
}); });
test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () { test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () {
final s = AppSettings()..copy.rsyncOptions = '-av --exclude=*.tmp --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); 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.where((o) => o.startsWith('--del')), ['--delete', '--delete-excluded']);
expect(on, contains('--exclude=*.tmp')); expect(on, contains('--exclude=*.tmp'));
}); });
test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () { test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () {
final s = AppSettings()..copy.robocopyOptions = '/MIR /PURGE /R:2'; 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('/MIR')));
expect(off, isNot(contains('/PURGE'))); expect(off, isNot(contains('/PURGE')));
expect(off, contains('/E')); 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)); expect(on.where((o) => o == '/PURGE'), hasLength(1));
}); });
@ -35,9 +35,9 @@ void main() {
final s = AppSettings() final s = AppSettings()
..copy.rsyncOptions = '-avPog --remove-source-files --remove-sent-files' ..copy.rsyncOptions = '-avPog --remove-source-files --remove-sent-files'
..copy.robocopyOptions = '/E /MOV /MOVE'; ..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); 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('/MOV')));
expect(w, isNot(contains('/MOVE'))); expect(w, isNot(contains('/MOVE')));
}); });

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.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/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
@ -55,7 +56,7 @@ void main() {
await live.syncNow(pair); await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤'); expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤');
expect(count(), 2); expect(count(), 2);
expect(await LiveSync.diff(pair), isEmpty); expect(await SyncDiff.of(pair), isEmpty);
live.dispose(); live.dispose();
}); });

View File

@ -6,6 +6,8 @@ import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/copy_center.dart'; import 'package:jj_mkvmaker/app/copy_center.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show transferProblem; import 'package:jj_mkvmaker/core/file_ops.dart' show transferProblem;
import 'package:jj_mkvmaker/app/live_sync.dart'; 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/app/settings.dart';
import 'package:jj_mkvmaker/core/cron_window.dart'; import 'package:jj_mkvmaker/core/cron_window.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
@ -155,7 +157,7 @@ void main() {
// 일정: 지금이 아닌 시간 (지금 시 + 2) // 일정: 지금이 아닌 시간 (지금 시 + 2)
final off = (DateTime.now().hour + 2) % 24; final off = (DateTime.now().hour + 2) % 24;
final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true); 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); expect(LiveSync.activeNow(pair), isFalse);
c.settings.copy.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c)..start(); final live = LiveSync(c)..start();
@ -169,7 +171,7 @@ void main() {
await live.syncNow(pair); await live.syncNow(pair);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty); expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
File(p.join(target, 'extra.txt')).writeAsStringSync('x'); File(p.join(target, 'extra.txt')).writeAsStringSync('x');
expect(await LiveSync.diff(pair), ['− extra.txt']); expect(await SyncDiff.of(pair), ['− extra.txt']);
live.dispose(); live.dispose();
}); });
@ -179,11 +181,11 @@ void main() {
File(p.join(src, '~jjren_abc_0')).writeAsStringSync('x'); File(p.join(src, '~jjren_abc_0')).writeAsStringSync('x');
Directory(target).createSync(); Directory(target).createSync();
File(p.join(target, '~jjback_abc_1')).writeAsStringSync('x'); 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'); 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(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_*']));
}); });
}); });

View File

@ -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);
});
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.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/settings.dart';
import 'package:jj_mkvmaker/app/transfer_job.dart'; import 'package:jj_mkvmaker/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/file_ops.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 /SJ'), isNot(contains('/XJ')));
expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']); expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']);
expect([robocopySkipsLinks(['/E']), robocopySkipsLinks(['/E', '/SJ'])], [true, false]); 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')); expect(live, contains('/XJ'));
}); });

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.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/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; 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); final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.copy.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); 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); await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb'); expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');