diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 47060dc..5a216f1 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2034,7 +2034,7 @@ "허용했으면 다시 확인": "Check again after allowing", "헝가리어": "Hungarian", "현재 방식": "Built-in", - "현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "When syncing folder contents with the built-in method or WebDAV (same as rsync -u). Skipped files stay in the source even when moving", + "현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "When copying or moving with the built-in method, robocopy or WebDAV (same as rsync -u / robocopy /XO). Skipped files stay in the source even when moving", "현재 유지": "Keep as is", "현재 페이지": "Current page", "현재 페이지 추가": "Add current page", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index fb1c6f4..b6f261f 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2034,7 +2034,7 @@ "허용했으면 다시 확인": "許可したらもう一度確認", "헝가리어": "ハンガリー語", "현재 방식": "現在の方式", - "현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · WebDAV でフォルダーの中身を合わせるとき (rsync の -u と同じ)。スキップしたファイルは移動でも元の場所に残します", + "현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · robocopy · WebDAV でコピー · 移動するとき (rsync の -u、robocopy の /XO と同じ)。スキップしたファイルは移動でも元の場所に残します", "현재 유지": "現状維持", "현재 페이지": "現在のページ", "현재 페이지 추가": "現在のページを追加", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 2b28eba..ecd8735 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2034,7 +2034,7 @@ "허용했으면 다시 확인": "允许后重新检查", "헝가리어": "匈牙利语", "현재 방식": "当前方式", - "현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · WebDAV 同步文件夹内容时(与 rsync 的 -u 相同)。跳过的文件即使是移动也会保留在源中", + "현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · robocopy · WebDAV 复制 · 移动时(与 rsync 的 -u、robocopy 的 /XO 相同)。跳过的文件即使是移动也会保留在源中", "현재 유지": "保持现状", "현재 페이지": "当前页面", "현재 페이지 추가": "添加当前页面", diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index d205da4..7739255 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -62,7 +62,8 @@ class TransferJob extends ChangeNotifier { /// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙) final NameConflict conflict; - /// 229: 앱이 직접 맞출 때 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도). 건너뛴 원본은 이동이라도 남긴다 + /// 229: 앱이 직접 맞출 때 · robocopy (/XO) 에서 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도). + /// 건너뛴 원본은 이동이라도 남긴다. rsync 는 옵션 칸의 -u 를 따른다 final bool skipNewer; /// 같은 이름이라 건너뛴 항목 (원본 경로) @@ -440,6 +441,7 @@ class TransferJob extends ChangeNotifier { bandwidthKBps: bandwidthKBps, move: move, contents: contents, + skipNewer: skipNewer, ); _tick(); for (final args in runs) { diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index fa1d7d0..a01966d 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -107,12 +107,15 @@ List> robocopyRuns({ int bandwidthKBps = 0, bool move = false, bool contents = false, + bool skipNewer = false, }) { // 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만 final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; final extra = [ if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', if (move) ...robocopyMoveOptions(opts), + // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다 + if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO', ]; final runs = >[ for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 50fb61a..675028f 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -156,7 +156,7 @@ class _CopySyncSettingsState extends State { value: s.copySkipNewer, onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v), title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')), - subtitle: Text(tr('현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')), + subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')), ), SettingTile( title: Text(tr('속도 제한 (KB/s)')), diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 43e13fb..1a95528 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -377,7 +377,7 @@ class _CopyCardState extends State<_CopyCard> { content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [ '${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}', // 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만) - effectiveRunText(t.method, t.options, move: t.move), + effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer), ])), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), @@ -552,7 +552,7 @@ class _CopyCardState extends State<_CopyCard> { width: 460, child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move)])}', + '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}', style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim), ), if (!t.move && optionsRemoveSource(_options.text)) diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 0e626fe..de5b11e 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -183,8 +183,8 @@ bool taskDeletes(CopyTask t) { /// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다. /// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true /// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다). -/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게) -String effectiveRunText(String method, String options, {required bool move}) { +/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게). [skipNewer] (229): robocopy 의 /XO +String effectiveRunText(String method, String options, {required bool move, bool skipNewer = false}) { final name = method == 'builtin' ? tr('현재 방식') : method; if (method == 'builtin') return name; final opts = withoutSourceRemovingOptions(options); @@ -195,7 +195,8 @@ String effectiveRunText(String method, String options, {required bool move}) { : method == 'rsync' ? ' --remove-source-files' : ''; - return '$name $opts$extra'.trim(); + final xo = method == 'robocopy' && skipNewer && !splitOptions(opts).any((o) => o.toUpperCase() == '/XO') ? ' /XO' : ''; + return '$name $opts$extra$xo'.trim(); } /// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때. @@ -207,7 +208,11 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM List? preview; Object? error; final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav); - final update = optionsUpdate(t.options) || (direct && skipNewer); + // robocopy 는 /XO (옵션 칸 또는 환경 설정) 가 -u 와 같다 + final robo = t.method == 'robocopy' && !direct; + final update = robo + ? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO') + : optionsUpdate(t.options) || (direct && skipNewer); String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src)); final ok = await showDialog( context: context, @@ -227,7 +232,7 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM Text('${vDisplay(s)} → ${vDisplay(dstOf(s))}', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), ], // 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다) - Text(effectiveRunText(t.method, t.options, move: t.move), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), + Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), // 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히 if (t.move) Padding( diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index b2ef2b1..2c9762b 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -258,6 +258,7 @@ void main() { expect(effectiveRunText('rsync', '-avPog', move: true), 'rsync -avPog --remove-source-files'); expect(effectiveRunText('rsync', '-avPog --remove-source-files', move: false), 'rsync -avPog'); expect(effectiveRunText('robocopy', '/E /MOV', move: true), 'robocopy /E /MOVE /IS /IT /IM'); // 238: 실제로 붙는 것까지 + expect(effectiveRunText('robocopy', '/E', move: true, skipNewer: true), 'robocopy /E /MOVE /IS /IT /IM /XO'); // 229 final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb'); Directory(a).createSync(recursive: true); Directory(b).createSync(recursive: true); diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart index 39c2606..712aa69 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -128,16 +128,17 @@ void main() { expect(job.leftInSource, 0); }); - test('235: 이동 뒤 원본에 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async { - // robocopy 가 건너뛰게: 받는 쪽이 더 새것 (/XO) + test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async { + // 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다 final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(dst, 'old.txt')).writeAsStringSync('new'); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: Platform.isWindows ? CopyMethod.robocopy : CopyMethod.builtin, - options: Platform.isWindows ? '/E /XO' : '', skipNewer: true); + options: Platform.isWindows ? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' : '', skipNewer: true); await job.run(); expect(job.error, isNull); - expect(old.existsSync(), isTrue); + expect(File(p.join(dst, 'old.txt')).readAsStringSync(), 'new', reason: '대상의 더 새 파일 그대로'); + expect(old.existsSync(), isTrue, reason: '건너뛴 원본은 남김'); expect(job.leftInSource, 1); expect([job.doneFiles, job.allFiles], [job.allFiles - 1, job.allFiles], reason: '옮긴 수는 남은 것을 뺀다 (robocopy 는 다 했다고 셈)'); final t = CopyTask(id: '1', sources: [src], dest: dst, move: true).copyWith(lastResult: 'done', lastFiles: job.doneFiles, lastLeft: job.leftInSource); diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index a1dc118..e02bd31 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -65,6 +65,9 @@ void main() { expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV', '/IS', '/IT', '/IM']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS · /IT · /IM) expect(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']); expect(robocopyIpg(0), 0); + // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" → /XO (옵션 칸에 있으면 두 번 넣지 않음) + expect(robocopyRuns(options: '/E', folders: [r'C:\a'], files: const [], dest: r'E:\d', skipNewer: true).single.last, '/XO'); + expect(robocopyRuns(options: '/E /xo', folders: [r'C:\a'], files: const [], dest: r'E:\d', skipNewer: true).single.where((o) => o.toUpperCase() == '/XO').length, 1); }); test('rsync 출력 읽기: 파일 줄만 (폴더 · 진행 · 요약 줄 뺌)', () {