From c41954b3eaf84965fce0908275262ad0443b9ddd Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:22:05 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=A5=BC=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EA=B8=B0=EB=A1=9C=20=ED=95=A9=EC=B3=90?= =?UTF-8?q?=EB=8F=84=20=EC=95=88=EC=AA=BD=EC=9D=98=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=AA=BD=EC=9D=B4=20=EB=8D=94=20=EC=83=88=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=80=20=EA=B1=B4=EB=84=88=EB=9C=80=20=C2=B7=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=EC=B0=BD=EC=97=90=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=95=8C=EB=A6=BC=20(229,=20931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 (버그, 기기 확인): 폴더 [덮어쓰기] (합치기) 가 안쪽의 더 새 파일을 예전 것으로 덮었다 - 설정 '받는 쪽이 더 새것이면 건너뛰기' 를 안쪽에도 - 이동이면 건너뛴 원본은 남기고 옮긴 것만 지움 ('n개는 원본에 남음'), 같은 이름 창의 폴더 줄에 '안쪽에 받는 쪽이 더 새 파일 n개' - 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 그대로 따름 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 4 ++ assets/l10n/ja.json | 4 ++ assets/l10n/zh-Hans.json | 4 ++ lib/app/transfer_job.dart | 10 ++++- lib/core/file_ops.dart | 83 +++++++++++++++++++++++++++++++++++-- lib/ui/explorer_page.dart | 21 +++++++++- test/move_settled_test.dart | 29 +++++++++++++ 7 files changed, 149 insertions(+), 6 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index d17dda7..9c4b577 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 9bac117..4984f5e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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)", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 37ac763..e654a43 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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)", "압축 파일 안에 없습니다": "压缩文件中没有", diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 872ca52..4c284d6 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -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]; // 이름 바꾸기로 옮긴 경우 } diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index a52bfdd..768d30f 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -321,7 +321,21 @@ class FileOps { /// 건너뛴 항목 (원본 경로) final skipped = []; - 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 = []; /// 214: [mirror] 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때. /// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다) @@ -424,13 +438,18 @@ class FileOps { onFileDone?.call(f.path); } - Future copyAny(String src, String target) async { + Future 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 _deleteExcept(String dir, Set keep) async { + Future 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 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 countNewerInside(String src, String dst, {int limit = 5000}) async { + var n = 0, seen = 0; + Future 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> move(List 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 { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 54d52f9..e0b8670 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1943,11 +1943,30 @@ class _ExplorerPageState extends State { 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( + 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} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart index fdf3d78..ccae87a 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -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)));