robocopy 도 '받는 쪽이 더 새것이면 건너뛰기' 를 따름 (/XO) (229)

- 데이터 (버그, 관리자 지적): robocopy 는 기본으로 예전 원본 (Older) 으로 대상의 새 파일을 덮어썼다 - 설정이 켜져 있으면 /XO
- 건너뛴 원본은 남기고 알림, 미리 보기 · 명령 줄도 같게. 설정 설명 글에 robocopy 를 넣음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-11 23:53:24 +09:00
parent d8ce0eb302
commit 90246717ec
11 changed files with 31 additions and 16 deletions

View File

@ -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",

View File

@ -2034,7 +2034,7 @@
"허용했으면 다시 확인": "許可したらもう一度確認",
"헝가리어": "ハンガリー語",
"현재 방식": "現在の方式",
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · WebDAV でフォルダーの中身を合わせるとき (rsync の -u と同じ)。スキップしたファイルは移動でも元の場所に残します",
"현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · robocopy · WebDAV でコピー · 移動するとき (rsync の -u、robocopy の /XO と同じ)。スキップしたファイルは移動でも元の場所に残します",
"현재 유지": "現状維持",
"현재 페이지": "現在のページ",
"현재 페이지 추가": "現在のページを追加",

View File

@ -2034,7 +2034,7 @@
"허용했으면 다시 확인": "允许后重新检查",
"헝가리어": "匈牙利语",
"현재 방식": "当前方式",
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · WebDAV 同步文件夹内容时(与 rsync 的 -u 相同)。跳过的文件即使是移动也会保留在源中",
"현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · robocopy · WebDAV 复制 · 移动时(与 rsync 的 -u、robocopy 的 /XO 相同)。跳过的文件即使是移动也会保留在源中",
"현재 유지": "保持现状",
"현재 페이지": "当前页面",
"현재 페이지 추가": "添加当前页面",

View File

@ -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) {

View File

@ -107,12 +107,15 @@ List<List<String>> 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 = <List<String>>[
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],

View File

@ -156,7 +156,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
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)')),

View File

@ -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))

View File

@ -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<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
List<PreviewItem>? 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<bool>(
context: context,
@ -227,7 +232,7 @@ Future<bool> 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(

View File

@ -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);

View File

@ -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);

View File

@ -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 출력 읽기: 파일 줄만 (폴더 · 진행 · 요약 줄 뺌)', () {