폴더를 덮어쓰기로 합쳐도 안쪽의 받는 쪽이 더 새 파일은 건너뜀 · 같은 이름 창에 미리 알림 (229, 931)
- 데이터 (버그, 기기 확인): 폴더 [덮어쓰기] (합치기) 가 안쪽의 더 새 파일을 예전 것으로 덮었다 - 설정 '받는 쪽이 더 새것이면 건너뛰기' 를 안쪽에도
- 이동이면 건너뛴 원본은 남기고 옮긴 것만 지움 ('n개는 원본에 남음'), 같은 이름 창의 폴더 줄에 '안쪽에 받는 쪽이 더 새 파일 n개'
- 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 그대로 따름
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
parent
bf4b27a5f6
commit
c41954b3ea
|
|
@ -1050,6 +1050,7 @@
|
|||
"받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "{0} items will be deleted on the receiving side (--delete, cannot be undone)",
|
||||
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "{0} files on the receiving side will be overwritten (same name, different content - the original is lost)",
|
||||
"받는 쪽이 더 새것 {0} (건너뜀)": "Newer on receiving side {0} (skipped)",
|
||||
"받는 쪽이 더 새것 {0}개 건너뜀": "{0} skipped (newer on the receiving side)",
|
||||
"받는 쪽이 더 새것이면 건너뛰기": "Skip when the receiving side is newer",
|
||||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept",
|
||||
"받다 만 AI 모델": "Unfinished AI models",
|
||||
|
|
@ -1374,6 +1375,9 @@
|
|||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment",
|
||||
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
|
||||
"안내 닫기": "Close tip",
|
||||
"안쪽 파일을 확인하는 중 …": "Checking files inside …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||
"암호화 안 됨 (http)": "Not encrypted (http)",
|
||||
"압축 파일 안에 없습니다": "Not in the archive",
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,7 @@
|
|||
"받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "受け取る側で {0} 個が削除されます (--delete、元に戻せません)",
|
||||
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "受け取る側の {0} 件を上書きします (同じ名前で内容が違うもの - 元のものはなくなります)",
|
||||
"받는 쪽이 더 새것 {0} (건너뜀)": "受け取る側が新しい {0} (スキップ)",
|
||||
"받는 쪽이 더 새것 {0}개 건너뜀": "受け取る側が新しい {0} 件をスキップ",
|
||||
"받는 쪽이 더 새것이면 건너뛰기": "受け取る側が新しければスキップ",
|
||||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま",
|
||||
"받다 만 AI 모델": "ダウンロード途中の AI モデル",
|
||||
|
|
@ -1374,6 +1375,9 @@
|
|||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください",
|
||||
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
|
||||
"안내 닫기": "案内を閉じる",
|
||||
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||
"암호화 안 됨 (http)": "暗号化なし (http)",
|
||||
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,7 @@
|
|||
"받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "接收方将删除 {0} 个项目 (--delete,无法撤销)",
|
||||
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "将覆盖接收端的 {0} 个文件(同名但内容不同 - 原文件将丢失)",
|
||||
"받는 쪽이 더 새것 {0} (건너뜀)": "接收端较新 {0}(跳过)",
|
||||
"받는 쪽이 더 새것 {0}개 건너뜀": "已跳过接收端较新的 {0} 个",
|
||||
"받는 쪽이 더 새것이면 건너뛰기": "接收端较新时跳过",
|
||||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留",
|
||||
"받다 만 AI 모델": "未下载完的 AI 模型",
|
||||
|
|
@ -1374,6 +1375,9 @@
|
|||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试",
|
||||
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
|
||||
"안내 닫기": "关闭提示",
|
||||
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||
"암호화 안 됨 (http)": "未加密 (http)",
|
||||
"압축 파일 안에 없습니다": "压缩文件中没有",
|
||||
|
|
|
|||
|
|
@ -188,6 +188,9 @@ class TransferJob extends ChangeNotifier {
|
|||
/// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것)
|
||||
int leftInSource = 0;
|
||||
|
||||
/// 229: 폴더를 합칠 때 받는 쪽이 더 새것이라 건너뛴 파일 수 (복사 · 이동)
|
||||
int keptNewer = 0;
|
||||
|
||||
/// 235: 끝난 이동에서 실제로 옮긴 파일 수 (원본에 남은 것은 뺀다 - robocopy 는 건너뛴 것도 다 한 것으로 센다)
|
||||
int get doneFiles => move && finished ? (allFiles - leftInSource).clamp(0, allFiles) : allDone;
|
||||
|
||||
|
|
@ -197,7 +200,9 @@ class TransferJob extends ChangeNotifier {
|
|||
final total = filesTotal.fold(0, (a, b) => a + b);
|
||||
final n = move ? (total - leftInSource).clamp(0, total) : allDone;
|
||||
final head = move ? trf('이동 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]) : trf('복사 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]);
|
||||
return leftInSource > 0 ? '$head · ${trf('{0}개는 원본에 남음 (건너뜀)', [leftInSource])}' : head;
|
||||
if (leftInSource > 0) return '$head · ${trf('{0}개는 원본에 남음 (건너뜀)', [leftInSource])}';
|
||||
// 229: 복사에서 받는 쪽이 더 새것이라 건너뛴 것
|
||||
return keptNewer > 0 ? '$head · ${trf('받는 쪽이 더 새것 {0}개 건너뜀', [keptNewer])}' : head;
|
||||
}
|
||||
|
||||
/// 위쪽 진행률: 끝낸 항목 + 지금 항목의 파일 진행
|
||||
|
|
@ -322,6 +327,8 @@ class TransferJob extends ChangeNotifier {
|
|||
// 보낸 양으로 전송 속도 (WebDAV 포함)
|
||||
onBytes: _addBytes,
|
||||
conflict: conflict,
|
||||
// 229: 폴더를 합칠 때도 안쪽의 더 새 파일은 건너뜀
|
||||
skipNewerInMerge: skipNewer,
|
||||
// 214: 이동은 맞춘 원본 파일만 지운다
|
||||
onSettled: move ? (s, size, modified) => settled[s] = (size, modified) : null,
|
||||
);
|
||||
|
|
@ -336,6 +343,7 @@ class TransferJob extends ChangeNotifier {
|
|||
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest);
|
||||
made.addAll(r);
|
||||
skipped.addAll(_ops!.skipped);
|
||||
keptNewer += _ops!.keptNewer.length;
|
||||
}
|
||||
filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,7 +321,21 @@ class FileOps {
|
|||
/// 건너뛴 항목 (원본 경로)
|
||||
final skipped = <String>[];
|
||||
|
||||
FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes, this.conflict = NameConflict.rename, this.onSettled});
|
||||
FileOps({
|
||||
this.bandwidthKBps = 0,
|
||||
this.onFileDone,
|
||||
this.onBytes,
|
||||
this.conflict = NameConflict.rename,
|
||||
this.onSettled,
|
||||
this.skipNewerInMerge = false,
|
||||
});
|
||||
|
||||
/// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기").
|
||||
/// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로)
|
||||
final bool skipNewerInMerge;
|
||||
|
||||
/// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다)
|
||||
final keptNewer = <String>[];
|
||||
|
||||
/// 214: [mirror] 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때.
|
||||
/// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다)
|
||||
|
|
@ -424,13 +438,18 @@ class FileOps {
|
|||
onFileDone?.call(f.path);
|
||||
}
|
||||
|
||||
Future<void> copyAny(String src, String target) async {
|
||||
Future<void> copyAny(String src, String target, {bool inner = false}) async {
|
||||
if (FileSystemEntity.isDirectorySync(src)) {
|
||||
await Directory(target).create(recursive: true);
|
||||
await for (final e in Directory(src).list(followLinks: false)) {
|
||||
await copyAny(e.path, p.join(target, p.basename(e.path)));
|
||||
await copyAny(e.path, p.join(target, p.basename(e.path)), inner: true);
|
||||
}
|
||||
} else {
|
||||
// 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다
|
||||
if (inner && skipNewerInMerge && await isNewerThere(src, target)) {
|
||||
keptNewer.add(src);
|
||||
return;
|
||||
}
|
||||
await copyFile(File(src), target);
|
||||
}
|
||||
}
|
||||
|
|
@ -447,6 +466,55 @@ class FileOps {
|
|||
return made;
|
||||
}
|
||||
|
||||
/// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다
|
||||
Future<void> _deleteExcept(String dir, Set<String> keep) async {
|
||||
Future<bool> walk(Directory d) async {
|
||||
var empty = true;
|
||||
await for (final e in d.list(followLinks: false)) {
|
||||
if (e is Directory) {
|
||||
if (await walk(e)) {
|
||||
await e.delete();
|
||||
} else {
|
||||
empty = false;
|
||||
}
|
||||
} else if (keep.contains(p.normalize(e.path))) {
|
||||
empty = false;
|
||||
} else {
|
||||
await e.delete();
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
if (await walk(Directory(dir))) await Directory(dir).delete();
|
||||
}
|
||||
|
||||
/// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지
|
||||
static Future<bool> isNewerThere(String src, String target) async {
|
||||
final t = File(target);
|
||||
if (!await t.exists()) return false;
|
||||
return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2)));
|
||||
}
|
||||
|
||||
/// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만)
|
||||
static Future<int> countNewerInside(String src, String dst, {int limit = 5000}) async {
|
||||
var n = 0, seen = 0;
|
||||
Future<void> walk(String s, String d) async {
|
||||
await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) {
|
||||
if (seen++ > limit) return;
|
||||
final t = p.join(d, p.basename(e.path));
|
||||
if (e is Directory) {
|
||||
if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t);
|
||||
} else if (await isNewerThere(e.path, t)) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제
|
||||
Future<List<String>> move(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress);
|
||||
|
|
@ -463,9 +531,16 @@ class FileOps {
|
|||
final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound;
|
||||
if (exists && FileSystemEntity.isDirectorySync(s)) {
|
||||
// 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제
|
||||
final keptBefore = keptNewer.length;
|
||||
made.addAll(await copy([s], destDir, onProgress: onProgress));
|
||||
_check();
|
||||
await delete([s]);
|
||||
final kept = keptNewer.sublist(keptBefore);
|
||||
if (kept.isEmpty) {
|
||||
await delete([s]);
|
||||
} else {
|
||||
// 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다
|
||||
await _deleteExcept(s, kept.map(p.normalize).toSet());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1943,11 +1943,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]),
|
||||
style: const TextStyle(color: Colors.orangeAccent)),
|
||||
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
|
||||
for (final x in clashes.take(3))
|
||||
for (final x in clashes.take(3)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 8),
|
||||
child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
// 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리
|
||||
if (x.there.isDir && !isDav(x.src) && !isDav(dest))
|
||||
FutureBuilder<int>(
|
||||
future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))),
|
||||
builder: (_, n) => Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: Text(
|
||||
n.connectionState != ConnectionState.done
|
||||
? tr('안쪽 파일을 확인하는 중 …')
|
||||
: (n.data ?? 0) == 0
|
||||
? ''
|
||||
: c.settings.copySkipNewer
|
||||
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data])
|
||||
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (always != null)
|
||||
Text(
|
||||
trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/transfer_job.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.dart' show FileOps, NameConflict;
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart' show CopyMethod;
|
||||
|
|
@ -189,6 +190,34 @@ void main() {
|
|||
expect(job.doneSummary(), contains('1개는 원본에 남음'));
|
||||
});
|
||||
|
||||
test('229 폴더 충돌 (931): 폴더를 [덮어쓰기] 로 합쳐도 안쪽의 받는 쪽이 더 새 파일은 건너뛰고 원본에 남긴다', () async {
|
||||
final base = Directory.systemTemp.createTempSync('jj_merge_');
|
||||
addTearDown(() => base.deleteSync(recursive: true));
|
||||
final s2 = p.join(base.path, 's2'), d2 = p.join(base.path, 'd2');
|
||||
final oldSame = File(p.join(s2, 'D', 'same.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('old');
|
||||
oldSame.setLastModifiedSync(DateTime(2026, 1, 1));
|
||||
File(p.join(s2, 'D', 'other.txt')).writeAsStringSync('o');
|
||||
File(p.join(d2, 'D', 'same.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('NEWER');
|
||||
expect(await FileOps.countNewerInside(p.join(s2, 'D'), p.join(d2, 'D')), 1, reason: '같은 이름 창에 미리 보일 수');
|
||||
final job = TransferJob(sources: [p.join(s2, 'D')], dest: d2, move: true, conflict: NameConflict.overwrite, skipNewer: true);
|
||||
await job.run();
|
||||
expect(job.error, isNull);
|
||||
expect(File(p.join(d2, 'D', 'same.txt')).readAsStringSync(), 'NEWER', reason: '더 새 파일 그대로');
|
||||
expect(oldSame.existsSync(), isTrue, reason: '건너뛴 원본은 이동이어도 남김');
|
||||
expect(File(p.join(s2, 'D', 'other.txt')).existsSync(), isFalse, reason: '나머지는 옮김');
|
||||
expect(File(p.join(d2, 'D', 'other.txt')).readAsStringSync(), 'o');
|
||||
expect(job.leftInSource, 1);
|
||||
expect(job.doneSummary(), contains('1개는 원본에 남음'));
|
||||
// 건너뛰기를 끄면 (사용자가 고름) 예전처럼 합치며 덮어쓴다
|
||||
final job2 = TransferJob(sources: [p.join(s2, 'D')], dest: d2, move: false, conflict: NameConflict.overwrite, skipNewer: false);
|
||||
await job2.run();
|
||||
expect(File(p.join(d2, 'D', 'same.txt')).readAsStringSync(), 'old');
|
||||
});
|
||||
|
||||
test('229: 건너뛰기를 끄면 (사용자가 고름) 예전처럼 덮어쓴다', () async {
|
||||
final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src');
|
||||
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
|
||||
|
|
|
|||
Loading…
Reference in New Issue