diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 778f158..4847e62 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -455,6 +455,8 @@ class LiveSync extends ChangeNotifier { x.target, ...opts, if (!opts.any((o) => o.toUpperCase() == '/E')) '/E', + // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions) + if (!robocopyLinkOption(opts)) '/XJ', if (x.delete) '/PURGE', if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}', '/BYTES', '/NJH', '/NJS', '/NDL', '/NP', diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index f05c14a..329d7fb 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -111,12 +111,7 @@ List> robocopyRuns({ }) { // 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 extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps); final runs = >[ for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], ]; @@ -131,15 +126,32 @@ List> robocopyRuns({ return runs; } +/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게 +List robocopyExtraOptions(List opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [ + if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', + if (move) ...robocopyMoveOptions(opts), + // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼). + // 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다. + // 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로 + if (!robocopyLinkOption(opts)) '/XJ', + // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다 + if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO', + ]; + +/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL) +bool robocopyLinkOption(List opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase())); + +/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면 +bool robocopySkipsLinks(List opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase())); + /// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. /// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). /// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. -/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). -/// 데이터: 원본 안의 링크 · junction 은 빼고 (/XJ) - robocopy 는 기본으로 junction 을 따라가 /MOVE 가 -/// 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 (Windows 에서 확인). 앱이 직접 할 때 (214) 와 같게 링크는 남긴다 +/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다 +/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인) List robocopyMoveOptions(List opts) => [ '/MOVE', - for (final o in const ['/IS', '/IT', '/IM', '/XJ']) + for (final o in const ['/IS', '/IT', '/IM']) if (!opts.any((x) => x.toUpperCase() == o)) o, ]; diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 53ff0ee..f31e1cc 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -183,25 +183,20 @@ bool taskDeletes(CopyTask t) { }; } -/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다. -/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true /// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다). -/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM /XJ 까지 (robocopyRuns 와 같게). [skipNewer] (229): robocopy 의 /XO +/// 238: robocopy 는 실제로 붙는 것까지 (이동의 /MOVE /IS /IT /IM, 링크 /XJ, [skipNewer] (229) 의 /XO - robocopyRuns 와 같게) 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); - final extra = !move - ? '' - : method == 'robocopy' - ? ' ${robocopyMoveOptions(splitOptions(opts)).join(' ')}' - : method == 'rsync' - ? ' --remove-source-files' - : ''; - final xo = method == 'robocopy' && skipNewer && !splitOptions(opts).any((o) => o.toUpperCase() == '/XO') ? ' /XO' : ''; - return '$name $opts$extra$xo'.trim(); + final extra = method == 'robocopy' + ? robocopyExtraOptions(splitOptions(opts), move: move, skipNewer: skipNewer) + : [if (move && method == 'rsync') '--remove-source-files']; + return [name, opts, ...extra].where((x) => x.isNotEmpty).join(' '); } +/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다. +/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true /// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때. /// [skipNewer] (229): 앱이 직접 할 때 (현재 방식 · WebDAV) 받는 쪽이 더 새것이면 건너뛰는지 (환경 설정) Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeMove = false, bool skipNewer = false}) async { @@ -247,8 +242,8 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM SyncPreviewView( compute: () async => [ for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, - // robocopy 이동도 링크를 빼고 (/XJ) 남긴다 - skipLinks: direct || (robo && t.move)), + // robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면) + skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options)))), ], onStart: () => set(() { preview = null; diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index 2f1a0c9..dc21781 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -317,6 +317,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 /R:2', move: false), 'robocopy /E /R:2 /XJ'); // 링크 · junction 은 복사도 expect(effectiveRunText('robocopy', '/E /MOV', move: true), 'robocopy /E /MOVE /IS /IT /IM /XJ'); // 238: 실제로 붙는 것까지 expect(effectiveRunText('robocopy', '/E', move: true, skipNewer: true), 'robocopy /E /MOVE /IS /IT /IM /XJ /XO'); // 229 final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb'); diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart index 63efc97..fdf3d78 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -143,6 +143,19 @@ void main() { expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '나머지는 옮김'); }); + test('robocopy 복사 (데이터): 원본 안의 junction 을 따라가 원본 바깥 파일을 대상에 복사하지 않는다 (/XJ - rsync -a 처럼)', () async { + if (!Platform.isWindows) return; + final outside = Directory(p.join(p.dirname(src), 'outside_c_${p.basename(src)}'))..createSync(); + addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null); + File(p.join(outside.path, 'keep.txt')).writeAsStringSync('k'); + Link(p.join(src, 'linked')).createSync(outside.path); // junction + final job = TransferJob(sources: [src], dest: dst, move: false, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2'); + await job.run(); + expect(job.error, isNull); + expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue); + expect(File(p.join(dst, 'linked', 'keep.txt')).existsSync(), isFalse, reason: '링크 안의 바깥 파일은 복사하지 않음'); + }); + test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async { // 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다 final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o'); diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index 52f0b2e..e90bcdc 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -110,6 +110,17 @@ void main() { expect(rc(true).where(isSourceRemovingOption), ['/MOVE']); }); + test('robocopy 링크 · junction (데이터): 복사 · 이동 모두 처음 값 /XJ, 옵션 칸에 링크 옵션이 있으면 그대로', () { + List rc(String o, {bool move = false}) => robocopyRuns(options: o, folders: [r'C:\a'], files: const [], dest: r'D:\b', move: move).single; + expect(rc('/E'), contains('/XJ')); + expect(rc('/E', move: true), contains('/XJ')); + expect(rc('/E /SJ'), isNot(contains('/XJ'))); + expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']); + expect([robocopySkipsLinks(['/E']), robocopySkipsLinks(['/E', '/SJ'])], [true, false]); + final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..robocopyOptions = '/E'); + expect(live, contains('/XJ')); + }); + test('61: robocopy 파일 줄은 Windows 언어와 상관없이 (일본어 · 중국어 · 한국어 종류 글)', () { final o = RobocopyOutput(); expect(o.feed('\t 新しいファイル \t\t 3000000\tC:\\t\\a b.txt\r\n 34% \r\n'), [r'C:\t\a b.txt']);