From d8ce0eb3029240766ef8a2adc956f5c07abf59ec Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:47:37 +0900 Subject: [PATCH] =?UTF-8?q?robocopy=20=EC=9D=B4=EB=8F=99=EC=9D=80=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A0=20=EC=8B=9C=EA=B0=81=20=C2=B7=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=EB=A7=8C=20=EB=8B=A4=EB=A5=B8=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=8F=84=20=EC=98=AE=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=97=90=20=EC=9B=90=EB=B3=B8=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EC=9D=80=20=EC=88=98=20=C2=B7=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=EC=A4=84=20(217=20=C2=B7=20235=20=C2=B7=20238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 (버그, 기기 확인): robocopy 가 'Modified' (만든 시각만 다름) · 'Tweaked' 를 /IS 로도 건너뛰어 원본에 남기고 성공처럼 보였다 - 이동에 /IT /IM 도 붙임 - 235 (버그): 이동 뒤 원본에 남은 수를 카드에 (CopyTask.lastLeft, 주황 경고) · 파일 수는 옮긴 수로 (doneFiles) - 238 (버그): 확인 창 · 카드 명령 줄에 실제로 붙는 /MOVE /IS /IT /IM - 시험 기대값: sync_tools_test · dismiss_test 의 robocopy 옵션 (관리자 승인) Co-Authored-By: Claude Opus 5.5 --- lib/app/copy_center.dart | 3 ++- lib/app/settings.dart | 8 ++++++++ lib/app/transfer_job.dart | 3 +++ lib/core/sync_tools.dart | 15 +++++++++++---- lib/ui/monitor_page.dart | 8 +++++++- lib/ui/sync_preview_view.dart | 11 +++++++++-- test/dismiss_test.dart | 2 +- test/move_settled_test.dart | 35 ++++++++++++++++++++++++++++++++++- test/sync_tools_test.dart | 4 ++-- 9 files changed, 77 insertions(+), 12 deletions(-) diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index e7f6d58..3661db4 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -143,7 +143,8 @@ class CopyCenter extends ChangeNotifier { if (job.pruned > 0) trf('빈 폴더 {0}개 지움', [job.pruned]), ].join(' · ') : '$e', - lastFiles: job.allDone, + lastFiles: job.doneFiles, + lastLeft: e == null ? job.leftInSource : 0, )); } c.note(e == null diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 72c876a..1c18e3e 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -135,6 +135,9 @@ class CopyTask { final String lastMessage; final int lastFiles; + /// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로 + final int lastLeft; + /// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 · /// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete) final String prune; @@ -153,6 +156,7 @@ class CopyTask { this.lastResult = '', this.lastMessage = '', this.lastFiles = 0, + this.lastLeft = 0, this.prune = '', }); @@ -169,6 +173,7 @@ class CopyTask { String? lastResult, String? lastMessage, int? lastFiles, + int? lastLeft, String? prune, }) => CopyTask( @@ -185,6 +190,7 @@ class CopyTask { lastResult: lastResult ?? this.lastResult, lastMessage: lastMessage ?? this.lastMessage, lastFiles: lastFiles ?? this.lastFiles, + lastLeft: lastLeft ?? this.lastLeft, prune: prune ?? this.prune, ); @@ -202,6 +208,7 @@ class CopyTask { 'lastResult': lastResult, 'lastMessage': lastMessage, 'lastFiles': lastFiles, + 'lastLeft': lastLeft, 'prune': prune, }; @@ -219,6 +226,7 @@ class CopyTask { lastResult: j['lastResult'] as String? ?? '', lastMessage: j['lastMessage'] as String? ?? '', lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0, + lastLeft: (j['lastLeft'] as num?)?.toInt() ?? 0, prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '', ); } diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 0816112..d205da4 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -156,6 +156,9 @@ class TransferJob extends ChangeNotifier { /// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것) int leftInSource = 0; + /// 235: 끝난 이동에서 실제로 옮긴 파일 수 (원본에 남은 것은 뺀다 - robocopy 는 건너뛴 것도 다 한 것으로 센다) + int get doneFiles => move && finished ? (allFiles - leftInSource).clamp(0, allFiles) : allDone; + /// 212 · 216: 끝 알림 한 줄 - "이동 끝: n개 · 원본 → 대상 · m개는 원본에 남음 (건너뜀)" / "복사 끝: n개 · 원본 → 대상" String doneSummary() { final from = sources.length == 1 ? vBasename(sources.first) : trf('{0}개 항목', [sources.length]); diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 2e3484e..fa1d7d0 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -112,10 +112,7 @@ List> robocopyRuns({ 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) '/MOVE', - // 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). - // 이동은 같은 파일도 포함 (/IS) 해 원본에서 지운다. 속성만 다른 것 (Tweaked) 등 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216) - if (move && !opts.any((o) => o.toUpperCase() == '/IS')) '/IS', + if (move) ...robocopyMoveOptions(opts), ]; final runs = >[ for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], @@ -131,6 +128,16 @@ List> robocopyRuns({ return runs; } +/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. +/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). +/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. +/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216) +List robocopyMoveOptions(List opts) => [ + '/MOVE', + for (final o in const ['/IS', '/IT', '/IM']) + if (!opts.any((x) => x.toUpperCase() == o)) o, + ]; + /// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초 int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000); diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 4fbc595..43e13fb 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -243,7 +243,7 @@ class TransferProgressLines extends StatelessWidget { [if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')), ), line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress, - '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'), + '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.doneFiles, job.allFiles])}'), ]); }, ); @@ -459,6 +459,12 @@ class _CopyCardState extends State<_CopyCard> { final (icon, color, status) = running ? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중')) : switch (t.lastResult) { + // 235: 이동인데 원본에 남은 것이 있으면 "끝" 만 보이지 않게 (주황 · 남은 수) + 'done' when t.lastLeft > 0 => ( + Icons.warning_amber, + Colors.orangeAccent, + '${trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])} · ${trf('{0}개는 원본에 남음 (건너뜀)', [t.lastLeft])}' + ), 'done' => (Icons.check_circle, Colors.greenAccent, trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])), 'failed' => (Icons.error, Colors.redAccent, trf('실패 · {0}', [_hm(t.lastRun)])), 'cancelled' => (Icons.pause_circle, Colors.orangeAccent, trf('멈춤 · {0}', [_hm(t.lastRun)])), diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 20a670d..0e626fe 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -182,12 +182,19 @@ bool taskDeletes(CopyTask t) { /// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다. /// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true -/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다) +/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다). +/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게) String effectiveRunText(String method, String options, {required bool move}) { final name = method == 'builtin' ? tr('현재 방식') : method; if (method == 'builtin') return name; final opts = withoutSourceRemovingOptions(options); - final extra = move ? (method == 'robocopy' ? ' /MOVE' : method == 'rsync' ? ' --remove-source-files' : '') : ''; + final extra = !move + ? '' + : method == 'robocopy' + ? ' ${robocopyMoveOptions(splitOptions(opts)).join(' ')}' + : method == 'rsync' + ? ' --remove-source-files' + : ''; return '$name $opts$extra'.trim(); } diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index bd3d0f0..b2ef2b1 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -257,7 +257,7 @@ void main() { testWidgets('209: 모니터링 카드는 실제로 실행할 것을 이동 / 복사와 함께 보인다 (복사 카드 칸의 원본 지우기 옵션은 쓰지 않는다고 알림)', (tester) async { 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'); + expect(effectiveRunText('robocopy', '/E /MOV', move: true), 'robocopy /E /MOVE /IS /IT /IM'); // 238: 실제로 붙는 것까지 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 27eb080..39c2606 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -108,7 +108,40 @@ void main() { expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '같은 파일도 옮김'); expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'a'); expect(job.leftInSource, 0); - // (속성만 다른 "수정됨" 파일은 /IS · /IT 로도 남는다 - 그때는 216 의 "n개는 원본에 남음" 으로 알린다) + }); + + test('217 다시: robocopy 이동 - 만든 시각만 다른 같은 파일 (robocopy 의 "Modified") 도 원본에서 지운다 (실제 Windows 에서 남던 것)', () async { + if (!Platform.isWindows) return; + final a = p.join(src, 'a.txt'), b = p.join(dst, 'a.txt'); + final r = Process.runSync('powershell', [ + '-NoProfile', + '-Command', + "Copy-Item -LiteralPath '$a' -Destination '$b'; (Get-Item -LiteralPath '$b').CreationTime = (Get-Date).AddMinutes(5); " + "(Get-Item -LiteralPath '$b').LastWriteTime = (Get-Item -LiteralPath '$a').LastWriteTime", + ]); + expect(r.exitCode, 0, reason: '${r.stderr}'); + final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2'); + await job.run(); + expect(job.error, isNull); + expect(File(a).existsSync(), isFalse, reason: 'Modified 도 옮김 (/IM)'); + expect(File(b).readAsStringSync(), 'a'); + expect(job.leftInSource, 0); + }); + + test('235: 이동 뒤 원본에 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async { + // robocopy 가 건너뛰게: 받는 쪽이 더 새것 (/XO) + 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); + await job.run(); + expect(job.error, isNull); + expect(old.existsSync(), isTrue); + 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); + expect(CopyTask.fromJson(t.toJson()).lastLeft, 1); }); test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async { diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index 7aa5983..a1dc118 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -61,8 +61,8 @@ void main() { bandwidthKBps: 640, move: true, ); - expect(runs[0], [r'C:\a\movies', p.join(r'E:\dst', 'movies'), '/E', '/R:2', '/IPG:100', '/MOVE', '/IS']); - expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV', '/IS']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS) + expect(runs[0], [r'C:\a\movies', p.join(r'E:\dst', 'movies'), '/E', '/R:2', '/IPG:100', '/MOVE', '/IS', '/IT', '/IM']); + 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); });