설정 A 분야 4: 복사 · 이동 · rsync · 실시간 동기화 (CopySettings) - 키 16 · 도우미 _method 함께

- 부르는 곳: s.copyTasks → s.copy.tasks 꼴 (RsyncRun 의 자기 필드 copySkipNewer 는 그대로 - 같은 이름이라 잘못 바뀐 것을 되돌림)
- 문서 주석 [AppSettings.x] → [CopySettings.x] 등

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:10:42 +09:00
parent ee7524e1c7
commit 059c8cba59
55 changed files with 430 additions and 393 deletions

View File

@ -24,9 +24,9 @@ void main() {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..copyMethodFolder = 'rsync' ..copy.methodFolder = 'rsync'
..copyBandwidthKBps = 5000 ..copy.bandwidthKBps = 5000
..liveSyncPairs = [ ..copy.liveSyncPairs = [
LiveSyncPair(r'D:\영상\원본', r'\\nas\backup\영상', method: 'rsync', delete: true), LiveSyncPair(r'D:\영상\원본', r'\\nas\backup\영상', method: 'rsync', delete: true),
LiveSyncPair(r'C:\Users\me\Documents', r'E:\Documents', enabled: false), LiveSyncPair(r'C:\Users\me\Documents', r'E:\Documents', enabled: false),
]; ];

View File

@ -33,7 +33,7 @@ void main() {
} }
Directory(dst).createSync(); Directory(dst).createSync();
final c = AppController(PlatformServices.create()); final c = AppController(PlatformServices.create());
c.settings.liveSyncPairs = [ c.settings.copy.liveSyncPairs = [
LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true), LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true),
]; ];
final live = LiveSync(c)..start(); final live = LiveSync(c)..start();

View File

@ -34,7 +34,7 @@ void main() {
final c = AppController(PlatformServices.create()); final c = AppController(PlatformServices.create());
c.settings c.settings
..rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
..explorer.style = 'windows'; ..explorer.style = 'windows';
final key = GlobalKey(); final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900); tester.view.physicalSize = const Size(1500, 900);
@ -101,8 +101,8 @@ void main() {
// 모니터링: 실행한 두 방향이 남는다 // 모니터링: 실행한 두 방향이 남는다
await tap(find.text('모니터링')); await tap(find.text('모니터링'));
await wait(1500); await wait(1500);
expect(c.settings.copyTasks.length, 2); expect(c.settings.copy.tasks.length, 2);
expect(c.settings.copyTasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue); expect(c.settings.copy.tasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue);
await shot('rsync_monitor'); await shot('rsync_monitor');
tester.view.reset(); tester.view.reset();
tmp.deleteSync(recursive: true); tmp.deleteSync(recursive: true);
@ -124,8 +124,8 @@ void main() {
await tester.runAsync(findBundledRsync); await tester.runAsync(findBundledRsync);
final c = AppController(PlatformServices.create()); final c = AppController(PlatformServices.create());
c.settings c.settings
..rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
..rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/'; ..copy.rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/';
final key = GlobalKey(); final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900); tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -194,8 +194,8 @@ void main() {
await tester.runAsync(findBundledRsync); await tester.runAsync(findBundledRsync);
final c = AppController(PlatformServices.create()); final c = AppController(PlatformServices.create());
c.settings c.settings
..rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
..liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)]; ..copy.liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)];
final key = GlobalKey(); final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900); tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -240,7 +240,7 @@ void main() {
expect(File(p.join(b, 'sub', 'deep', 'x.txt')).readAsStringSync(), 'x'); expect(File(p.join(b, 'sub', 'deep', 'x.txt')).readAsStringSync(), 'x');
expect(Directory(p.join(b, 'empty')).existsSync(), isTrue); // 빈 폴더도 대상에는 만들어짐 (rsync -a) expect(Directory(p.join(b, 'empty')).existsSync(), isTrue); // 빈 폴더도 대상에는 만들어짐 (rsync -a)
expect(Directory(a).existsSync(), isFalse, reason: '원본 폴더까지 지움'); expect(Directory(a).existsSync(), isFalse, reason: '원본 폴더까지 지움');
final moved = c.settings.copyTasks.single; final moved = c.settings.copy.tasks.single;
expect([moved.move, moved.prune, moved.lastResult], [true, 'all', 'done']); expect([moved.move, moved.prune, moved.lastResult], [true, 'all', 'done']);
expect(moved.lastMessage, '빈 폴더 4개 지움'); // sub/deep · sub · empty · srcA expect(moved.lastMessage, '빈 폴더 4개 지움'); // sub/deep · sub · empty · srcA
@ -263,8 +263,8 @@ void main() {
expect(File(p.join(d, 'sub', 'deep', 'y.txt')).readAsStringSync(), 'y'); expect(File(p.join(d, 'sub', 'deep', 'y.txt')).readAsStringSync(), 'y');
expect(Directory(cDir).existsSync(), isTrue, reason: '원본 폴더는 남김'); expect(Directory(cDir).existsSync(), isTrue, reason: '원본 폴더는 남김');
expect(Directory(cDir).listSync(), isEmpty, reason: '빈 폴더는 모두 지움'); expect(Directory(cDir).listSync(), isEmpty, reason: '빈 폴더는 모두 지움');
expect(c.settings.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔'); expect(c.settings.copy.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔');
final fin = c.settings.copyTasks.firstWhere((t) => t.sources.single == cDir); final fin = c.settings.copy.tasks.firstWhere((t) => t.sources.single == cDir);
expect([fin.options, fin.move, fin.prune, fin.lastResult], ['-avHPOg', true, 'keep', 'done']); expect([fin.options, fin.move, fin.prune, fin.lastResult], ['-avHPOg', true, 'keep', 'done']);
expect(fin.lastMessage, '빈 폴더 3개 지움'); // sub/deep · sub · empty (liveC 는 남김) expect(fin.lastMessage, '빈 폴더 3개 지움'); // sub/deep · sub · empty (liveC 는 남김)
tester.view.reset(); tester.view.reset();

View File

@ -149,7 +149,7 @@ void main() {
testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async { testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async {
// 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초) // 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초)
File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7)); File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7));
c.settings.copyBandwidthKBps = 80; c.settings.copy.bandwidthKBps = 80;
await show(t, ExplorerPage(device: device, c: c)); await show(t, ExplorerPage(device: device, c: c));
await selectAndPress(t, 'big.bin', '복사'); await selectAndPress(t, 'big.bin', '복사');
await t.tap(find.widgetWithText(FilledButton, '복사')); await t.tap(find.widgetWithText(FilledButton, '복사'));

View File

@ -60,9 +60,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.paths = [tmp.path, tmp.path] ..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
c.videos.add(VideoItem(video)); c.videos.add(VideoItem(video));
// 256: 폰 가로에서 작업 기록을 켜도 목록이 여러 줄 보이는지 - 동영상 5개 · 기록 몇 줄 // 256: 폰 가로에서 작업 기록을 켜도 목록이 여러 줄 보이는지 - 동영상 5개 · 기록 몇 줄
for (var i = 2; i <= 5; i++) { for (var i = 2; i <= 5; i++) {

View File

@ -51,12 +51,12 @@ void main() {
} }
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; ..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
// 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음) // 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음)
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp');
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await t.runAsync(() => live.syncNow(pair)); await t.runAsync(() => live.syncNow(pair));

View File

@ -47,13 +47,13 @@ void main() {
File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n'); File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"' ..copy.rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"'
..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)]; ..copy.liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
final live = LiveSync(c)..start(); final live = LiveSync(c)..start();
addTearDown(live.dispose); addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single; final pair = c.settings.copy.liveSyncPairs.single;
await t.runAsync(() => live.syncNow(pair)); await t.runAsync(() => live.syncNow(pair));
expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty); expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty);
final shotKey = GlobalKey(); final shotKey = GlobalKey();

View File

@ -48,7 +48,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.paths = [left, right] ..explorer.paths = [left, right]
..copyConflict = 'overwrite'; ..copy.conflict = 'overwrite';
final root = p.rootPrefix(tmp.path); final root = p.rootPrefix(tmp.path);
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
final shotKey = GlobalKey(); final shotKey = GlobalKey();

View File

@ -51,7 +51,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.orientation = 'side' ..explorer.orientation = 'side'
..rsyncPaths = [l, r]; ..copy.rsyncPaths = [l, r];
final shotKey = GlobalKey(); final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp( await t.pumpWidget(MaterialApp(
theme: buildTheme(), theme: buildTheme(),

View File

@ -28,7 +28,7 @@ class CopyCenter extends ChangeNotifier {
/// 작업 id → 지금 (또는 마지막) 실행 /// 작업 id → 지금 (또는 마지막) 실행
final jobs = <String, TransferJob>{}; final jobs = <String, TransferJob>{};
List<CopyTask> get tasks => c.settings.copyTasks; List<CopyTask> get tasks => c.settings.copy.tasks;
bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished; bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished;
@ -50,7 +50,7 @@ class CopyCenter extends ChangeNotifier {
CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) { CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) {
final s = c.settings; final s = c.settings;
final hasDir = sources.any((s) => isDav(s) || FileSystemEntity.isDirectorySync(s)); final hasDir = sources.any((s) => isDav(s) || FileSystemEntity.isDirectorySync(s));
var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile)); var m = CopyMethod.of(method ?? (hasDir ? s.copy.methodFolder : s.copy.methodFile));
if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin; if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin;
// WebDAV 가 끼면 실제로는 앱이 직접 복사한다 (확인 창에 robocopy 로 잘못 보이지 않게). Rsync 화면 ([method] 지정) 은 그대로 // WebDAV 가 끼면 실제로는 앱이 직접 복사한다 (확인 창에 robocopy 로 잘못 보이지 않게). Rsync 화면 ([method] 지정) 은 그대로
if (method == null && [...sources, dest].any(isDav)) m = CopyMethod.builtin; if (method == null && [...sources, dest].any(isDav)) m = CopyMethod.builtin;
@ -61,9 +61,9 @@ class CopyCenter extends ChangeNotifier {
move: move, move: move,
contents: contents, contents: contents,
method: m.name, method: m.name,
options: m == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions, options: m == CopyMethod.robocopy ? s.copy.robocopyOptions : s.copy.rsyncOptions,
once: s.copyRunMode == 'once', once: s.copy.runMode == 'once',
bandwidthKBps: s.copyBandwidthKBps, bandwidthKBps: s.copy.bandwidthKBps,
); );
} }
@ -79,20 +79,20 @@ class CopyCenter extends ChangeNotifier {
final t = fresh(sources, dest, move: move, contents: contents, method: method); final t = fresh(sources, dest, move: move, contents: contents, method: method);
final old = tasks.where((x) => x.key == t.key).firstOrNull; final old = tasks.where((x) => x.key == t.key).firstOrNull;
if (old != null) return old; if (old != null) return old;
await c.updateSettings((x) => x.copyTasks = [t, ...x.copyTasks]); await c.updateSettings((x) => x.copy.tasks = [t, ...x.copy.tasks]);
notifyListeners(); notifyListeners();
return t; return t;
} }
Future<void> update(CopyTask t) async { Future<void> update(CopyTask t) async {
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) y.id == t.id ? t : y]); await c.updateSettings((x) => x.copy.tasks = [for (final y in x.copy.tasks) y.id == t.id ? t : y]);
notifyListeners(); notifyListeners();
} }
Future<void> remove(String id) async { Future<void> remove(String id) async {
cancel(id); cancel(id);
jobs.remove(id); jobs.remove(id);
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) if (y.id != id) y]); await c.updateSettings((x) => x.copy.tasks = [for (final y in x.copy.tasks) if (y.id != id) y]);
notifyListeners(); notifyListeners();
} }
@ -126,7 +126,7 @@ class CopyCenter extends ChangeNotifier {
contents: t.contents, contents: t.contents,
prune: t.move ? t.prune : '', prune: t.move ? t.prune : '',
conflict: conflict, conflict: conflict,
skipNewer: c.settings.copySkipNewer, skipNewer: c.settings.copy.skipNewer,
); );
jobs[t.id] = job; jobs[t.id] = job;
job.addListener(notifyListeners); job.addListener(notifyListeners);
@ -170,13 +170,13 @@ class CopyCenter extends ChangeNotifier {
]; ];
final rest = t.sources.where((s) => !dirs.contains(s)).toList(); final rest = t.sources.where((s) => !dirs.contains(s)).toList();
await c.updateSettings((x) => x await c.updateSettings((x) => x
..liveSyncPairs = [ ..copy.liveSyncPairs = [
...x.liveSyncPairs, ...x.copy.liveSyncPairs,
for (final n in pairs) for (final n in pairs)
if (!x.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n, if (!x.copy.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n,
] ]
..copyTasks = [ ..copy.tasks = [
for (final y in x.copyTasks) for (final y in x.copy.tasks)
if (y.id != t.id) if (y.id != t.id)
y y
else if (rest.isNotEmpty) else if (rest.isNotEmpty)
@ -203,8 +203,8 @@ class CopyCenter extends ChangeNotifier {
final m = copyMethodAvailable(CopyMethod.rsync, s) ? 'rsync' : x.method; final m = copyMethodAvailable(CopyMethod.rsync, s) ? 'rsync' : x.method;
final t = fresh([x.source], x.target, contents: true, method: m); final t = fresh([x.source], x.target, contents: true, method: m);
await c.updateSettings((y) => y await c.updateSettings((y) => y
..liveSyncPairs = [for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o] ..copy.liveSyncPairs = [for (final o in y.copy.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o]
..copyTasks = [t, ...y.copyTasks]); ..copy.tasks = [t, ...y.copy.tasks]);
notifyListeners(); notifyListeners();
return t; return t;
} }

View File

@ -41,8 +41,8 @@ Future<void> findBundledRsync() async {
/// - 직접 지정: 그 파일 /// - 직접 지정: 그 파일
/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것 /// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것
Future<String?> rsyncExecutable(AppSettings s) async { Future<String?> rsyncExecutable(AppSettings s) async {
if (s.rsyncSource == 'custom') { if (s.copy.rsyncSource == 'custom') {
return s.rsyncPath.isNotEmpty && File(s.rsyncPath).existsSync() ? s.rsyncPath : null; return s.copy.rsyncPath.isNotEmpty && File(s.copy.rsyncPath).existsSync() ? s.copy.rsyncPath : null;
} }
final b = bundledRsync; final b = bundledRsync;
if (b != null && File(b).existsSync()) return b; if (b != null && File(b).existsSync()) return b;
@ -54,11 +54,11 @@ Future<String?> rsyncExecutable(AppSettings s) async {
bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) { bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) {
CopyMethod.builtin => true, CopyMethod.builtin => true,
CopyMethod.robocopy => Platform.isWindows, CopyMethod.robocopy => Platform.isWindows,
CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom' || bundledRsync != null, CopyMethod.rsync => Platform.isWindows || s.copy.rsyncSource == 'custom' || bundledRsync != null,
}; };
/// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. /// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다.
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [AppSettings.liveSyncIntervalSec] 마다 살핀다. /// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다.
/// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다. /// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다.
class LiveSync extends ChangeNotifier { class LiveSync extends ChangeNotifier {
final AppController c; final AppController c;
@ -82,13 +82,13 @@ class LiveSync extends ChangeNotifier {
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다 /// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
Timer? _clock; Timer? _clock;
/// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다) /// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [CopySettings.liveSyncOnStart] 를 따른다)
final paused = <String>{}; final paused = <String>{};
/// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작) /// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작)
void start({bool hold = false}) { void start({bool hold = false}) {
instance = this; instance = this;
if (hold) paused.addAll(c.settings.liveSyncPairs.where((x) => x.enabled).map(keyOf)); if (hold) paused.addAll(c.settings.copy.liveSyncPairs.where((x) => x.enabled).map(keyOf));
c.addListener(_apply); c.addListener(_apply);
_apply(); _apply();
_clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock()); _clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock());
@ -120,19 +120,19 @@ class LiveSync extends ChangeNotifier {
/// 지금 지켜보는 쌍 (켜져 있고 멈추지 않은 것) /// 지금 지켜보는 쌍 (켜져 있고 멈추지 않은 것)
List<LiveSyncPair> get watching => [ List<LiveSyncPair> get watching => [
for (final x in c.settings.liveSyncPairs) for (final x in c.settings.copy.liveSyncPairs)
if (x.enabled && !paused.contains(keyOf(x))) x, if (x.enabled && !paused.contains(keyOf(x))) x,
]; ];
void pause(LiveSyncPair x) => _setPaused({...paused, keyOf(x)}); void pause(LiveSyncPair x) => _setPaused({...paused, keyOf(x)});
void resume(LiveSyncPair x) => _setPaused({...paused}..remove(keyOf(x))); void resume(LiveSyncPair x) => _setPaused({...paused}..remove(keyOf(x)));
void pauseAll() => _setPaused({...paused, ...c.settings.liveSyncPairs.map(keyOf)}); void pauseAll() => _setPaused({...paused, ...c.settings.copy.liveSyncPairs.map(keyOf)});
void resumeAll() => _setPaused({}); void resumeAll() => _setPaused({});
/// 시작할 때 고른 쌍만 (나머지는 멈춤) /// 시작할 때 고른 쌍만 (나머지는 멈춤)
void runOnly(Iterable<LiveSyncPair> xs) { void runOnly(Iterable<LiveSyncPair> xs) {
final keep = xs.map(keyOf).toSet(); final keep = xs.map(keyOf).toSet();
_setPaused({for (final x in c.settings.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)}); _setPaused({for (final x in c.settings.copy.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)});
} }
void _setPaused(Set<String> v) { void _setPaused(Set<String> v) {
@ -153,10 +153,10 @@ class LiveSync extends ChangeNotifier {
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로. /// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
/// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다. /// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다.
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이 /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none; RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.copy.rsyncOptions) : RsyncFilter.none;
/// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions]) /// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions])
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : ''; String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.copy.rsyncOptions : '';
/// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두). /// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두).
/// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작 /// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작
@ -419,8 +419,8 @@ class LiveSync extends ChangeNotifier {
} }
/// 설정의 쌍을 바꾼다 (같은 원본 → 대상) /// 설정의 쌍을 바꾼다 (같은 원본 → 대상)
Future<void> _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.liveSyncPairs = [ Future<void> _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.copy.liveSyncPairs = [
for (final p in s.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p, for (final p in s.copy.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p,
]); ]);
/// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다 /// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다
@ -449,7 +449,7 @@ class LiveSync extends ChangeNotifier {
void _apply() { void _apply() {
final s = c.settings; final s = c.settings;
final pairs = watching; final pairs = watching;
final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]); final sig = jsonEncode([for (final x in pairs) x.toJson(), s.copy.liveSyncIntervalSec]);
if (sig == _sig) return; if (sig == _sig) return;
_sig = sig; _sig = sig;
_stopAll(); _stopAll();
@ -475,11 +475,11 @@ class LiveSync extends ChangeNotifier {
_schedule(x); _schedule(x);
}); });
} catch (_) { } catch (_) {
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); _timers[k] = Timer.periodic(Duration(seconds: s.copy.liveSyncIntervalSec), (_) => _schedule(x));
} }
} else { } else {
// Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다 // Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); _timers[k] = Timer.periodic(Duration(seconds: s.copy.liveSyncIntervalSec), (_) => _schedule(x));
} }
_schedule(x); // 시작할 때 한 번 _schedule(x); // 시작할 때 한 번
} }
@ -581,7 +581,7 @@ class LiveSync extends ChangeNotifier {
// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교) // 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교)
final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method); final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method);
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다 // M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps); final ops = FileOps(bandwidthKBps: s.copy.bandwidthKBps);
final n = switch (method) { final n = switch (method) {
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)), CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)),
CopyMethod.rsync => await _rsync(x, s), CopyMethod.rsync => await _rsync(x, s),
@ -650,15 +650,15 @@ class LiveSync extends ChangeNotifier {
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
// 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs) // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
...rsyncFileOptionArgs([ ...rsyncFileOptionArgs([
for (final o in splitOptions(s.rsyncOptions)) for (final o in splitOptions(s.copy.rsyncOptions))
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o, if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
], windows: windows), ], windows: windows),
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (s.copy.bandwidthKBps > 0) '--bwlimit=${s.copy.bandwidthKBps}',
if (x.delete) '--delete', if (x.delete) '--delete',
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창 // 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름) // (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', if (x.delete && splitOptions(s.copy.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/', '${toCygwinPath(x.source, windows: windows)}/',
'${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20) '${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20)
@ -667,7 +667,7 @@ class LiveSync extends ChangeNotifier {
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97 /// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97
static List<String> robocopyArgsFor(LiveSyncPair x, AppSettings s) { static List<String> robocopyArgsFor(LiveSyncPair x, AppSettings s) {
final opts = [ final opts = [
for (final o in splitOptions(s.robocopyOptions)) for (final o in splitOptions(s.copy.robocopyOptions))
if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o, if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o,
]; ];
return [ return [
@ -679,7 +679,7 @@ class LiveSync extends ChangeNotifier {
if (!robocopyLinkOption(opts)) '/XJ', if (!robocopyLinkOption(opts)) '/XJ',
if (x.delete) '/PURGE', if (x.delete) '/PURGE',
...appTempRobocopyExcludes, // 78 보충 ...appTempRobocopyExcludes, // 78 보충
if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}', if (s.copy.bandwidthKBps > 0) '/IPG:${robocopyIpg(s.copy.bandwidthKBps)}',
'/BYTES', '/NJH', '/NJS', '/NDL', '/NP', '/BYTES', '/NJH', '/NJS', '/NDL', '/NP',
]; ];
} }

View File

@ -16,6 +16,7 @@ import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart'; import '../core/remote_idle.dart';
import 'settings/records.dart'; import 'settings/records.dart';
import 'settings/settings_group.dart'; import 'settings/settings_group.dart';
import 'settings/copy_settings.dart';
import 'settings/tor_settings.dart'; import 'settings/tor_settings.dart';
import 'settings/network_settings.dart'; import 'settings/network_settings.dart';
import 'settings/explorer_settings.dart'; import 'settings/explorer_settings.dart';
@ -23,6 +24,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart'; export 'settings/records.dart';
export 'settings/settings_group.dart'; export 'settings/settings_group.dart';
export 'settings/settings_store.dart'; export 'settings/settings_store.dart';
export 'settings/copy_settings.dart';
export 'settings/tor_settings.dart'; export 'settings/tor_settings.dart';
export 'settings/network_settings.dart'; export 'settings/network_settings.dart';
export 'settings/explorer_settings.dart'; export 'settings/explorer_settings.dart';
@ -226,42 +228,18 @@ class AppSettings {
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
String copyMethodFile = 'builtin';
/// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip'
String copyConflict = 'ask';
/// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름.
/// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로)
String mkvExisting = 'ask'; String mkvExisting = 'ask';
/// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음).
/// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단)
bool copySkipNewer = true;
String copyMethodFolder = 'builtin';
String rsyncOptions = '-avPog';
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync)
String copyRunMode = 'each';
/// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게)
int copyBandwidthKBps = 0;
/// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath])
String rsyncSource = 'download';
String rsyncPath = '';
/// 실시간 동기화 (lsyncd 처럼)
List<LiveSyncPair> liveSyncPairs = [];
/// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기
List<CopyTask> copyTasks = [];
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = [];
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두 /// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
List<String> components = AppComponent.defaultInstalled; List<String> components = AppComponent.defaultInstalled;
@ -303,8 +281,6 @@ class AppSettings {
/// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 /// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다
bool startMenuShortcut = true; bool startMenuShortcut = true;
/// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림
bool syncStopToast = true;
// ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ── // ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ──
@ -432,15 +408,11 @@ class AppSettings {
/// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로 /// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로
String aiUpAfter = 'upscaled'; String aiUpAfter = 'upscaled';
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
/// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시). /// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시).
/// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]). /// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]).
bool runInBackground = true; bool runInBackground = true;
/// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작)
String liveSyncOnStart = 'auto';
/// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground] /// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground]
bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground; bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground;
@ -499,8 +471,11 @@ class AppSettings {
/// Tor 釉뚮씪?곗? (?ъ슜???붿껌 10/10) /// Tor 釉뚮씪?곗? (?ъ슜???붿껌 10/10)
final tor = TorBrowserSettings(); final tor = TorBrowserSettings();
/// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면)
final copy = CopySettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor]; List<SettingsGroup> get groups => [explorer, network, tor, copy];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) { Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -571,12 +546,7 @@ class AppSettings {
'rootEnabled': rootEnabled, 'rootEnabled': rootEnabled,
'vaultMasterCopy': vaultMasterCopy, 'vaultMasterCopy': vaultMasterCopy,
'vaultAutoLockSeconds': vaultAutoLockSeconds, 'vaultAutoLockSeconds': vaultAutoLockSeconds,
'copyMethodFile': copyMethodFile,
'copyConflict': copyConflict,
'mkvExisting': mkvExisting, 'mkvExisting': mkvExisting,
'copySkipNewer': copySkipNewer,
'copyMethodFolder': copyMethodFolder,
'rsyncPaths': rsyncPaths,
'components': components, 'components': components,
'aiImageV1': true, 'aiImageV1': true,
'robocopyLinksV1': true, 'robocopyLinksV1': true,
@ -592,7 +562,6 @@ class AppSettings {
'readerVolumeKeys': readerVolumeKeys, 'readerVolumeKeys': readerVolumeKeys,
'readerSplit': readerSplit, 'readerSplit': readerSplit,
'startMenuShortcut': startMenuShortcut, 'startMenuShortcut': startMenuShortcut,
'syncStopToast': syncStopToast,
'swipeWrap': swipeWrap, 'swipeWrap': swipeWrap,
'browserSwipeHinted': browserSwipeHinted, 'browserSwipeHinted': browserSwipeHinted,
'cleanupPrechecked': cleanupPrechecked, 'cleanupPrechecked': cleanupPrechecked,
@ -647,18 +616,8 @@ class AppSettings {
'smbRequireSecure': smbRequireSecure, 'smbRequireSecure': smbRequireSecure,
'remoteIdleSeconds': remoteIdleSeconds, 'remoteIdleSeconds': remoteIdleSeconds,
'ftpServers': [for (final x in ftpServers) x.toJson()], 'ftpServers': [for (final x in ftpServers) x.toJson()],
'rsyncOptions': rsyncOptions,
'robocopyOptions': robocopyOptions,
'copyRunMode': copyRunMode,
'copyBandwidthKBps': copyBandwidthKBps,
'rsyncSource': rsyncSource,
'rsyncPath': rsyncPath,
'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()],
'liveSyncIntervalSec': liveSyncIntervalSec,
'runInBackground': runInBackground, 'runInBackground': runInBackground,
'backgroundV2': true, 'backgroundV2': true,
'liveSyncOnStart': liveSyncOnStart,
'copyTasks': [for (final x in copyTasks) x.toJson()],
'autoCheckUpdates': autoCheckUpdates, 'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck, 'lastUpdateCheck': lastUpdateCheck,
'lastUpdateCheckVersion': lastUpdateCheckVersion, 'lastUpdateCheckVersion': lastUpdateCheckVersion,
@ -689,8 +648,6 @@ class AppSettings {
return j; return j;
} }
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
/// 색 보정 값 (-100 ~ 100) /// 색 보정 값 (-100 ~ 100)
static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100);
@ -762,12 +719,7 @@ class AppSettings {
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30 ..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true ..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : '' ..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
..copyMethodFile = _method(j['copyMethodFile'])
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask' ..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
..copySkipNewer = j['copySkipNewer'] != false
..copyMethodFolder = _method(j['copyMethodFolder'])
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
..components = j['components'] is List ..components = j['components'] is List
? [ ? [
// 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙). // 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙).
@ -794,7 +746,6 @@ class AppSettings {
..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false ..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false
..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto' ..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto'
..startMenuShortcut = j['startMenuShortcut'] != false ..startMenuShortcut = j['startMenuShortcut'] != false
..syncStopToast = j['syncStopToast'] != false
..swipeWrap = j['swipeWrap'] != false ..swipeWrap = j['swipeWrap'] != false
..browserSwipeHinted = j['browserSwipeHinted'] == true ..browserSwipeHinted = j['browserSwipeHinted'] == true
..cleanupPrechecked = j['cleanupPrechecked'] is List ..cleanupPrechecked = j['cleanupPrechecked'] is List
@ -863,24 +814,8 @@ class AppSettings {
for (final x in (j['ftpServers'] as List?) ?? const []) for (final x in (j['ftpServers'] as List?) ?? const [])
if (x is Map) FtpServer.fromJson(x), if (x is Map) FtpServer.fromJson(x),
] ]
..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'
..copyBandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000)
..rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download'
..rsyncPath = j['rsyncPath'] as String? ?? ''
..liveSyncPairs = [
for (final x in (j['liveSyncPairs'] as List?) ?? const [])
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
]
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
// 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로 // 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로
..runInBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true ..runInBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true
..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto'
..copyTasks = [
for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
]
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? '' ..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? ''

View File

@ -0,0 +1,102 @@
import 'records.dart';
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class CopySettings implements SettingsGroup {
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
String methodFile = 'builtin';
/// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip'
String conflict = 'ask';
/// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음).
/// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단)
bool skipNewer = true;
String methodFolder = 'builtin';
String rsyncOptions = '-avPog';
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync)
String runMode = 'each';
/// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게)
int bandwidthKBps = 0;
/// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath])
String rsyncSource = 'download';
String rsyncPath = '';
/// 실시간 동기화 (lsyncd 처럼)
List<LiveSyncPair> liveSyncPairs = [];
/// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기
List<CopyTask> tasks = [];
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = [];
/// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림
bool syncStopToast = true;
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
/// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작)
String liveSyncOnStart = 'auto';
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['copyMethodFile'] = methodFile;
j['copyConflict'] = conflict;
j['copySkipNewer'] = skipNewer;
j['copyMethodFolder'] = methodFolder;
j['rsyncOptions'] = rsyncOptions;
j['robocopyOptions'] = robocopyOptions;
j['copyRunMode'] = runMode;
j['copyBandwidthKBps'] = bandwidthKBps;
j['rsyncSource'] = rsyncSource;
j['rsyncPath'] = rsyncPath;
j['liveSyncPairs'] = [for (final x in liveSyncPairs) x.toJson()];
j['liveSyncIntervalSec'] = liveSyncIntervalSec;
j['liveSyncOnStart'] = liveSyncOnStart;
j['copyTasks'] = [for (final x in tasks) x.toJson()];
j['rsyncPaths'] = rsyncPaths;
j['syncStopToast'] = syncStopToast;
}
@override
void read(Map<String, dynamic> j) {
methodFile = _method(j['copyMethodFile']);
conflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask';
skipNewer = j['copySkipNewer'] != false;
methodFolder = _method(j['copyMethodFolder']);
rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'];
syncStopToast = j['syncStopToast'] != false;
rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog';
robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
runMode = j['copyRunMode'] == 'once' ? 'once' : 'each';
bandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000);
rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download';
rsyncPath = j['rsyncPath'] as String? ?? '';
liveSyncPairs = [
for (final x in (j['liveSyncPairs'] as List?) ?? const [])
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
];
liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600);
liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto';
tasks = [
for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
];
}
}

View File

@ -148,7 +148,7 @@ Future<void> main(List<String> args) async {
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); await i18n.apply(controller.settings.uiLanguage, save: false);
@ -286,7 +286,7 @@ Future<void> main(List<String> args) async {
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source]))))); ?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
// 92: 트레이에 둔 채 실시간 동기화가 멈추면 Windows 알림 · 트레이 툴팁, 누르면 모니터링 > lsync 카드로 // 92: 트레이에 둔 채 실시간 동기화가 멈추면 Windows 알림 · 트레이 툴팁, 누르면 모니터링 > lsync 카드로
final syncAlert = SyncAlert(live, services.shell, final syncAlert = SyncAlert(live, services.shell,
toastEnabled: () => controller.settings.syncStopToast, toastEnabled: () => controller.settings.copy.syncStopToast,
openLsync: () { openLsync: () {
final nav = navigatorKey.currentState; final nav = navigatorKey.currentState;
if (nav != null) unawaited(nav.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: controller, initialTab: 1)))); if (nav != null) unawaited(nav.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: controller, initialTab: 1))));
@ -399,7 +399,7 @@ Future<void> main(List<String> args) async {
if (home != 'mkv' && nav0 != null) { if (home != 'mkv' && nav0 != null) {
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks)); unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
} }
if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live); if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
// 설정 파일을 읽지 못했으면 (백업으로 되살렸거나 처음 설정으로 켰으면) 알린다 // 설정 파일을 읽지 못했으면 (백업으로 되살렸거나 처음 설정으로 켰으면) 알린다
if (ctx.mounted) await showSettingsProblem(ctx, controller); if (ctx.mounted) await showSettingsProblem(ctx, controller);
watchSecretIssue(messengerKey, controller); watchSecretIssue(messengerKey, controller);
@ -595,7 +595,7 @@ Future<void> runAndroid(String dataDir) async {
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); await i18n.apply(controller.settings.uiLanguage, save: false);
@ -703,7 +703,7 @@ Future<void> runAndroid(String dataDir) async {
} }
// 다시 켤 때 실시간 동기화를 골라서 시작 // 다시 켤 때 실시간 동기화를 골라서 시작
final ctx0 = navigatorKey.currentContext; final ctx0 = navigatorKey.currentContext;
if (controller.settings.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live); if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live);
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고) // 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
final current = await _currentVersion(services); final current = await _currentVersion(services);
final ctxV = navigatorKey.currentContext; final ctxV = navigatorKey.currentContext;

View File

@ -157,7 +157,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
return null; return null;
} }
var dest = choices.first.$1; var dest = choices.first.$1;
var conflict = switch (c.settings.copyConflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename }; var conflict = switch (c.settings.copy.conflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename };
Future<int?> free(String d) async { Future<int?> free(String d) async {
var x = d; var x = d;
while (!Directory(x).existsSync() && p.dirname(x) != x) { while (!Directory(x).existsSync() && p.dirname(x) != x) {
@ -220,7 +220,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
// 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠) // 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠)
if (conflict == NameConflict.overwrite) if (conflict == NameConflict.overwrite)
Text( Text(
c.settings.copySkipNewer c.settings.copy.skipNewer
? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).') ? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).')
: tr('덮어쓴 파일은 되돌릴 수 없습니다.'), : tr('덮어쓴 파일은 되돌릴 수 없습니다.'),
style: TextStyle(color: JjColors.warning, fontSize: 12)), style: TextStyle(color: JjColors.warning, fontSize: 12)),
@ -253,13 +253,13 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
if (kind == ArchiveKind.zip) { if (kind == ArchiveKind.zip) {
return extractZipPlan(local, dest, plan, return extractZipPlan(local, dest, plan,
conflict: conflict, conflict: conflict,
skipNewer: c.settings.copySkipNewer, skipNewer: c.settings.copy.skipNewer,
password: pw, password: pw,
cancelled: cancelled, cancelled: cancelled,
onProgress: (d, t) => set(t == 0 ? null : d / t)); onProgress: (d, t) => set(t == 0 ? null : d / t));
} }
return extract7zPlan(exe!, local, dest, plan, return extract7zPlan(exe!, local, dest, plan,
conflict: conflict, skipNewer: c.settings.copySkipNewer, password: pw, cancelled: cancelled, onProgress: set); conflict: conflict, skipNewer: c.settings.copy.skipNewer, password: pw, cancelled: cancelled, onProgress: set);
}); });
say([ say([
trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]), trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]),

View File

@ -125,36 +125,36 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
title: Text(tr('파일 복사 · 이동 방법')), title: Text(tr('파일 복사 · 이동 방법')),
subtitle: Text(tr('파일만 골랐을 때')), subtitle: Text(tr('파일만 골랐을 때')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: _valid(s.copyMethodFile), value: _valid(s.copy.methodFile),
items: _methods(), items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!), onChanged: (v) => c.updateSettings((x) => x.copy.methodFile = v!),
), ),
), ),
SettingTile( SettingTile(
title: Text(tr('폴더 복사 · 이동 방법')), title: Text(tr('폴더 복사 · 이동 방법')),
subtitle: Text(tr('폴더가 들어 있을 때')), subtitle: Text(tr('폴더가 들어 있을 때')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: _valid(s.copyMethodFolder), value: _valid(s.copy.methodFolder),
items: _methods(), items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!), onChanged: (v) => c.updateSettings((x) => x.copy.methodFolder = v!),
), ),
), ),
_options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions, _options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.copy.robocopyOptions,
defaultRobocopyOptions, (x, v) => x.robocopyOptions = v), defaultRobocopyOptions, (x, v) => x.copy.robocopyOptions = v),
], ],
// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때, robocopy · rsync 는 그 프로그램의 규칙) // 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때, robocopy · rsync 는 그 프로그램의 규칙)
SettingTile( SettingTile(
title: Text(tr('같은 이름이 있을 때')), title: Text(tr('같은 이름이 있을 때')),
subtitle: Text(tr('복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)')), subtitle: Text(tr('복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: const ['ask', 'rename', 'overwrite', 'skip'].contains(s.copyConflict) ? s.copyConflict : 'ask', value: const ['ask', 'rename', 'overwrite', 'skip'].contains(s.copy.conflict) ? s.copy.conflict : 'ask',
items: [ items: [
DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))),
DropdownMenuItem(value: 'rename', child: Text(tr('늘 이름 바꾸기'))), DropdownMenuItem(value: 'rename', child: Text(tr('늘 이름 바꾸기'))),
DropdownMenuItem(value: 'overwrite', child: Text(tr('늘 덮어쓰기'))), DropdownMenuItem(value: 'overwrite', child: Text(tr('늘 덮어쓰기'))),
DropdownMenuItem(value: 'skip', child: Text(tr('늘 건너뛰기'))), DropdownMenuItem(value: 'skip', child: Text(tr('늘 건너뛰기'))),
], ],
onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'), onChanged: (v) => c.updateSettings((x) => x.copy.conflict = v ?? 'ask'),
), ),
), ),
_skipNewerTile(), _skipNewerTile(),
@ -165,12 +165,12 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
trailing: SizedBox( trailing: SizedBox(
width: 140, width: 140,
child: TextFormField( child: TextFormField(
initialValue: '${s.copyBandwidthKBps}', initialValue: '${s.copy.bandwidthKBps}',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: 'KB/s'), decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: 'KB/s'),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (v) => c.updateSettings((x) => x.copyBandwidthKBps = int.tryParse(v) ?? 0), onChanged: (v) => c.updateSettings((x) => x.copy.bandwidthKBps = int.tryParse(v) ?? 0),
), ),
), ),
), ),
@ -180,8 +180,8 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
/// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync) /// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync)
/// 229: 받는 쪽이 더 새것이면 건너뛰기 - 파일 탐색기 · Rsync 탭 두 곳에 같은 설정 (Rsync 화면 · 모니터링에서도 쓰여 찾기 쉽게) /// 229: 받는 쪽이 더 새것이면 건너뛰기 - 파일 탐색기 · Rsync 탭 두 곳에 같은 설정 (Rsync 화면 · 모니터링에서도 쓰여 찾기 쉽게)
Widget _skipNewerTile() => SwitchListTile( Widget _skipNewerTile() => SwitchListTile(
value: c.settings.copySkipNewer, value: c.settings.copy.skipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v), onChanged: (v) => c.updateSettings((x) => x.copy.skipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')), title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))), subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
); );
@ -193,7 +193,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
// 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다 // 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. ' _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'), '새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true), s.copy.rsyncOptions, defaultRsyncOptions, (x, v) => x.copy.rsyncOptions = v, rsync: true),
_skipNewerTile(), _skipNewerTile(),
SettingTile( SettingTile(
title: Text(tr('rsync 가져오기')), title: Text(tr('rsync 가져오기')),
@ -201,14 +201,14 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
? tr('확인 중…') ? tr('확인 중…')
: _rsync != null : _rsync != null
? trf('사용: {0}', [_rsync!]) ? trf('사용: {0}', [_rsync!])
: s.rsyncSource == 'download' : s.copy.rsyncSource == 'download'
? (desk ? (desk
? tr('이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)') ? tr('이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)')
: tr('이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.')) : tr('이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.'))
: tr('지정한 파일이 없습니다')), : tr('지정한 파일이 없습니다')),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [ trailing: Row(mainAxisSize: MainAxisSize.min, children: [
// 내려받기 · 내려받은 것 지우기 (앱에 들어 있는 것을 쓰면 보이지 않음) // 내려받기 · 내려받은 것 지우기 (앱에 들어 있는 것을 쓰면 보이지 않음)
if (desk && s.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync)) if (desk && s.copy.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync))
_rsync == null _rsync == null
? TextButton( ? TextButton(
onPressed: () async { onPressed: () async {
@ -227,28 +227,28 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
DropdownButton<String>( DropdownButton<String>(
value: s.rsyncSource, value: s.copy.rsyncSource,
items: [ items: [
DropdownMenuItem(value: 'download', child: Text(tr('앱에 들어 있는 것 (기본)'))), DropdownMenuItem(value: 'download', child: Text(tr('앱에 들어 있는 것 (기본)'))),
DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정'))), DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정'))),
], ],
onChanged: (v) async { onChanged: (v) async {
await c.updateSettings((x) => x.rsyncSource = v!); await c.updateSettings((x) => x.copy.rsyncSource = v!);
await _check(); await _check();
}, },
), ),
]), ]),
), ),
if (s.rsyncSource == 'custom') if (s.copy.rsyncSource == 'custom')
SettingTile( SettingTile(
title: Text(tr('rsync 실행 파일')), title: Text(tr('rsync 실행 파일')),
subtitle: Text(s.rsyncPath.isEmpty ? tr('고르지 않음') : s.rsyncPath), subtitle: Text(s.copy.rsyncPath.isEmpty ? tr('고르지 않음') : s.copy.rsyncPath),
trailing: TextButton( trailing: TextButton(
onPressed: () async { onPressed: () async {
final r = await FilePicker.pickFiles(dialogTitle: tr('rsync 실행 파일')); final r = await FilePicker.pickFiles(dialogTitle: tr('rsync 실행 파일'));
final path = r.isEmpty ? null : r.single.path; final path = r.isEmpty ? null : r.single.path;
if (path == null) return; if (path == null) return;
await c.updateSettings((x) => x.rsyncPath = path); await c.updateSettings((x) => x.copy.rsyncPath = path);
await _check(); await _check();
}, },
child: Text(tr('고르기')), child: Text(tr('고르기')),
@ -275,8 +275,8 @@ Future<void> removeLiveSyncPair(BuildContext context, AppController c, LiveSyncP
ok: tr('지우기'), ok: tr('지우기'),
); );
if (!ok) return; if (!ok) return;
await c.updateSettings((y) => y.liveSyncPairs = [ await c.updateSettings((y) => y.copy.liveSyncPairs = [
for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o, for (final o in y.copy.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o,
]); ]);
} }
@ -374,7 +374,7 @@ class _LiveSyncTile extends StatelessWidget {
if (ok != true) return; if (ok != true) return;
if (old == null) { if (old == null) {
await c.updateSettings((x) => await c.updateSettings((x) =>
x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule)]); x.copy.liveSyncPairs = [...x.copy.liveSyncPairs, LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule)]);
return; return;
} }
// 원본 · 대상이 바뀌면 새 쌍 (지우기 확인도 새로 - 42), 아니면 방법 · 지우기 · 일정만 // 원본 · 대상이 바뀌면 새 쌍 (지우기 확인도 새로 - 42), 아니면 방법 · 지우기 · 일정만
@ -382,21 +382,21 @@ class _LiveSyncTile extends StatelessWidget {
final now = same final now = same
? old.copyWith(method: method, delete: delete, schedule: schedule) ? old.copyWith(method: method, delete: delete, schedule: schedule)
: LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule, enabled: old.enabled); : LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule, enabled: old.enabled);
await c.updateSettings((x) => x.liveSyncPairs = [ await c.updateSettings((x) => x.copy.liveSyncPairs = [
for (final o in x.liveSyncPairs) o.source == old.source && o.target == old.target ? now : o, for (final o in x.copy.liveSyncPairs) o.source == old.source && o.target == old.target ? now : o,
]); ]);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final live = LiveSync.instance; final live = LiveSync.instance;
final pairs = c.settings.liveSyncPairs; final pairs = c.settings.copy.liveSyncPairs;
Widget body() => Column(children: [ Widget body() => Column(children: [
SettingTile( SettingTile(
title: Text(tr('실시간 동기화 (lsyncd 처럼)')), title: Text(tr('실시간 동기화 (lsyncd 처럼)')),
subtitle: Text(Platform.isWindows subtitle: Text(Platform.isWindows
? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.') ? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.')
: trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.liveSyncIntervalSec])), : trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.copy.liveSyncIntervalSec])),
trailing: TextButton.icon( trailing: TextButton.icon(
onPressed: () => _add(context, c), onPressed: () => _add(context, c),
icon: const Icon(Icons.add, size: 18), icon: const Icon(Icons.add, size: 18),
@ -414,13 +414,13 @@ class _LiveSyncTile extends StatelessWidget {
? tr('WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.') ? tr('WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.')
: tr('짧을수록 빨리 맞추지만 배터리를 더 씁니다.')), : tr('짧을수록 빨리 맞추지만 배터리를 더 씁니다.')),
trailing: DropdownButton<int>( trailing: DropdownButton<int>(
value: c.settings.liveSyncIntervalSec, value: c.settings.copy.liveSyncIntervalSec,
items: [ items: [
for (final v in {5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600, c.settings.liveSyncIntervalSec}.toList()..sort()) for (final v in {5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600, c.settings.copy.liveSyncIntervalSec}.toList()..sort())
DropdownMenuItem(value: v, child: Text(v < 60 ? trf('{0}초', [v]) : trf('{0}분', [v ~/ 60]))), DropdownMenuItem(value: v, child: Text(v < 60 ? trf('{0}초', [v]) : trf('{0}분', [v ~/ 60]))),
], ],
onChanged: (v) { onChanged: (v) {
if (v != null) c.updateSettings((x) => x.liveSyncIntervalSec = v); if (v != null) c.updateSettings((x) => x.copy.liveSyncIntervalSec = v);
}, },
), ),
), ),
@ -452,7 +452,7 @@ class _LiveSyncTile extends StatelessWidget {
onPressed: () async { onPressed: () async {
final r = await editSchedule(context, pairs[i].schedule); final r = await editSchedule(context, pairs[i].schedule);
if (r == null) return; if (r == null) return;
await c.updateSettings((x) => x.liveSyncPairs = [ await c.updateSettings((x) => x.copy.liveSyncPairs = [
for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(schedule: r) : pairs[k], for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(schedule: r) : pairs[k],
]); ]);
}, },
@ -465,7 +465,7 @@ class _LiveSyncTile extends StatelessWidget {
Switch( Switch(
value: pairs[i].enabled, value: pairs[i].enabled,
onChanged: (v) => c.updateSettings( onChanged: (v) => c.updateSettings(
(x) => x.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]), (x) => x.copy.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]),
), ),
IconButton( IconButton(
tooltip: tr('고치기'), tooltip: tr('고치기'),
@ -501,14 +501,14 @@ class LiveSyncRunOptions extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = c.settings; final s = c.settings;
final onStart = DropdownButton<String>( final onStart = DropdownButton<String>(
value: s.liveSyncOnStart, value: s.copy.liveSyncOnStart,
isDense: dense, isDense: dense,
items: [ items: [
DropdownMenuItem(value: 'auto', child: Text(tr('바로 시작 (기본)'))), DropdownMenuItem(value: 'auto', child: Text(tr('바로 시작 (기본)'))),
DropdownMenuItem(value: 'ask', child: Text(tr('골라서 시작'))), DropdownMenuItem(value: 'ask', child: Text(tr('골라서 시작'))),
DropdownMenuItem(value: 'off', child: Text(tr('시작 안 함 (모니터링에서 시작)'))), DropdownMenuItem(value: 'off', child: Text(tr('시작 안 함 (모니터링에서 시작)'))),
], ],
onChanged: (v) => c.updateSettings((x) => x.liveSyncOnStart = v!), onChanged: (v) => c.updateSettings((x) => x.copy.liveSyncOnStart = v!),
); );
void setBackground(bool? v) => c.updateSettings((x) => x.backgroundRun = v ?? false); void setBackground(bool? v) => c.updateSettings((x) => x.backgroundRun = v ?? false);
if (dense) { if (dense) {

View File

@ -407,7 +407,7 @@ class ExplorerController extends ChangeNotifier {
final i = panes.indexOf(q); final i = panes.indexOf(q);
final paths = [...panes.map((x) => x.current)]; final paths = [...panes.map((x) => x.current)];
if (rsync) { if (rsync) {
unawaited(c.updateSettings((s) => s.rsyncPaths = paths)); unawaited(c.updateSettings((s) => s.copy.rsyncPaths = paths));
} else { } else {
final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList(); final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s unawaited(c.updateSettings((s) => s

View File

@ -47,7 +47,7 @@ class ExplorerRsyncFlow {
allowInnerToOuter: ctl.c.settings.allowInnerToOuter, allowInnerToOuter: ctl.c.settings.allowInnerToOuter,
jobs: this.jobs.list, jobs: this.jobs.list,
viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]), viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]),
copySkipNewer: ctl.c.settings.copySkipNewer); copySkipNewer: ctl.c.settings.copy.skipNewer);
if (run == null) { if (run == null) {
ctl.host.snack(problem!); ctl.host.snack(problem!);
if (missing) { if (missing) {

View File

@ -99,7 +99,7 @@ class ExplorerTransfers extends ChangeNotifier {
final have = await rsyncExecutable(s); final have = await rsyncExecutable(s);
if (have != null) return have; if (have != null) return have;
if (!context.mounted) return null; if (!context.mounted) return null;
if (s.rsyncSource == 'custom' || !Platform.isWindows) { if (s.copy.rsyncSource == 'custom' || !Platform.isWindows) {
ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'));
return null; return null;
} }
@ -174,7 +174,7 @@ class ExplorerTransfers extends ChangeNotifier {
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]); final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
final always = switch (ctl.c.settings.copyConflict) { final always = switch (ctl.c.settings.copy.conflict) {
'rename' => NameConflict.rename, 'rename' => NameConflict.rename,
'overwrite' => NameConflict.overwrite, 'overwrite' => NameConflict.overwrite,
'skip' => NameConflict.skip, 'skip' => NameConflict.skip,
@ -240,7 +240,7 @@ class ExplorerTransfers extends ChangeNotifier {
? tr('안쪽 파일을 확인하는 중 …') ? tr('안쪽 파일을 확인하는 중 …')
: (n.data ?? 0) == 0 : (n.data ?? 0) == 0
? '' ? ''
: ctl.c.settings.copySkipNewer : ctl.c.settings.copy.skipNewer
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data])
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]),
style: TextStyle(fontSize: 12, color: JjColors.warning), style: TextStyle(fontSize: 12, color: JjColors.warning),

View File

@ -184,7 +184,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0)); if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_access.checkAccess()); if (Platform.isAndroid) unawaited(_access.checkAccess());
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorer.paths; final saved = widget.rsync ? c.settings.copy.rsyncPaths : c.settings.explorer.paths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait( final reach = await Future.wait(

View File

@ -120,12 +120,12 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
// 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게). // 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게).
// 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로 // 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로
if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copySkipNewer) || !mounted) return; if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copy.skipNewer) || !mounted) return;
widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest])); widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest]));
String? exe; String? exe;
if (t.method == 'rsync') { if (t.method == 'rsync') {
exe = await rsyncExecutable(widget.c.settings); exe = await rsyncExecutable(widget.c.settings);
if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) { if (exe == null && Platform.isWindows && widget.c.settings.copy.rsyncSource == 'download' && mounted) {
exe = await installRsyncWithDialog(context); exe = await installRsyncWithDialog(context);
} }
if (exe == null) { if (exe == null) {
@ -162,7 +162,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [ content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}', '${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만) // 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer), effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copy.skipNewer),
])), ])),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
@ -342,7 +342,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
], ],
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) { if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
_options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : ''; _options.text = v == 'robocopy' ? s.copy.robocopyOptions : v == 'rsync' ? s.copy.rsyncOptions : '';
} }
_method = v!; _method = v!;
}), }),
@ -363,7 +363,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
width: 460, width: 460,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text( Text(
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}', '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copy.skipNewer)])}',
style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim), style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim),
), ),
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다) // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)

View File

@ -147,10 +147,10 @@ Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyn
if (ok == true) await live.syncEmptyAnyway(x); if (ok == true) await live.syncEmptyAnyway(x);
} }
/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. /// 앱을 다시 켤 때 ([CopySettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기.
/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작). /// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작).
Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async { Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList(); final pairs = live.c.settings.copy.liveSyncPairs.where((x) => x.enabled).toList();
if (pairs.isEmpty) return; if (pairs.isEmpty) return;
final pick = {for (final x in pairs) LiveSync.keyOf(x)}; final pick = {for (final x in pairs) LiveSync.keyOf(x)};
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
@ -190,8 +190,8 @@ Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
} }
/// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로) /// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로)
Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [ Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.copy.liveSyncPairs = [
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y, for (final y in x.copy.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
]); ]);
/// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴 /// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴
@ -257,7 +257,7 @@ Future<bool> finalizeLiveSync(BuildContext context, AppController c, LiveSyncPai
); );
if (go != true || !context.mounted) return false; if (go != true || !context.mounted) return false;
var exe = await rsyncExecutable(c.settings); var exe = await rsyncExecutable(c.settings);
if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) { if (exe == null && Platform.isWindows && c.settings.copy.rsyncSource == 'download' && context.mounted) {
exe = await installRsyncWithDialog(context); exe = await installRsyncWithDialog(context);
} }
if (exe == null) { if (exe == null) {

View File

@ -86,7 +86,7 @@ class _LiveTabState extends State<_LiveTab> {
// 열 때 맞출 것을 새로 센다 // 열 때 맞출 것을 새로 센다
final live = LiveSync.instance; final live = LiveSync.instance;
if (live != null) { if (live != null) {
for (final x in c.settings.liveSyncPairs) { for (final x in c.settings.copy.liveSyncPairs) {
live.refreshPending(x); live.refreshPending(x);
} }
} }
@ -103,7 +103,7 @@ class _LiveTabState extends State<_LiveTab> {
return ListenableBuilder( return ListenableBuilder(
listenable: Listenable.merge([c, ?live]), listenable: Listenable.merge([c, ?live]),
builder: (context, _) { builder: (context, _) {
final pairs = c.settings.liveSyncPairs; final pairs = c.settings.copy.liveSyncPairs;
return ListView(padding: const EdgeInsets.all(12), children: [ return ListView(padding: const EdgeInsets.all(12), children: [
Row(children: [ Row(children: [
Expanded( Expanded(

View File

@ -42,13 +42,13 @@ class _RsyncSettingsSectionState extends State<RsyncSettingsSection> {
), ),
if (desk) if (desk)
SwitchListTile( SwitchListTile(
value: s.syncStopToast, value: s.copy.syncStopToast,
onChanged: (v) => c.updateSettings((x) => x.syncStopToast = v), onChanged: (v) => c.updateSettings((x) => x.copy.syncStopToast = v),
title: Text(tr('동기화가 멈추면 Windows 알림')), title: Text(tr('동기화가 멈추면 Windows 알림')),
subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')), subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')),
), ),
// Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음) // Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음)
if (desk && s.syncStopToast) const ToastStatusHint(), if (desk && s.copy.syncStopToast) const ToastStatusHint(),
]); ]);
} }
} }

View File

@ -89,9 +89,9 @@ bool isPlainHttp(String url) => url.trim().toLowerCase().startsWith('http://');
List<String> davServerUsers(AppSettings s, String id) { List<String> davServerUsers(AppSettings s, String id) {
bool on(String path) => isDav(path) && DavPath.parse(path).server == id; bool on(String path) => isDav(path) && DavPath.parse(path).server == id;
return [ return [
for (final x in s.liveSyncPairs) for (final x in s.copy.liveSyncPairs)
if (on(x.source) || on(x.target)) trf('실시간 동기화: {0} → {1}', [vDisplay(x.source), vDisplay(x.target)]), if (on(x.source) || on(x.target)) trf('실시간 동기화: {0} → {1}', [vDisplay(x.source), vDisplay(x.target)]),
for (final t in s.copyTasks) for (final t in s.copy.tasks)
if (on(t.dest) || t.sources.any(on)) trf('기억된 작업: {0} → {1}', [t.sources.map(vDisplay).join(', '), vDisplay(t.dest)]), if (on(t.dest) || t.sources.any(on)) trf('기억된 작업: {0} → {1}', [t.sources.map(vDisplay).join(', '), vDisplay(t.dest)]),
]; ];
} }

View File

@ -36,9 +36,9 @@ void main() {
Directory(p.join(tmp.path, 'sub')).createSync(); Directory(p.join(tmp.path, 'sub')).createSync();
c.settings c.settings
..explorer.paths = [tmp.path, tmp.path] ..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page(c)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page(c))));
for (var i = 0; i < 20; i++) { for (var i = 0; i < 20; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30))); await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));

View File

@ -36,7 +36,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_card_'); final tmp = Directory.systemTemp.createTempSync('jj_card_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
final src = p.join(tmp.path, 'movies', 'src'), dst = p.join(tmp.path, 'backup', 'dst'); final src = p.join(tmp.path, 'movies', 'src'), dst = p.join(tmp.path, 'backup', 'dst');
c.settings.liveSyncPairs = [LiveSyncPair(src, dst)]; c.settings.copy.liveSyncPairs = [LiveSyncPair(src, dst)];
final live = LiveSync(c); final live = LiveSync(c);
LiveSync.instance = live; LiveSync.instance = live;
addTearDown(() { addTearDown(() {
@ -61,13 +61,13 @@ void main() {
expect(find.text('실시간 동기화를 지울까요?'), findsOneWidget); expect(find.text('실시간 동기화를 지울까요?'), findsOneWidget);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, hasLength(1)); expect(c.settings.copy.liveSyncPairs, hasLength(1));
// 카드의 지우기 버튼 → 확인 → 지움 // 카드의 지우기 버튼 → 확인 → 지움
await tester.tap(find.byTooltip('지우기')); await tester.tap(find.byTooltip('지우기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '지우기')); await tester.tap(find.widgetWithText(FilledButton, '지우기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, isEmpty); expect(c.settings.copy.liveSyncPairs, isEmpty);
}); });
testWidgets('46: MKV 목록 "모두 지우기" 는 확인 뒤 · 45: 대기 작업이 있으면 지금 작업만 / 모두 고르기', (tester) async { testWidgets('46: MKV 목록 "모두 지우기" 는 확인 뒤 · 45: 대기 작업이 있으면 지금 작업만 / 모두 고르기', (tester) async {

View File

@ -27,7 +27,7 @@ void main() {
test('기본값 = 정한 답 · 저장 · 읽기', () { test('기본값 = 정한 답 · 저장 · 읽기', () {
final s = AppSettings(); final s = AppSettings();
expect([ expect([
s.startMenuShortcut, s.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter, s.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.rememberPasswords, s.recycleOnDelete, s.readerSystemBars, s.rememberPasswords, s.recycleOnDelete, s.readerSystemBars,
], everyElement(isTrue)); ], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']); expect(s.cleanupPrechecked, ['work', 'download']);
@ -37,12 +37,12 @@ void main() {
expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav], expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav],
everyElement(isTrue)); everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22) // 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.liveSyncOnStart, s.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]); expect([s.copy.liveSyncOnStart, s.copy.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]);
expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]); expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]);
// 바꾼 값은 남는다 // 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings() final b = AppSettings.fromJson((AppSettings()
..startMenuShortcut = false ..startMenuShortcut = false
..syncStopToast = false ..copy.syncStopToast = false
..swipeWrap = false ..swipeWrap = false
..migrationNotice = false ..migrationNotice = false
..allowInnerToOuter = false ..allowInnerToOuter = false
@ -52,7 +52,7 @@ void main() {
..tripleTapAction = 'none' ..tripleTapAction = 'none'
..davExternalOpen = 'url') ..davExternalOpen = 'url')
.toJson()); .toJson());
expect([b.startMenuShortcut, b.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, expect([b.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
b.recycleOnDelete], everyElement(isFalse)); b.recycleOnDelete], everyElement(isFalse));
expect([b.cleanupPrechecked, b.tripleTapAction, b.davExternalOpen], [['logs'], 'none', 'url']); expect([b.cleanupPrechecked, b.tripleTapAction, b.davExternalOpen], [['logs'], 'none', 'url']);
}); });

View File

@ -90,17 +90,17 @@ void main() {
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('o'); ..writeAsStringSync('o');
final pair = LiveSyncPair(src, dst, delete: true); final pair = LiveSyncPair(src, dst, delete: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair)); await tester.runAsync(() => live.syncNow(pair));
expect(live.toDelete[LiveSync.keyOf(pair)], ['only.txt']); expect(live.toDelete[LiveSync.keyOf(pair)], ['only.txt']);
await host(tester, (ctx) => confirmLiveDeletes(ctx, live, c.settings.liveSyncPairs.single)); await host(tester, (ctx) => confirmLiveDeletes(ctx, live, c.settings.copy.liveSyncPairs.single));
await dismiss(tester, how == 'cancel' ? 'back' : how); // 이 창의 "닫기" 는 [나중에] await dismiss(tester, how == 'cancel' ? 'back' : how); // 이 창의 "닫기" 는 [나중에]
await settle(tester, () => false, rounds: 10); await settle(tester, () => false, rounds: 10);
expect(File(p.join(dst, 'only.txt')).existsSync(), isTrue); expect(File(p.join(dst, 'only.txt')).existsSync(), isTrue);
expect(c.settings.liveSyncPairs.single.delete, isTrue); expect(c.settings.copy.liveSyncPairs.single.delete, isTrue);
expect(c.settings.liveSyncPairs.single.deleteConfirmed, isFalse); expect(c.settings.copy.liveSyncPairs.single.deleteConfirmed, isFalse);
}); });
testWidgets('70 원본이 빈 쌍의 [그래도 맞추기] 창 ($how) → 지우지 않음', (tester) async { testWidgets('70 원본이 빈 쌍의 [그래도 맞추기] 창 ($how) → 지우지 않음', (tester) async {
@ -110,7 +110,7 @@ void main() {
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('k'); ..writeAsStringSync('k');
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair)); await tester.runAsync(() => live.syncNow(pair));
@ -151,7 +151,7 @@ void main() {
final keep = File(p.join(dst, 'keep.txt')) final keep = File(p.join(dst, 'keep.txt'))
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('k'); ..writeAsStringSync('k');
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -175,7 +175,7 @@ void main() {
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('a'); ..writeAsStringSync('a');
Directory(dst).createSync(); Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -278,7 +278,7 @@ void main() {
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('a'); ..writeAsStringSync('a');
Directory(dst).createSync(); Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'robocopy', options: '/E')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'robocopy', options: '/E')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -297,11 +297,11 @@ void main() {
testWidgets('237: 모니터링 카드 실패 글은 예외 이름 없이, 원문 (명령 줄 포함) 은 [자세히] 에서 보고 복사', (tester) async { testWidgets('237: 모니터링 카드 실패 글은 예외 이름 없이, 원문 (명령 줄 포함) 은 [자세히] 에서 보고 복사', (tester) async {
const raw = 'ProcessException: 원본 파일을 지우지 못했습니다\n Command: rsync -avPog --remove-source-files a/ b/'; const raw = 'ProcessException: 원본 파일을 지우지 못했습니다\n Command: rsync -avPog --remove-source-files a/ b/';
c.settings.copyTasks = [ c.settings.copy.tasks = [
const CopyTask(id: '1', sources: ['a'], dest: 'b', method: 'rsync', options: '-avPog', move: true) const CopyTask(id: '1', sources: ['a'], dest: 'b', method: 'rsync', options: '-avPog', move: true)
.copyWith(lastResult: 'failed', lastRun: DateTime(2026, 10, 12, 9).toIso8601String(), lastMessage: '원본 파일을 지우지 못했습니다', lastDetail: raw), .copyWith(lastResult: 'failed', lastRun: DateTime(2026, 10, 12, 9).toIso8601String(), lastMessage: '원본 파일을 지우지 못했습니다', lastDetail: raw),
]; ];
expect(CopyTask.fromJson(c.settings.copyTasks.single.toJson()).lastDetail, raw); expect(CopyTask.fromJson(c.settings.copy.tasks.single.toJson()).lastDetail, raw);
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -339,7 +339,7 @@ void main() {
final src = p.join(tmp.path, 'mv243'), dst = p.join(tmp.path, 'mv243d'); final src = p.join(tmp.path, 'mv243'), dst = p.join(tmp.path, 'mv243d');
Directory(src).createSync(); Directory(src).createSync();
Directory(dst).createSync(); Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -350,12 +350,12 @@ void main() {
expect(find.textContaining('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤)'), findsOneWidget); expect(find.textContaining('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤)'), findsOneWidget);
await tester.tap(find.text('취소').last); await tester.tap(find.text('취소').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음'); expect(c.settings.copy.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음');
await tester.tap(find.text('lsync 로 이동').first); await tester.tap(find.text('lsync 로 이동').first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '옮기기')); await tester.tap(find.widgetWithText(FilledButton, '옮기기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs.map((x) => x.source), [src]); expect(c.settings.copy.liveSyncPairs.map((x) => x.source), [src]);
}); });
testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async { testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async {
@ -365,9 +365,9 @@ void main() {
..writeAsStringSync('n'); ..writeAsStringSync('n');
final center = CopyCenter.of(c); final center = CopyCenter.of(c);
c.settings c.settings
..rsyncOptions = '-avPog' ..copy.rsyncOptions = '-avPog'
..explorer.layout = 'single' ..explorer.layout = 'single'
..rsyncPaths = [outer, tmp.path]; ..copy.rsyncPaths = [outer, tmp.path];
// 이동 (원본 파일 지우기) 작업만 --delete 를 기억 // 이동 (원본 파일 지우기) 작업만 --delete 를 기억
await center.remember([inner], outer, contents: true, method: 'rsync', move: true).then((t) => center.update(t.copyWith(options: '-avPog --delete'))); await center.remember([inner], outer, contents: true, method: 'rsync', move: true).then((t) => center.update(t.copyWith(options: '-avPog --delete')));
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
@ -404,7 +404,7 @@ void main() {
final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb'); final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb');
Directory(a).createSync(recursive: true); Directory(a).createSync(recursive: true);
Directory(b).createSync(recursive: true); Directory(b).createSync(recursive: true);
c.settings.copyTasks = [ c.settings.copy.tasks = [
CopyTask(id: '1', sources: [a], dest: b, contents: true, method: 'rsync', options: '-avPog --remove-source-files'), CopyTask(id: '1', sources: [a], dest: b, contents: true, method: 'rsync', options: '-avPog --remove-source-files'),
CopyTask(id: '2', sources: [b], dest: a, contents: true, move: true, method: 'rsync', options: '-avPog'), CopyTask(id: '2', sources: [b], dest: a, contents: true, move: true, method: 'rsync', options: '-avPog'),
]; ];
@ -427,7 +427,7 @@ void main() {
File(p.join(dst, 'keep.txt')) File(p.join(dst, 'keep.txt'))
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('k'); ..writeAsStringSync('k');
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -441,7 +441,7 @@ void main() {
expect(find.text('고친 옵션을 저장하지 않았습니다'), findsOneWidget); expect(find.text('고친 옵션을 저장하지 않았습니다'), findsOneWidget);
await tester.tap(find.text('저장하고 실행')); await tester.tap(find.text('저장하고 실행'));
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
expect(c.settings.copyTasks.single.options, '-av --delete'); expect(c.settings.copy.tasks.single.options, '-av --delete');
// 저장한 옵션에 지우기가 있으므로 미리 보기 창 // 저장한 옵션에 지우기가 있으므로 미리 보기 창
expect(find.text('지우기가 들어 있는 작업입니다'), findsOneWidget); expect(find.text('지우기가 들어 있는 작업입니다'), findsOneWidget);
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);

View File

@ -158,8 +158,8 @@ void main() {
testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async { testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async {
c.settings c.settings
..copyMethodFile = 'robocopy' ..copy.methodFile = 'robocopy'
..robocopyOptions = '/E /MOV'; ..copy.robocopyOptions = '/E /MOV';
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt'))); await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15); await settle(tester, () => false, rounds: 15);

View File

@ -103,7 +103,7 @@ void main() {
}); });
testWidgets('48 보충: 환경 설정이 "늘 덮어쓰기" 면 묻지 않고 (확인 창에 알림만) 덮어쓴다', (tester) async { testWidgets('48 보충: 환경 설정이 "늘 덮어쓰기" 면 묻지 않고 (확인 창에 알림만) 덮어쓴다', (tester) async {
c.settings.copyConflict = 'overwrite'; c.settings.copy.conflict = 'overwrite';
await startCopy(tester); await startCopy(tester);
expect(find.text('덮어쓰기 (원래 파일은 없어집니다)'), findsNothing, reason: '고르는 칸 없음'); expect(find.text('덮어쓰기 (원래 파일은 없어집니다)'), findsNothing, reason: '고르는 칸 없음');
expect(find.textContaining('환경 설정대로 덮어씁니다'), findsOneWidget); expect(find.textContaining('환경 설정대로 덮어씁니다'), findsOneWidget);
@ -121,7 +121,7 @@ void main() {
File(p.join(right, 'D', 'same.txt')) File(p.join(right, 'D', 'same.txt'))
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('NEWER'); ..writeAsStringSync('NEWER');
c.settings.copyConflict = 'overwrite'; c.settings.copy.conflict = 'overwrite';
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -167,7 +167,7 @@ void main() {
testWidgets('52: 복사 중에 화면을 떠났다 돌아와도 진행 막대가 다시 보이고, 끝나면 받는 폴더를 새로 읽는다 (.jjpart 가 남아 보이지 않음)', (tester) async { testWidgets('52: 복사 중에 화면을 떠났다 돌아와도 진행 막대가 다시 보이고, 끝나면 받는 폴더를 새로 읽는다 (.jjpart 가 남아 보이지 않음)', (tester) async {
File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(150 * 1024, 3)); File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(150 * 1024, 3));
c.settings.copyBandwidthKBps = 60; // 약 2.5초 걸리게 c.settings.copy.bandwidthKBps = 60; // 약 2.5초 걸리게
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);

View File

@ -208,7 +208,7 @@ void main() {
File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것 File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것
c.settings c.settings
..explorer.layout = 'single' // 파일 탐색기 배치와 상관없이 두 창 ..explorer.layout = 'single' // 파일 탐색기 배치와 상관없이 두 창
..rsyncPaths = [left, right]; ..copy.rsyncPaths = [left, right];
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -257,7 +257,7 @@ void main() {
expect(find.textContaining('-avPog -u'), findsNWidgets(2)); expect(find.textContaining('-avPog -u'), findsNWidgets(2));
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await calm(tester); await calm(tester);
expect(c.settings.copyTasks, isEmpty); expect(c.settings.copy.tasks, isEmpty);
expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음 expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
@ -276,7 +276,7 @@ void main() {
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await calm(tester); await calm(tester);
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터) // 고른 폴더는 기억 (다음에 열 때 그 폴더부터)
expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]); expect(c.settings.copy.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]);
// 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에) // 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에)
await act(tester, () => tester.tap(find.text('rsub'))); await act(tester, () => tester.tap(find.text('rsub')));

View File

@ -152,7 +152,7 @@ void main() {
File(p.join(left, 'e.txt')).writeAsStringSync('e'); File(p.join(left, 'e.txt')).writeAsStringSync('e');
c.settings c.settings
..explorer.dropAction = 'move' ..explorer.dropAction = 'move'
..copyConflict = 'overwrite'; ..copy.conflict = 'overwrite';
await open(tester); await open(tester);
// 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지 // 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지
await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty);
@ -178,7 +178,7 @@ void main() {
..writeAsStringSync('mine'); ..writeAsStringSync('mine');
c.settings c.settings
..explorer.dropAction = 'move' ..explorer.dropAction = 'move'
..copyConflict = 'overwrite'; ..copy.conflict = 'overwrite';
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('선택').first)); await act(tester, () => tester.tap(find.text('선택').first));
await act(tester, () => tester.tap(markOf('doc.txt'))); await act(tester, () => tester.tap(markOf('doc.txt')));

View File

@ -92,7 +92,7 @@ void main() {
test('Rsync 실행: 막는 이유 · 쌍 · ⇄ 의 옵션 (← 는 지우지 않음 · -u) · 원본 지우기 · 미리 보기 (실제 폴더)', () async { test('Rsync 실행: 막는 이유 · 쌍 · ⇄ 의 옵션 (← 는 지우지 않음 · -u) · 원본 지우기 · 미리 보기 (실제 폴더)', () async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.rsyncOptions = '-a --delete'; c.settings.copy.rsyncOptions = '-a --delete';
final center = CopyCenter.of(c); final center = CopyCenter.of(c);
final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R');
Directory(l).createSync(); Directory(l).createSync();

View File

@ -30,7 +30,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.paths = [left, right] ..explorer.paths = [left, right]
..copyBandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게 ..copy.bandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -70,7 +70,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.orientation = 'side' ..explorer.orientation = 'side'
..rsyncPaths = [l, r]; ..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(400, 860); tester.view.physicalSize = const Size(400, 860);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -110,7 +110,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.orientation = 'side' ..explorer.orientation = 'side'
..rsyncPaths = [l, r]; ..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(412, 900); tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -146,7 +146,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.orientation = 'side' ..explorer.orientation = 'side'
..rsyncPaths = [l, r]; ..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(860, 400); tester.view.physicalSize = const Size(860, 400);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -168,7 +168,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.orientation = 'side' ..explorer.orientation = 'side'
..rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')]; ..copy.rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')];
tester.view.physicalSize = const Size(412, 880); tester.view.physicalSize = const Size(412, 880);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -194,7 +194,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.layout = 'single' ..explorer.layout = 'single'
..rsyncPaths = [l, r]; ..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);

View File

@ -185,7 +185,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
c.settings.rsyncPaths = [left, right]; c.settings.copy.rsyncPaths = [left, right];
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
@ -203,9 +203,9 @@ void main() {
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await calm(tester); await calm(tester);
await act(tester, () => tester.tap(find.text('거꾸로'))); await act(tester, () => tester.tap(find.text('거꾸로')));
await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left, await settle(tester, () => c.settings.copy.rsyncPaths.length == 2 && c.settings.copy.rsyncPaths[0] == right && c.settings.copy.rsyncPaths[1] == left,
rounds: 100); rounds: 100);
expect(c.settings.rsyncPaths, [right, left]); expect(c.settings.copy.rsyncPaths, [right, left]);
}); });
testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async { testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async {

View File

@ -38,8 +38,8 @@ void main() {
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell())); c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell()));
c.settings c.settings
..explorer.paths = [left, right] ..explorer.paths = [left, right]
..copyMethodFile = 'builtin' ..copy.methodFile = 'builtin'
..copyMethodFolder = 'builtin'; ..copy.methodFolder = 'builtin';
}); });
tearDown(() async { tearDown(() async {
for (var i = 0;; i++) { for (var i = 0;; i++) {

View File

@ -122,8 +122,8 @@ void main() {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings c.settings
..webdavServers = [s] ..webdavServers = [s]
..rsyncOptions = '-av --remove-source-files' ..copy.rsyncOptions = '-av --remove-source-files'
..rsyncPaths = [left, 'dav://nas/']; ..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]); DavRegistry.configure([s]);
// 1280×720 (낮은 창) 에서도 체크 · 경고가 버튼에 가려지지 않게 // 1280×720 (낮은 창) 에서도 체크 · 경고가 버튼에 가려지지 않게
await pump(tester, ExplorerPage(device: device, c: c, rsync: true), size: const Size(1280, 720)); await pump(tester, ExplorerPage(device: device, c: c, rsync: true), size: const Size(1280, 720));
@ -193,7 +193,7 @@ void main() {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings c.settings
..webdavServers = [s] ..webdavServers = [s]
..rsyncPaths = [left, 'dav://nas/']; ..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]); DavRegistry.configure([s]);
await pump(tester, ExplorerPage(device: device, c: c, rsync: true)); await pump(tester, ExplorerPage(device: device, c: c, rsync: true));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty); await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty);
@ -216,6 +216,6 @@ void main() {
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200); await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
expect(got.readAsStringSync(), 'inner'); expect(got.readAsStringSync(), 'inner');
expect(File(p.join(remote.path, '원격', 'note.txt')).existsSync(), isTrue); // 지우기 없이 expect(File(p.join(remote.path, '원격', 'note.txt')).existsSync(), isTrue); // 지우기 없이
expect(c.settings.copyTasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음 expect(c.settings.copy.tasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음
}); });
} }

View File

@ -53,9 +53,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..explorer.paths = [tmp.path, tmp.path] ..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4'))); c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4')));
c.selected = c.videos.first; c.selected = c.videos.first;
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);

View File

@ -5,7 +5,7 @@ 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()..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 = LiveSync.rsyncArgsFor(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 = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
@ -13,7 +13,7 @@ void main() {
}); });
test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () { test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () {
final s = AppSettings()..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 = LiveSync.rsyncArgsFor(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 = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
@ -22,7 +22,7 @@ void main() {
}); });
test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () { test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () {
final s = AppSettings()..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 = LiveSync.robocopyArgsFor(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')));
@ -33,8 +33,8 @@ void main() {
test('107: 원본을 지우는 옵션 (--remove-source-files · /MOV · /MOVE) 도 실시간 동기화에서는 쓰지 않는다', () { test('107: 원본을 지우는 옵션 (--remove-source-files · /MOV · /MOVE) 도 실시간 동기화에서는 쓰지 않는다', () {
final s = AppSettings() final s = AppSettings()
..rsyncOptions = '-avPog --remove-source-files --remove-sent-files' ..copy.rsyncOptions = '-avPog --remove-source-files --remove-sent-files'
..robocopyOptions = '/E /MOV /MOVE'; ..copy.robocopyOptions = '/E /MOV /MOVE';
final r = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); final r = LiveSync.rsyncArgsFor(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 = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:', r'D:', delete: true), s).map((o) => o.toUpperCase()).toList();

View File

@ -31,8 +31,8 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로) // 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로)
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync; ..copy.rsyncPath = rsync;
final bad = <String>[]; final bad = <String>[];
for (final opts in sceneOptions) { for (final opts in sceneOptions) {
writeScene(src, want); writeScene(src, want);
@ -40,9 +40,9 @@ void main() {
writeScene(src, got); writeScene(src, got);
final before = treeOf(got); final before = treeOf(got);
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
c.settings.rsyncOptions = opts; c.settings.copy.rsyncOptions = opts;
final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts)); final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts));
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
final why = <String>[]; final why = <String>[];
@ -64,7 +64,7 @@ void main() {
if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl'); if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl');
if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl'); if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl');
if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl'); if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl');
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
why.addAll(treeDiff(w, treeOf(got))); why.addAll(treeDiff(w, treeOf(got)));
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
live.dispose(); live.dispose();
@ -86,11 +86,11 @@ void main() {
} }
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; ..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair)); await tester.runAsync(() => live.syncNow(pair));
@ -130,29 +130,29 @@ void main() {
} }
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp'; ..copy.rsyncOptions = '-av --exclude=*.tmp';
// 이미 이 옵션으로 확인한 쌍 // 이미 이 옵션으로 확인한 쌍
c.settings.liveSyncPairs = [ c.settings.copy.liveSyncPairs = [
LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'), LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
]; ];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
LiveSyncPair pair() => c.settings.liveSyncPairs.single; LiveSyncPair pair() => c.settings.copy.liveSyncPairs.single;
final k = LiveSync.keyOf(pair()); final k = LiveSync.keyOf(pair());
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], isNull); expect(live.toDelete[k], isNull);
expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue); expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue);
// ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억 // ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억
c.settings.rsyncOptions = '-avh --exclude=*.tmp'; c.settings.copy.rsyncOptions = '-avh --exclude=*.tmp';
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], isNull); expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-avh --exclude=*.tmp'); expect(pair().confirmedOptions, '-avh --exclude=*.tmp');
// ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음 // ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음
c.settings.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded'; c.settings.copy.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded';
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep'])); expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep']));
expect(live.reasked, contains(k)); expect(live.reasked, contains(k));
@ -167,7 +167,7 @@ void main() {
expect(live.reasked, isNot(contains(k))); expect(live.reasked, isNot(contains(k)));
// ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음 // ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음
c.settings.rsyncOptions = '-avh'; c.settings.copy.rsyncOptions = '-avh';
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], isNull); expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-avh'); expect(pair().confirmedOptions, '-avh');
@ -175,15 +175,15 @@ void main() {
// ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만 // ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만
File(p.join(dst, 'new.tmp')).writeAsStringSync('n'); File(p.join(dst, 'new.tmp')).writeAsStringSync('n');
c.settings c.settings
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded' ..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; ..copy.liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
expect(pair().confirmedOptions, isNull); expect(pair().confirmedOptions, isNull);
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], ['new.tmp']); expect(live.toDelete[k], ['new.tmp']);
expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue); expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue);
c.settings c.settings
..rsyncOptions = '-av --exclude=*.tmp' ..copy.rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; ..copy.liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], isNull); expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-av --exclude=*.tmp'); expect(pair().confirmedOptions, '-av --exclude=*.tmp');
@ -219,8 +219,8 @@ void main() {
} }
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = sceneRsync ?? ''; ..copy.rsyncPath = sceneRsync ?? '';
}); });
tearDown(() => tmp.deleteSync(recursive: true)); tearDown(() => tmp.deleteSync(recursive: true));
bool there(String rel) => File(p.join(dst, rel)).existsSync(); bool there(String rel) => File(p.join(dst, rel)).existsSync();
@ -248,14 +248,14 @@ void main() {
expect(r.exitCode, 0, reason: '${r.stderr}'); expect(r.exitCode, 0, reason: '${r.stderr}');
expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움'); expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움');
c.settings.rsyncOptions = opts; c.settings.copy.rsyncOptions = opts;
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
for (var i = 0; i < 2; i++) { for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single); await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(live.status[k]!.$2, isNot(contains('실패'))); expect(live.status[k]!.$2, isNot(contains('실패')));
expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)'); expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)');
expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함'); expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함');
@ -264,13 +264,13 @@ void main() {
expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음'); expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음');
} }
// [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크) // [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크)
final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single); final (list, _) = await live.previewDeletes(c.settings.copy.liveSyncPairs.single);
expect(list, unorderedEquals(['old.log', 'extra.txt'])); expect(list, unorderedEquals(['old.log', 'extra.txt']));
await live.deleteNow(c.settings.liveSyncPairs.single); await live.deleteNow(c.settings.copy.liveSyncPairs.single);
expect([there('old.log'), there('extra.txt')], [false, false]); expect([there('old.log'), there('extra.txt')], [false, false]);
// 다음 자동 맞추기는 다시 보내기만 // 다음 자동 맞추기는 다시 보내기만
File(p.join(dst, 'extra2.txt')).writeAsStringSync('x'); File(p.join(dst, 'extra2.txt')).writeAsStringSync('x');
await live.syncNow(c.settings.liveSyncPairs.single); await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(there('extra2.txt'), isTrue); expect(there('extra2.txt'), isTrue);
expect(live.noAutoDelete[k], isNotEmpty); expect(live.noAutoDelete[k], isNotEmpty);
}); });
@ -279,19 +279,19 @@ void main() {
test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async { test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="P old.log"'; c.settings.copy.rsyncOptions = '-a --filter="P old.log"';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
await live.syncNow(pair); await live.syncNow(pair);
expect(live.noAutoDelete[k], isNull); expect(live.noAutoDelete[k], isNull);
expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴'); expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴');
await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
expect((there('old.log'), there('extra.txt')), (true, false)); expect((there('old.log'), there('extra.txt')), (true, false));
for (var i = 0; i < 2; i++) { for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single); await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)'); expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)');
expect(live.pending[k], isEmpty); expect(live.pending[k], isEmpty);
} }
@ -299,9 +299,9 @@ void main() {
test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async { test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --exclude=*.log'; c.settings.copy.rsyncOptions = '-a --exclude=*.log';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await live.syncNow(pair); await live.syncNow(pair);
@ -312,9 +312,9 @@ void main() {
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async { testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로 c.settings.copy.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
late BuildContext ctx; late BuildContext ctx;

View File

@ -50,7 +50,7 @@ void main() {
() async { () async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
await live.syncNow(pair); await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤'); expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤');
@ -65,7 +65,7 @@ void main() {
..writeAsStringSync('a'); ..writeAsStringSync('a');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(src, dst, delete: true); // 새 쌍: deleteConfirmed false final pair = LiveSyncPair(src, dst, delete: true); // 새 쌍: deleteConfirmed false
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
await live.syncNow(pair); await live.syncNow(pair);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
@ -74,8 +74,8 @@ void main() {
expect(live.toDelete[k], unorderedEquals(['keep1.txt', 'sub'])); expect(live.toDelete[k], unorderedEquals(['keep1.txt', 'sub']));
expect(live.status[k]!.$2, contains('확인 필요')); expect(live.status[k]!.$2, contains('확인 필요'));
// 지우기 포함으로 맞추기 (확인) // 지우기 포함으로 맞추기 (확인)
await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
expect(c.settings.liveSyncPairs.single.deleteConfirmed, isTrue); expect(c.settings.copy.liveSyncPairs.single.deleteConfirmed, isTrue);
expect(File(p.join(dst, 'keep1.txt')).existsSync(), isFalse); expect(File(p.join(dst, 'keep1.txt')).existsSync(), isFalse);
expect(live.toDelete[k], isNull); expect(live.toDelete[k], isNull);
live.dispose(); live.dispose();

View File

@ -54,10 +54,10 @@ void main() {
final a = LiveSyncPair(p.join(tmp.path, 'a'), p.join(tmp.path, 'A')); final a = LiveSyncPair(p.join(tmp.path, 'a'), p.join(tmp.path, 'A'));
final b = LiveSyncPair(p.join(tmp.path, 'b'), p.join(tmp.path, 'B')); final b = LiveSyncPair(p.join(tmp.path, 'b'), p.join(tmp.path, 'B'));
final d = LiveSyncPair(p.join(tmp.path, 'd'), p.join(tmp.path, 'D')); final d = LiveSyncPair(p.join(tmp.path, 'd'), p.join(tmp.path, 'D'));
c.settings.liveSyncPairs = [a, b, d]; c.settings.copy.liveSyncPairs = [a, b, d];
await replaceLiveSyncPair(c, b, b.copyWith(enabled: false)); await replaceLiveSyncPair(c, b, b.copyWith(enabled: false));
expect(c.settings.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]); expect(c.settings.copy.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]);
expect(c.settings.liveSyncPairs.map((x) => x.enabled), [true, false, true]); expect(c.settings.copy.liveSyncPairs.map((x) => x.enabled), [true, false, true]);
}); });
testWidgets('copy_task_card: 작업이 없으면 안내, 있으면 작업마다 카드', (tester) async { testWidgets('copy_task_card: 작업이 없으면 안내, 있으면 작업마다 카드', (tester) async {
@ -76,7 +76,7 @@ void main() {
testWidgets('live_pair_card: 화면 일은 부르는 쪽 콜백으로 (최종 정리 · 복사 목록으로)', (tester) async { testWidgets('live_pair_card: 화면 일은 부르는 쪽 콜백으로 (최종 정리 · 복사 목록으로)', (tester) async {
final c = _controller(); final c = _controller();
final x = LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), method: 'builtin'); final x = LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), method: 'builtin');
c.settings.liveSyncPairs = [x]; c.settings.copy.liveSyncPairs = [x];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -95,12 +95,12 @@ void main() {
// 스위치는 replaceLiveSyncPair 로 저장 // 스위치는 replaceLiveSyncPair 로 저장
await tester.tap(find.byType(Switch)); await tester.tap(find.byType(Switch));
await tester.pump(); await tester.pump();
expect(c.settings.liveSyncPairs.single.enabled, isFalse); expect(c.settings.copy.liveSyncPairs.single.enabled, isFalse);
// 복사 목록으로 옮기면 lsync 쌍은 빠지고 복사 작업이 생긴 뒤 부르는 쪽 콜백 // 복사 목록으로 옮기면 lsync 쌍은 빠지고 복사 작업이 생긴 뒤 부르는 쪽 콜백
await tester.tap(find.text('복사 · rsync 로 이동')); await tester.tap(find.text('복사 · rsync 로 이동'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(shown, 1); expect(shown, 1);
expect(c.settings.liveSyncPairs, isEmpty); expect(c.settings.copy.liveSyncPairs, isEmpty);
expect(c.settings.copyTasks.single.dest, x.target); expect(c.settings.copy.tasks.single.dest, x.target);
}); });
} }

View File

@ -88,7 +88,7 @@ void main() {
final single = File(p.join(tmp.path, 'single.txt'))..writeAsStringSync('s'); final single = File(p.join(tmp.path, 'single.txt'))..writeAsStringSync('s');
final center = CopyCenter(c); final center = CopyCenter(c);
final t = CopyTask(id: '9', sources: [src, single.path], dest: dst, move: true, method: 'rsync', prune: 'all', once: true); final t = CopyTask(id: '9', sources: [src, single.path], dest: dst, move: true, method: 'rsync', prune: 'all', once: true);
await c.updateSettings((x) => x.copyTasks = [t]); await c.updateSettings((x) => x.copy.tasks = [t]);
expect(await center.toLiveSync(center.tasks.single), 1); expect(await center.toLiveSync(center.tasks.single), 1);
final rest = center.tasks.single; final rest = center.tasks.single;
expect(rest.sources, [single.path]); expect(rest.sources, [single.path]);
@ -96,7 +96,7 @@ void main() {
}); });
test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async { test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async {
c.settings.copyMethodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음) c.settings.copy.methodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음)
final center = CopyCenter(c); final center = CopyCenter(c);
final t = await center.remember([src], dst); final t = await center.remember([src], dst);
await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77)); await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77));
@ -114,12 +114,12 @@ void main() {
// lsync 로: 원본 → 대상\이름 // lsync 로: 원본 → 대상\이름
expect(await center.toLiveSync(center.tasks.single), 1); expect(await center.toLiveSync(center.tasks.single), 1);
expect(center.tasks, isEmpty); expect(center.tasks, isEmpty);
final pair = c.settings.liveSyncPairs.single; final pair = c.settings.copy.liveSyncPairs.single;
expect([pair.source, pair.target], [src, p.join(dst, 'src')]); expect([pair.source, pair.target], [src, p.join(dst, 'src')]);
// 다시 복사 목록으로 (안의 것 → 대상) // 다시 복사 목록으로 (안의 것 → 대상)
final back = await center.fromLiveSync(pair); final back = await center.fromLiveSync(pair);
expect(c.settings.liveSyncPairs, isEmpty); expect(c.settings.copy.liveSyncPairs, isEmpty);
expect([back.contents, back.dest], [true, p.join(dst, 'src')]); expect([back.contents, back.dest], [true, p.join(dst, 'src')]);
expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면) expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면)
File(p.join(src, 'new.txt')).writeAsStringSync('n'); File(p.join(src, 'new.txt')).writeAsStringSync('n');
@ -139,7 +139,7 @@ void main() {
}); });
test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async { test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async {
c.settings.copyMethodFolder = 'rsync'; c.settings.copy.methodFolder = 'rsync';
final center = CopyCenter(c); final center = CopyCenter(c);
final t = await center.remember([src], src); // 자기 안으로 final t = await center.remember([src], src); // 자기 안으로
expect(await center.start(t), isNull); expect(await center.start(t), isNull);
@ -157,7 +157,7 @@ void main() {
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 LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt']));
expect(LiveSync.activeNow(pair), isFalse); expect(LiveSync.activeNow(pair), isFalse);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c)..start(); final live = LiveSync(c)..start();
// 모아서 3초 뒤 (컴퓨터가 바쁘면 늦을 수 있어 정해진 4초가 아니라 될 때까지 - 최대 20초) // 모아서 3초 뒤 (컴퓨터가 바쁘면 늦을 수 있어 정해진 4초가 아니라 될 때까지 - 최대 20초)
for (var i = 0; i < 80 && (live.pending[LiveSync.keyOf(pair)]?.length ?? 0) < 2; i++) { for (var i = 0; i < 80 && (live.pending[LiveSync.keyOf(pair)]?.length ?? 0) < 2; i++) {
@ -191,10 +191,10 @@ void main() {
test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () { test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () {
final s = AppSettings() final s = AppSettings()
..runInBackground = true ..runInBackground = true
..liveSyncOnStart = 'ask'; ..copy.liveSyncOnStart = 'ask';
final r = AppSettings.fromJson(s.toJson()); final r = AppSettings.fromJson(s.toJson());
expect([r.runInBackground, r.liveSyncOnStart], [true, 'ask']); expect([r.runInBackground, r.copy.liveSyncOnStart], [true, 'ask']);
expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).liveSyncOnStart, 'auto'); expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).copy.liveSyncOnStart, 'auto');
// 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로 // 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로
expect(AppSettings.fromJson({}).runInBackground, isTrue); expect(AppSettings.fromJson({}).runInBackground, isTrue);
expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).runInBackground, isFalse); expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).runInBackground, isFalse);
@ -218,9 +218,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pa = LiveSyncPair(a.path, p.join(tmp.path, 'ta')), pb = LiveSyncPair(b.path, p.join(tmp.path, 'tb')); final pa = LiveSyncPair(a.path, p.join(tmp.path, 'ta')), pb = LiveSyncPair(b.path, p.join(tmp.path, 'tb'));
c.settings c.settings
..liveSyncPairs = [pa, pb] ..copy.liveSyncPairs = [pa, pb]
..liveSyncOnStart = 'off'; ..copy.liveSyncOnStart = 'off';
final live = LiveSync(c)..start(hold: c.settings.liveSyncOnStart != 'auto'); final live = LiveSync(c)..start(hold: c.settings.copy.liveSyncOnStart != 'auto');
// 맞추기 (rsync 프로세스) 가 끝난 뒤 닫고 지운다 - 도는 중에 지우면 "다른 프로세스가 사용 중" (전체 시험이 무거울 때) // 맞추기 (rsync 프로세스) 가 끝난 뒤 닫고 지운다 - 도는 중에 지우면 "다른 프로세스가 사용 중" (전체 시험이 무거울 때)
addTearDown(() async { addTearDown(() async {
for (var i = 0; i < 200 && live.anyRunning; i++) { for (var i = 0; i < 200 && live.anyRunning; i++) {
@ -264,7 +264,7 @@ void main() {
Directory(p.join(t.path, 'onlydir')).createSync(); Directory(p.join(t.path, 'onlydir')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(a.path, t.path, method: 'builtin'); final pair = LiveSyncPair(a.path, t.path, method: 'builtin');
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await live.refreshPending(pair); await live.refreshPending(pair);
@ -283,7 +283,7 @@ void main() {
File(p.join(a.path, '1.txt')).writeAsStringSync('1'); File(p.join(a.path, '1.txt')).writeAsStringSync('1');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(a.path, p.join(tmp.path, 't'), method: 'builtin'); final pair = LiveSyncPair(a.path, p.join(tmp.path, 't'), method: 'builtin');
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final running = live.syncNow(pair); final running = live.syncNow(pair);
live.dispose(); live.dispose();
@ -297,8 +297,8 @@ void main() {
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')] ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])]; ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -318,7 +318,7 @@ void main() {
expect(find.text('설정 반영 후 실행'), findsOneWidget); expect(find.text('설정 반영 후 실행'), findsOneWidget);
await tester.tap(find.text('현재 유지')); await tester.tap(find.text('현재 유지'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.copyTasks.single.bandwidthKBps, 500); expect(c.settings.copy.tasks.single.bandwidthKBps, 500);
expect(CopyCenter.of(c).jobs, isEmpty); expect(CopyCenter.of(c).jobs, isEmpty);
// lsync 탭: 일정 요약 · 맞추기 버튼 // lsync 탭: 일정 요약 · 맞추기 버튼
@ -333,7 +333,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_monbad_'); final tmp = Directory.systemTemp.createTempSync('jj_monbad_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -353,7 +353,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_mon233_'); final tmp = Directory.systemTemp.createTempSync('jj_mon233_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')]; c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')];
tester.view.physicalSize = const Size(1500, 1000); tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -372,6 +372,6 @@ void main() {
expect(find.text('받는 쪽이 더 새것이면 건너뛰기'), findsOneWidget); expect(find.text('받는 쪽이 더 새것이면 건너뛰기'), findsOneWidget);
await tester.tap(find.text('받는 쪽이 더 새것이면 건너뛰기')); await tester.tap(find.text('받는 쪽이 더 새것이면 건너뛰기'));
await tester.pump(); await tester.pump();
expect(c.settings.copySkipNewer, isFalse); expect(c.settings.copy.skipNewer, isFalse);
}); });
} }

View File

@ -182,8 +182,8 @@ void main() {
}); });
test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async { test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async {
expect(AppSettings.fromJson({}).copySkipNewer, isTrue); expect(AppSettings.fromJson({}).copy.skipNewer, isTrue);
expect(AppSettings.fromJson((AppSettings()..copySkipNewer = false).toJson()).copySkipNewer, isFalse); expect(AppSettings.fromJson((AppSettings()..copy.skipNewer = false).toJson()).copy.skipNewer, isFalse);
final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src'); final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src');
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst'); File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst');

View File

@ -225,14 +225,14 @@ void main() {
final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']); final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = '-a' ..copy.rsyncOptions = '-a'
..liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')]; ..copy.liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
await live.syncNow(c.settings.liveSyncPairs.single); await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.single)]!.$2, isNot(contains('실패'))); expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.single)]!.$2, isNot(contains('실패')));
expect(treeOf(d).keys.toSet(), {'a.txt'}); expect(treeOf(d).keys.toSet(), {'a.txt'});
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
@ -241,13 +241,13 @@ void main() {
final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']); final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"' ..copy.rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')]; ..copy.liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single; final pair = c.settings.copy.liveSyncPairs.single;
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('실패')));
final t = treeOf(d); final t = treeOf(d);

View File

@ -159,15 +159,15 @@ void main() {
// ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤) // ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤)
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..rsyncPath = rsync ..copy.rsyncPath = rsync
..rsyncOptions = opts ..copy.rsyncOptions = opts
..webdavServers = [dav()]; ..webdavServers = [dav()];
for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) { for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) {
final dir = local ? target : p.join(remote.path, 'l'); final dir = local ? target : p.join(remote.path, 'l');
writeScene(src, dir, srcFiles: _src, dstFiles: _dst); writeScene(src, dir, srcFiles: _src, dstFiles: _dst);
final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete); final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
await live.syncNow(pair); await live.syncNow(pair);
@ -180,7 +180,7 @@ void main() {
for (final a in asked) { for (final a in asked) {
if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a'); if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a');
} }
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
for (final d in treeDiff(w, treeOf(dir))) { for (final d in treeDiff(w, treeOf(dir))) {
why.add('$label: $d'); why.add('$label: $d');
} }

View File

@ -39,7 +39,7 @@ void main() {
c.settings c.settings
..webdavServers = [s] ..webdavServers = [s]
..explorer.layout = 'single' ..explorer.layout = 'single'
..rsyncPaths = [left, 'dav://nas/']; ..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]); DavRegistry.configure([s]);
}); });
tearDown(() async { tearDown(() async {
@ -68,7 +68,7 @@ void main() {
testWidgets('100 · 95: WebDAV ⇄ + --delete - 미리 보기대로 → (지움) 다음 ← (지우지 않음), 한쪽에만 있던 새 파일이 양쪽에서 사라지지 않는다', testWidgets('100 · 95: WebDAV ⇄ + --delete - 미리 보기대로 → (지움) 다음 ← (지우지 않음), 한쪽에만 있던 새 파일이 양쪽에서 사라지지 않는다',
(tester) async { (tester) async {
c.settings.rsyncOptions = '-avPog --delete'; c.settings.copy.rsyncOptions = '-avPog --delete';
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('좌 ⇄ 우'))); await act(tester, () => tester.tap(find.text('좌 ⇄ 우')));
await waitCompare(tester); await waitCompare(tester);
@ -92,7 +92,7 @@ void main() {
testWidgets('96: 원본 파일 지우기를 켜면 확인 창 · 미리 보기가 실제 실행할 (이동) 작업의 옵션으로 바뀐다', (tester) async { testWidgets('96: 원본 파일 지우기를 켜면 확인 창 · 미리 보기가 실제 실행할 (이동) 작업의 옵션으로 바뀐다', (tester) async {
// 기억해 둔 (복사) 작업은 --delete 없음, 새 작업의 기본 옵션은 --delete 있음 // 기억해 둔 (복사) 작업은 --delete 없음, 새 작업의 기본 옵션은 --delete 있음
c.settings.rsyncOptions = '-avPog --delete'; c.settings.copy.rsyncOptions = '-avPog --delete';
await open(tester); await open(tester);
await CopyCenter.of(c) await CopyCenter.of(c)
.remember([p.join(left, 'sub')], 'dav://nas/rsub', contents: true, method: 'rsync') .remember([p.join(left, 'sub')], 'dav://nas/rsub', contents: true, method: 'rsync')
@ -115,7 +115,7 @@ void main() {
}); });
testWidgets('97: [다시 비교] 하는 동안은 지우는 실행을 누를 수 없다', (tester) async { testWidgets('97: [다시 비교] 하는 동안은 지우는 실행을 누를 수 없다', (tester) async {
c.settings.rsyncOptions = '-avPog --del'; c.settings.copy.rsyncOptions = '-avPog --del';
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
await waitCompare(tester); await waitCompare(tester);
@ -150,13 +150,13 @@ void main() {
await settle(tester, () => false, rounds: 60); await settle(tester, () => false, rounds: 60);
expect(File(p.join(remote.path, 'rsub', 'a.txt')).existsSync(), isFalse); expect(File(p.join(remote.path, 'rsub', 'a.txt')).existsSync(), isFalse);
expect(File(p.join(left, 'sub', 'b.txt')).existsSync(), isFalse); expect(File(p.join(left, 'sub', 'b.txt')).existsSync(), isFalse);
expect(c.settings.copyTasks, isEmpty); expect(c.settings.copy.tasks, isEmpty);
await tester.pump(const Duration(seconds: 20)); await tester.pump(const Duration(seconds: 20));
}); });
} }
testWidgets('110: 좁은 화면 - 비교가 끝나도 [취소] · [실행] 자리가 그대로, 창을 여러 번 열고 닫아도 실행되지 않음', (tester) async { testWidgets('110: 좁은 화면 - 비교가 끝나도 [취소] · [실행] 자리가 그대로, 창을 여러 번 열고 닫아도 실행되지 않음', (tester) async {
c.settings.rsyncOptions = '-avPog --delete'; c.settings.copy.rsyncOptions = '-avPog --delete';
HttpOverrides.global = null; HttpOverrides.global = null;
tester.view.physicalSize = const Size(900, 1400); // 세로로 든 태블릿 tester.view.physicalSize = const Size(900, 1400); // 세로로 든 태블릿
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;

View File

@ -67,12 +67,12 @@ void main() {
test('124-1: 지우려는 서버를 쓰는 실시간 동기화 · 기억된 작업을 찾는다', () { test('124-1: 지우려는 서버를 쓰는 실시간 동기화 · 기억된 작업을 찾는다', () {
final s = AppSettings() final s = AppSettings()
..liveSyncPairs = [ ..copy.liveSyncPairs = [
LiveSyncPair(r'C:\영상', 'dav://nas/백업'), LiveSyncPair(r'C:\영상', 'dav://nas/백업'),
LiveSyncPair(r'C:\a', r'D:\b'), LiveSyncPair(r'C:\a', r'D:\b'),
LiveSyncPair('dav://other/x', r'C:\y'), LiveSyncPair('dav://other/x', r'C:\y'),
] ]
..copyTasks = [ ..copy.tasks = [
CopyTask(id: 't', sources: const ['dav://nas/사진'], dest: r'C:\사진', method: 'builtin', options: '', once: false), CopyTask(id: 't', sources: const ['dav://nas/사진'], dest: r'C:\사진', method: 'builtin', options: '', once: false),
]; ];
final users = davServerUsers(s, 'nas'); final users = davServerUsers(s, 'nas');

View File

@ -12,7 +12,7 @@ import 'package:jj_mkvmaker/ui/settings_page.dart';
void main() { void main() {
testWidgets('213: 기본값 ("-avPog") 을 지나며 쳐도 칸이 그대로 (포커스 · 입력 이어짐) · ↺ 는 칸 안의 글도 되돌린다', (tester) async { testWidgets('213: 기본값 ("-avPog") 을 지나며 쳐도 칸이 그대로 (포커스 · 입력 이어짐) · ↺ 는 칸 안의 글도 되돌린다', (tester) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.rsyncOptions = '-avPo'; c.settings.copy.rsyncOptions = '-avPo';
tester.view.physicalSize = const Size(1400, 1600); tester.view.physicalSize = const Size(1400, 1600);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -36,11 +36,11 @@ void main() {
expect(tester.state(find.byType(TextFormField).first), same(first), reason: '칸을 새로 만들지 않음 ($t)'); expect(tester.state(find.byType(TextFormField).first), same(first), reason: '칸을 새로 만들지 않음 ($t)');
expect(tester.testTextInput.hasAnyClients, isTrue, reason: '포커스 그대로 ($t)'); expect(tester.testTextInput.hasAnyClients, isTrue, reason: '포커스 그대로 ($t)');
} }
expect(c.settings.rsyncOptions, '-avPog -'); expect(c.settings.copy.rsyncOptions, '-avPog -');
// ↺: 칸 안의 글도 기본값으로 // ↺: 칸 안의 글도 기본값으로
await tester.tap(find.byTooltip('기본값으로 ($defaultRsyncOptions)').first); await tester.tap(find.byTooltip('기본값으로 ($defaultRsyncOptions)').first);
await tester.pump(); await tester.pump();
expect(c.settings.rsyncOptions, defaultRsyncOptions); expect(c.settings.copy.rsyncOptions, defaultRsyncOptions);
expect(find.text(defaultRsyncOptions), findsWidgets); expect(find.text(defaultRsyncOptions), findsWidgets);
}); });

View File

@ -25,7 +25,7 @@ void main() {
}); });
test('WebDAV 가 끼면 탐색기 복사 확인 창에 robocopy 가 아니라 실제 방법 (현재 방식)', () { test('WebDAV 가 끼면 탐색기 복사 확인 창에 robocopy 가 아니라 실제 방법 (현재 방식)', () {
c.settings.copyMethodFolder = 'robocopy'; c.settings.copy.methodFolder = 'robocopy';
final t = CopyCenter.of(c).fresh(['dav://nas/a'], r'C:\dst'); final t = CopyCenter.of(c).fresh(['dav://nas/a'], r'C:\dst');
expect(CopyMethod.of(t.method), CopyMethod.builtin); expect(CopyMethod.of(t.method), CopyMethod.builtin);
// Rsync 화면 (방법을 정해서) 은 그대로 // Rsync 화면 (방법을 정해서) 은 그대로

View File

@ -39,7 +39,7 @@ void main() {
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(p.join(tmp.path, 'gone'), p.join(tmp.path, 'dst')); final pair = LiveSyncPair(p.join(tmp.path, 'gone'), p.join(tmp.path, 'dst'));
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final shell = _Shell(); final shell = _Shell();
var opened = 0; var opened = 0;

View File

@ -40,7 +40,7 @@ void main() {
test('68: 원본을 읽을 수 없으면 문제로 기억 (짧은 원인 · 원문 예외 없음) · "맞출 것 없음" 이 아님 · 작업 알림 맨 앞', () async { test('68: 원본을 읽을 수 없으면 문제로 기억 (짧은 원인 · 원문 예외 없음) · "맞출 것 없음" 이 아님 · 작업 알림 맨 앞', () async {
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
await live.refreshPending(pair); await live.refreshPending(pair);
@ -64,12 +64,12 @@ void main() {
test('258: 멈춘 쌍을 지우면 "동기화 멈춤" 도 바로 사라진다 (앱을 다시 켜지 않아도)', () async { test('258: 멈춘 쌍을 지우면 "동기화 멈춤" 도 바로 사라진다 (앱을 다시 켜지 않아도)', () async {
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c)..start(); final live = LiveSync(c)..start();
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
await live.syncNow(pair); await live.syncNow(pair);
expect(live.problems[k], isNotNull); expect(live.problems[k], isNotNull);
await c.updateSettings((s) => s.liveSyncPairs = []); await c.updateSettings((s) => s.copy.liveSyncPairs = []);
expect(live.problems, isEmpty); expect(live.problems, isEmpty);
live.dispose(); live.dispose();
}); });
@ -77,7 +77,7 @@ void main() {
test('70: 원본이 정말 비었으면 멈추고 지울 목록 → [그래도 맞추기] 로 그 한 번만 지운다', () async { test('70: 원본이 정말 비었으면 멈추고 지울 목록 → [그래도 맞추기] 로 그 한 번만 지운다', () async {
Directory(src).createSync(); Directory(src).createSync();
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
await live.syncNow(pair); await live.syncNow(pair);
@ -104,7 +104,7 @@ void main() {
final live = LiveSync(c); final live = LiveSync(c);
// 안쪽 → 바깥, 지우기 없음: 맞춘다 (다른 파일은 그대로) // 안쪽 → 바깥, 지우기 없음: 맞춘다 (다른 파일은 그대로)
final ok = LiveSyncPair(inner, outer); final ok = LiveSyncPair(inner, outer);
c.settings.liveSyncPairs = [ok]; c.settings.copy.liveSyncPairs = [ok];
await live.syncNow(ok); await live.syncNow(ok);
expect(File(p.join(outer, 'new.txt')).existsSync(), isTrue); expect(File(p.join(outer, 'new.txt')).existsSync(), isTrue);
expect(File(p.join(outer, 'other.txt')).existsSync(), isTrue); expect(File(p.join(outer, 'other.txt')).existsSync(), isTrue);
@ -123,7 +123,7 @@ void main() {
(tester) async { (tester) async {
Directory(src).createSync(); Directory(src).createSync();
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
LiveSync.instance = live; LiveSync.instance = live;
addTearDown(() { addTearDown(() {
@ -155,7 +155,7 @@ void main() {
Directory(src).createSync(); Directory(src).createSync();
File(p.join(src, 'x.txt')).writeAsStringSync('x'); File(p.join(src, 'x.txt')).writeAsStringSync('x');
final pair = LiveSyncPair(src, dst); final pair = LiveSyncPair(src, dst);
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
LiveSync.instance = live; LiveSync.instance = live;
addTearDown(() { addTearDown(() {
@ -188,8 +188,8 @@ void main() {
..writeAsStringSync('r'); ..writeAsStringSync('r');
c.settings c.settings
..explorer.layout = 'single' ..explorer.layout = 'single'
..rsyncOptions = '-avPog --delete' ..copy.rsyncOptions = '-avPog --delete'
..rsyncPaths = [l, r]; ..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);

View File

@ -37,11 +37,11 @@ void main() {
}); });
test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () { test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () {
expect([AppSettings().copyMethodFile, AppSettings().copyMethodFolder], ['builtin', 'builtin']); expect([AppSettings().copy.methodFile, AppSettings().copy.methodFolder], ['builtin', 'builtin']);
expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copyMethodFolder, 'builtin'); expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copy.methodFolder, 'builtin');
expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copyMethodFolder, 'robocopy'); expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copy.methodFolder, 'robocopy');
final s = AppSettings()..rsyncPaths = ['/a', '/b']; final s = AppSettings()..copy.rsyncPaths = ['/a', '/b'];
expect(AppSettings.fromJson(s.toJson()).rsyncPaths, ['/a', '/b']); expect(AppSettings.fromJson(s.toJson()).copy.rsyncPaths, ['/a', '/b']);
}); });
test('양쪽 (⇄) rsync 는 -u 를 더한다 (이미 있으면 그대로)', () { test('양쪽 (⇄) rsync 는 -u 를 더한다 (이미 있으면 그대로)', () {
@ -117,7 +117,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()..robocopyOptions = '/E'); final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..copy.robocopyOptions = '/E');
expect(live, contains('/XJ')); expect(live, contains('/XJ'));
}); });
@ -277,20 +277,20 @@ void main() {
test('실시간 동기화: 지금 맞추기 (현재 방식) · 상태', () async { test('실시간 동기화: 지금 맞추기 (현재 방식) · 상태', () async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(src, p.join(tmp.path, 'mirror')); final pair = LiveSyncPair(src, p.join(tmp.path, 'mirror'));
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
await live.syncNow(pair); await live.syncNow(pair);
expect(File(p.join(tmp.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bbb'); expect(File(p.join(tmp.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bbb');
expect(live.status[LiveSync.keyOf(pair)]!.$2, contains('3')); expect(live.status[LiveSync.keyOf(pair)]!.$2, contains('3'));
// 대상이 원본 안이면 거부 // 대상이 원본 안이면 거부
c.settings.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))]; c.settings.copy.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))];
live.start(); live.start();
expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.first)]!.$2, contains('안에')); expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.first)]!.$2, contains('안에'));
live.dispose(); live.dispose();
// 설정 저장 · 읽기 // 설정 저장 · 읽기
final back = AppSettings.fromJson(c.settings.toJson()); final back = AppSettings.fromJson(c.settings.toJson());
expect(back.liveSyncPairs.single.target, p.join(src, 'in')); expect(back.copy.liveSyncPairs.single.target, p.join(src, 'in'));
expect([back.copyMethodFolder, back.rsyncOptions, back.copyRunMode, back.rsyncSource], expect([back.copy.methodFolder, back.copy.rsyncOptions, back.copy.runMode, back.copy.rsyncSource],
['builtin', '-avPog', 'each', 'download']); ['builtin', '-avPog', 'each', 'download']);
}); });
}); });

View File

@ -155,7 +155,7 @@ void main() {
// 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도 // 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
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.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 LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
await live.syncNow(pair); await live.syncNow(pair);
@ -336,10 +336,10 @@ void main() {
final before = treeOf(got); final before = treeOf(got);
// rsync 가 지운 것 (받는 쪽에 있다가 없어진 것) // rsync 가 지운 것 (받는 쪽에 있다가 없어진 것)
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
c.settings.rsyncOptions = opts; c.settings.copy.rsyncOptions = opts;
// 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다 // 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다
final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts)); final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts));
c.settings.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);
final k = LiveSync.keyOf(pair); final k = LiveSync.keyOf(pair);
final why = <String>[]; final why = <String>[];
@ -353,7 +353,7 @@ void main() {
for (final a in asked) { for (final a in asked) {
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a'); if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
} }
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
why.addAll(treeDiff(w, treeOf(got))); why.addAll(treeDiff(w, treeOf(got)));
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
live.dispose(); live.dispose();
@ -368,18 +368,18 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')] ..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]
..rsyncOptions = '-av --exclude=*.tmp' ..copy.rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [ ..copy.liveSyncPairs = [
LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd', LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',
method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
]; ];
final live = LiveSync(c); final live = LiveSync(c);
addTearDown(live.dispose); addTearDown(live.dispose);
LiveSyncPair pair() => c.settings.liveSyncPairs.single; LiveSyncPair pair() => c.settings.copy.liveSyncPairs.single;
final k = LiveSync.keyOf(pair()); final k = LiveSync.keyOf(pair());
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], isNull); expect(live.toDelete[k], isNull);
c.settings.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; c.settings.copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
await live.syncNow(pair()); await live.syncNow(pair());
expect(live.toDelete[k], ['only.tmp']); expect(live.toDelete[k], ['only.tmp']);
expect(live.reasked, contains(k)); expect(live.reasked, contains(k));