robocopy 이동은 만든 시각 · 속성만 다른 같은 파일도 옮김 · 카드에 원본에 남은 수 · 명령 줄 (217 · 235 · 238)
- 데이터 (버그, 기기 확인): 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 <noreply@anthropic.com>fix_mkv
parent
d991ef91c1
commit
d8ce0eb302
|
|
@ -143,7 +143,8 @@ class CopyCenter extends ChangeNotifier {
|
||||||
if (job.pruned > 0) trf('빈 폴더 {0}개 지움', [job.pruned]),
|
if (job.pruned > 0) trf('빈 폴더 {0}개 지움', [job.pruned]),
|
||||||
].join(' · ')
|
].join(' · ')
|
||||||
: '$e',
|
: '$e',
|
||||||
lastFiles: job.allDone,
|
lastFiles: job.doneFiles,
|
||||||
|
lastLeft: e == null ? job.leftInSource : 0,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
c.note(e == null
|
c.note(e == null
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,9 @@ class CopyTask {
|
||||||
final String lastMessage;
|
final String lastMessage;
|
||||||
final int lastFiles;
|
final int lastFiles;
|
||||||
|
|
||||||
|
/// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로
|
||||||
|
final int lastLeft;
|
||||||
|
|
||||||
/// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 ·
|
/// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 ·
|
||||||
/// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete)
|
/// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete)
|
||||||
final String prune;
|
final String prune;
|
||||||
|
|
@ -153,6 +156,7 @@ class CopyTask {
|
||||||
this.lastResult = '',
|
this.lastResult = '',
|
||||||
this.lastMessage = '',
|
this.lastMessage = '',
|
||||||
this.lastFiles = 0,
|
this.lastFiles = 0,
|
||||||
|
this.lastLeft = 0,
|
||||||
this.prune = '',
|
this.prune = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,6 +173,7 @@ class CopyTask {
|
||||||
String? lastResult,
|
String? lastResult,
|
||||||
String? lastMessage,
|
String? lastMessage,
|
||||||
int? lastFiles,
|
int? lastFiles,
|
||||||
|
int? lastLeft,
|
||||||
String? prune,
|
String? prune,
|
||||||
}) =>
|
}) =>
|
||||||
CopyTask(
|
CopyTask(
|
||||||
|
|
@ -185,6 +190,7 @@ class CopyTask {
|
||||||
lastResult: lastResult ?? this.lastResult,
|
lastResult: lastResult ?? this.lastResult,
|
||||||
lastMessage: lastMessage ?? this.lastMessage,
|
lastMessage: lastMessage ?? this.lastMessage,
|
||||||
lastFiles: lastFiles ?? this.lastFiles,
|
lastFiles: lastFiles ?? this.lastFiles,
|
||||||
|
lastLeft: lastLeft ?? this.lastLeft,
|
||||||
prune: prune ?? this.prune,
|
prune: prune ?? this.prune,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -202,6 +208,7 @@ class CopyTask {
|
||||||
'lastResult': lastResult,
|
'lastResult': lastResult,
|
||||||
'lastMessage': lastMessage,
|
'lastMessage': lastMessage,
|
||||||
'lastFiles': lastFiles,
|
'lastFiles': lastFiles,
|
||||||
|
'lastLeft': lastLeft,
|
||||||
'prune': prune,
|
'prune': prune,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -219,6 +226,7 @@ class CopyTask {
|
||||||
lastResult: j['lastResult'] as String? ?? '',
|
lastResult: j['lastResult'] as String? ?? '',
|
||||||
lastMessage: j['lastMessage'] as String? ?? '',
|
lastMessage: j['lastMessage'] as String? ?? '',
|
||||||
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
|
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 : '',
|
prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,9 @@ class TransferJob extends ChangeNotifier {
|
||||||
/// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것)
|
/// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것)
|
||||||
int leftInSource = 0;
|
int leftInSource = 0;
|
||||||
|
|
||||||
|
/// 235: 끝난 이동에서 실제로 옮긴 파일 수 (원본에 남은 것은 뺀다 - robocopy 는 건너뛴 것도 다 한 것으로 센다)
|
||||||
|
int get doneFiles => move && finished ? (allFiles - leftInSource).clamp(0, allFiles) : allDone;
|
||||||
|
|
||||||
/// 212 · 216: 끝 알림 한 줄 - "이동 끝: n개 · 원본 → 대상 · m개는 원본에 남음 (건너뜀)" / "복사 끝: n개 · 원본 → 대상"
|
/// 212 · 216: 끝 알림 한 줄 - "이동 끝: n개 · 원본 → 대상 · m개는 원본에 남음 (건너뜀)" / "복사 끝: n개 · 원본 → 대상"
|
||||||
String doneSummary() {
|
String doneSummary() {
|
||||||
final from = sources.length == 1 ? vBasename(sources.first) : trf('{0}개 항목', [sources.length]);
|
final from = sources.length == 1 ? vBasename(sources.first) : trf('{0}개 항목', [sources.length]);
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,7 @@ List<List<String>> robocopyRuns({
|
||||||
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
||||||
final extra = [
|
final extra = [
|
||||||
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
|
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
|
||||||
if (move) '/MOVE',
|
if (move) ...robocopyMoveOptions(opts),
|
||||||
// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
|
|
||||||
// 이동은 같은 파일도 포함 (/IS) 해 원본에서 지운다. 속성만 다른 것 (Tweaked) 등 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216)
|
|
||||||
if (move && !opts.any((o) => o.toUpperCase() == '/IS')) '/IS',
|
|
||||||
];
|
];
|
||||||
final runs = <List<String>>[
|
final runs = <List<String>>[
|
||||||
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
||||||
|
|
@ -131,6 +128,16 @@ List<List<String>> robocopyRuns({
|
||||||
return runs;
|
return runs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함.
|
||||||
|
/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
|
||||||
|
/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다.
|
||||||
|
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216)
|
||||||
|
List<String> robocopyMoveOptions(List<String> 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 초
|
/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초
|
||||||
int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000);
|
int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ class TransferProgressLines extends StatelessWidget {
|
||||||
[if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')),
|
[if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')),
|
||||||
),
|
),
|
||||||
line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress,
|
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
|
final (icon, color, status) = running
|
||||||
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
|
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
|
||||||
: switch (t.lastResult) {
|
: 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])),
|
'done' => (Icons.check_circle, Colors.greenAccent, trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])),
|
||||||
'failed' => (Icons.error, Colors.redAccent, trf('실패 · {0}', [_hm(t.lastRun)])),
|
'failed' => (Icons.error, Colors.redAccent, trf('실패 · {0}', [_hm(t.lastRun)])),
|
||||||
'cancelled' => (Icons.pause_circle, Colors.orangeAccent, trf('멈춤 · {0}', [_hm(t.lastRun)])),
|
'cancelled' => (Icons.pause_circle, Colors.orangeAccent, trf('멈춤 · {0}', [_hm(t.lastRun)])),
|
||||||
|
|
|
||||||
|
|
@ -182,12 +182,19 @@ bool taskDeletes(CopyTask t) {
|
||||||
|
|
||||||
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
|
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
|
||||||
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
|
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
|
||||||
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다)
|
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다).
|
||||||
|
/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게)
|
||||||
String effectiveRunText(String method, String options, {required bool move}) {
|
String effectiveRunText(String method, String options, {required bool move}) {
|
||||||
final name = method == 'builtin' ? tr('현재 방식') : method;
|
final name = method == 'builtin' ? tr('현재 방식') : method;
|
||||||
if (method == 'builtin') return name;
|
if (method == 'builtin') return name;
|
||||||
final opts = withoutSourceRemovingOptions(options);
|
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();
|
return '$name $opts$extra'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ void main() {
|
||||||
testWidgets('209: 모니터링 카드는 실제로 실행할 것을 이동 / 복사와 함께 보인다 (복사 카드 칸의 원본 지우기 옵션은 쓰지 않는다고 알림)', (tester) async {
|
testWidgets('209: 모니터링 카드는 실제로 실행할 것을 이동 / 복사와 함께 보인다 (복사 카드 칸의 원본 지우기 옵션은 쓰지 않는다고 알림)', (tester) async {
|
||||||
expect(effectiveRunText('rsync', '-avPog', move: true), 'rsync -avPog --remove-source-files');
|
expect(effectiveRunText('rsync', '-avPog', move: true), 'rsync -avPog --remove-source-files');
|
||||||
expect(effectiveRunText('rsync', '-avPog --remove-source-files', move: false), 'rsync -avPog');
|
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');
|
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);
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,40 @@ void main() {
|
||||||
expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '같은 파일도 옮김');
|
expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '같은 파일도 옮김');
|
||||||
expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'a');
|
expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'a');
|
||||||
expect(job.leftInSource, 0);
|
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 {
|
test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async {
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@ void main() {
|
||||||
bandwidthKBps: 640,
|
bandwidthKBps: 640,
|
||||||
move: true,
|
move: true,
|
||||||
);
|
);
|
||||||
expect(runs[0], [r'C:\a\movies', p.join(r'E:\dst', 'movies'), '/E', '/R:2', '/IPG:100', '/MOVE', '/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']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS)
|
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(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']);
|
||||||
expect(robocopyIpg(0), 0);
|
expect(robocopyIpg(0), 0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue