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
parent
db38d0ddbf
commit
3b3ce6386e
|
|
@ -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<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 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다.
|
||||
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다.
|
||||
|
|
@ -170,9 +133,9 @@ class LiveSync extends ChangeNotifier {
|
|||
? RsyncFilter.parse(old)
|
||||
: RsyncFilter.none;
|
||||
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;
|
||||
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<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
|
||||
final excl = <String>[];
|
||||
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<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 {
|
||||
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 = <String>[];
|
||||
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<int> _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<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 {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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_*']));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue