From a15f6c25af9587b6a832b40fb6e4b4c6b148fe27 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:42:57 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2078=20=EB=B3=B4=EC=B6=A9=20(15=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=C2=B7=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=20a=20=C2=B7=20b):=20Windows=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=93=B8=20=EC=88=98=20=EC=97=86=EB=8A=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=20=EB=A7=89=EA=B8=B0=20=C2=B7=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EB=A6=84=EC=9D=80=20=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20=C2=B7=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EC=97=90=EC=84=9C=20=EC=9E=84=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B9=BC=EA=B8=B0=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a 이름 검사 nameProblem (일괄 미리 보기 · 한 이름 바꾸기 · 새 폴더 공통): - 어디서나: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자 (예전과 같음). - Windows 의 로컬 경로 (WebDAV 는 빼고): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음. 예전에는 미리 보기가 괜찮다고 보인 뒤 [바꾸기] 에서 실패해 되돌려지거나 '.' 이 말없이 빠질 수 있었다. - Android · WebDAV 는 지금처럼 (관리자 판단). b 임시 이름 (~jjren_ · ~jjback_) 이 실시간 동기화 · rsync 에 섞이지 않게: - 일괄 바꾸기는 새 이름 자리가 비는 차례대로 바로 바꾸고, 고른 것끼리 고리로 엮였을 때 (a↔b) 만 하나를 임시 이름으로 비켜 둔다. 되돌리기 (실패 · [되돌리기]) 도 같은 방식. - lsync: 감시 (Windows 폴더 감시) · 맞출 것 세기 (로컬 · WebDAV) · 지울 것 세기 · 앱이 맞추기 (로컬 · WebDAV) 에서 뺀다. rsync 인수 --exclude=~jjren_* --exclude=~jjback_*, robocopy /XF. 맞추기 미리 보기에서도 뺀다. - 탐색기 · Rsync 화면의 rsync 복사 · 이동 (rsyncArgs) 에도 같은 제외 (화면의 명령 줄에도 보임). robocopy 복사 · 이동은 그대로. - 시험: 이름 검사 (예약 · 끝 점 · 빈칸 · 256자, Windows 규칙일 때만), 이어진 이름은 임시 이름 없이 · 고리는 하나만, 실패 되돌림, lsync 가 임시 이름을 세지 않음 · 인수에 제외, rsync 인수 기대값. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 3 ++ assets/l10n/ja.json | 3 ++ assets/l10n/zh-Hans.json | 3 ++ lib/app/live_sync.dart | 14 +++++-- lib/core/batch_rename.dart | 74 ++++++++++++++++++++++--------------- lib/core/file_ops.dart | 37 ++++++++++++++++--- lib/core/sync_preview.dart | 6 +-- lib/core/sync_tools.dart | 3 ++ test/batch_rename_test.dart | 45 +++++++++++++++++++++- test/monitor_test.dart | 13 +++++++ test/sync_tools_test.dart | 4 +- 11 files changed, 159 insertions(+), 46 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 6dd063c..a1c3640 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -332,6 +332,7 @@ "Whisper base (빠름)": "Whisper base (fast)", "Whisper small (정확)": "Whisper small (accurate)", "Whisper 모델 (OpenAI)": "Whisper models (OpenAI)", + "Windows 가 쓰는 이름 ({0})": "Name reserved by Windows ({0})", "Windows 기본 브라우저": "Windows default browser", "Windows 기본 프로그램": "Windows default program", "Windows 알림 설정 열기": "Open Windows notification settings", @@ -1762,6 +1763,7 @@ "이름": "Name", "이름 (비워도 됨)": "Name (optional)", "이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)", + "이름 끝에 점 · 빈칸은 쓸 수 없음": "A name can't end with a dot or space", "이름 바꾸기": "Rename", "이름 바꾸기 (예: 이름 (2))": "Keep both (e.g. name (2))", "이름 바꾸기 · 이동": "Rename · move", @@ -1772,6 +1774,7 @@ "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이름을 바꿉니다": "renames it", "이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})", + "이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)", "이미 그리는 중입니다": "Already drawing", "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 만든 MKV 가 있습니다 ({0}개)": "Some MKVs already exist ({0})", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 6ea8227..3b7c5db 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -332,6 +332,7 @@ "Whisper base (빠름)": "Whisper base(高速)", "Whisper small (정확)": "Whisper small(高精度)", "Whisper 모델 (OpenAI)": "Whisper モデル (OpenAI)", + "Windows 가 쓰는 이름 ({0})": "Windows が使う名前 ({0})", "Windows 기본 브라우저": "Windows の既定のブラウザー", "Windows 기본 프로그램": "Windows の既定のプログラム", "Windows 알림 설정 열기": "Windows の通知設定を開く", @@ -1762,6 +1763,7 @@ "이름": "名前", "이름 (비워도 됨)": "名前 (空欄可)", "이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)", + "이름 끝에 점 · 빈칸은 쓸 수 없음": "名前の末尾に点・空白は使えません", "이름 바꾸기": "名前を変更", "이름 바꾸기 (예: 이름 (2))": "名前を変える (例: 名前 (2))", "이름 바꾸기 · 이동": "名前の変更・移動", @@ -1772,6 +1774,7 @@ "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이름을 바꿉니다": "名前を変えます", "이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})", + "이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)", "이미 그리는 중입니다": "すでに描画中です", "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 만든 MKV 가 있습니다 ({0}개)": "作成済みの MKV があります ({0}件)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 6b1c628..ce267b4 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -332,6 +332,7 @@ "Whisper base (빠름)": "Whisper base(快速)", "Whisper small (정확)": "Whisper small(准确)", "Whisper 모델 (OpenAI)": "Whisper 模型(OpenAI)", + "Windows 가 쓰는 이름 ({0})": "Windows 保留名称 ({0})", "Windows 기본 브라우저": "Windows 默认浏览器", "Windows 기본 프로그램": "Windows 默认程序", "Windows 알림 설정 열기": "打开 Windows 通知设置", @@ -1762,6 +1763,7 @@ "이름": "名称", "이름 (비워도 됨)": "名称 (可留空)", "이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)", + "이름 끝에 점 · 빈칸은 쓸 수 없음": "名称不能以点或空格结尾", "이름 바꾸기": "重命名", "이름 바꾸기 (예: 이름 (2))": "重命名 (例: 名称 (2))", "이름 바꾸기 · 이동": "重命名 · 移动", @@ -1772,6 +1774,7 @@ "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이름을 바꿉니다": "重命名", "이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})", + "이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)", "이미 그리는 중입니다": "已在绘制中", "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 만든 MKV 가 있습니다 ({0}개)": "已有制作好的 MKV({0} 个)", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 4e14cf8..5d5ab2a 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -164,6 +164,7 @@ class LiveSync extends ChangeNotifier { for (final e in items) { if (out.length >= limit) return; final name = p.basename(e.path); + if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다 names.add(name); final r = rel.isEmpty ? name : '$rel/$name'; final t = p.join(dst, name); @@ -183,7 +184,7 @@ class LiveSync extends ChangeNotifier { if (x.delete && await Directory(dst).exists()) { await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { final name = p.basename(e.path); - if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); + if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); } } } @@ -213,6 +214,7 @@ class LiveSync extends ChangeNotifier { for (final e in items) { if (out.length >= limit) return; final name = vBasename(e.path); + if (isAppTempName(name)) continue; // 78 보충 names.add(name); final r = rel.isEmpty ? name : '$rel/$name'; if (e.isDir) { @@ -226,7 +228,7 @@ class LiveSync extends ChangeNotifier { } if (x.delete) { for (final name in there.keys) { - if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); + if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); } } } @@ -338,7 +340,11 @@ class LiveSync extends ChangeNotifier { } if (Platform.isWindows && !isDav(x.source)) { try { - _watch[k] = Directory(x.source).watch(recursive: true).listen((_) => _schedule(x)); + // 78 보충: 이름 바꾸는 중의 임시 이름 (~jjren_ · ~jjback_) 은 바뀐 것으로 치지 않는다 + _watch[k] = Directory(x.source).watch(recursive: true).listen((ev) { + if (isAppTempName(p.basename(ev.path))) return; + _schedule(x); + }); } catch (_) { _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); } @@ -472,6 +478,7 @@ class LiveSync extends ChangeNotifier { if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (x.delete) '--delete', + ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 '${toCygwinPath(x.source, windows: windows)}/', '${toCygwinPath(x.target, windows: windows)}/', ]; @@ -490,6 +497,7 @@ class LiveSync extends ChangeNotifier { // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions) if (!robocopyLinkOption(opts)) '/XJ', if (x.delete) '/PURGE', + ...appTempRobocopyExcludes, // 78 보충 if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}', '/BYTES', '/NJH', '/NJS', '/NDL', '/NP', ]; diff --git a/lib/core/batch_rename.dart b/lib/core/batch_rename.dart index c69f063..1fedd30 100644 --- a/lib/core/batch_rename.dart +++ b/lib/core/batch_rename.dart @@ -79,8 +79,10 @@ List planRename(List paths, RenameRule rule, final dir = vDirname(paths[i]); final name = names[i]; String? problem; - if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|]'))) { - problem = tr('쓸 수 없는 이름'); + // 78 보충: Windows 예약 이름 · 끝 점 · 빈칸 · 255자 (Windows 로컬 경로만) + final bad = nameProblem(name, windowsRules: usesWindowsNames(paths[i])); + if (bad != null) { + problem = bad; } else if ([for (var k = 0; k < paths.length; k++) if (k != i && vDirname(paths[k]) == dir && same(names[k], name)) k].isNotEmpty) { problem = tr('다른 항목과 같은 이름'); } else if (!same(name, vBasename(paths[i])) && @@ -105,8 +107,10 @@ class BatchRenameException implements Exception { String toString() => '$path: $error'; } -/// 바꾸기: 먼저 모두 임시 이름으로, 그다음 새 이름으로 (맞바꾸기 a↔b 도 되게). -/// 도중에 실패하면 이미 움직인 것을 원래 이름으로 되돌리고 (되돌리기도 임시 이름을 거쳐) [BatchRenameException]. +/// 바꾸기. 78 보충 (관리자 판단): 임시 이름은 꼭 필요할 때만 - 새 이름 자리가 비는 차례대로 바로 바꾸고, +/// 고른 것끼리 고리로 엮여 (a↔b) 아무것도 먼저 바꿀 수 없을 때만 하나를 임시 이름 (~jjren_) 으로 비켜 둔다. +/// (예전에는 모두 임시 이름을 거쳐, 실시간 동기화가 그 순간을 맞추면 백업 쪽에 지우기 · 새 파일이 생길 수 있었다) +/// 도중에 실패하면 이미 움직인 것을 같은 방식으로 원래 이름으로 되돌리고 (~jjback_) [BatchRenameException]. /// 돌려주는 값: (원래 경로, 새 경로) - 되돌리기에 쓴다 Future> runBatchRename(List<(String path, String newName)> jobs, {Future Function(String path, String name)? renamer}) async { @@ -116,38 +120,48 @@ Future> runBatchRename(List<(String path, String newName) // 항목마다 지금 있는 곳 final now = [for (final j in todo) j.$1]; - Future rollback(String at, Object e) async { - final stuck = []; - final moved = [for (var i = 0; i < todo.length; i++) if (now[i] != todo[i].$1) i]; - // 되돌리기도 두 단계 (맞바꾼 이름끼리 부딪히지 않게) - for (final i in moved) { + /// [idx] 의 항목을 [want] 이름으로. [stuck] 이 있으면 (되돌리기) 실패한 것을 거기 남기고 계속 + Future settle(List idx, String Function(int) want, String tempTag, {List? stuck}) async { + bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); + final pending = [...idx]; + while (pending.isNotEmpty) { + var moved = false; + for (final i in [...pending]) { + final name = want(i); + final dir = vDirname(now[i]); + // 다른 남은 항목이 아직 그 이름을 쓰고 있으면 그것이 먼저 비켜야 한다 + if (pending.any((j) => j != i && vDirname(now[j]) == dir && same(vBasename(now[j]), name))) continue; + try { + now[i] = await ren(now[i], name); + } catch (e) { + if (stuck == null) rethrow; + stuck.add(now[i]); + } + pending.remove(i); + moved = true; + } + if (moved) continue; + // 고리 (맞바꾸기 등): 하나를 임시 이름으로 비켜 둔다 + final i = pending.first; try { - now[i] = await ren(now[i], '~jjback_${stamp}_$i'); - } catch (_) {} - } - for (final i in moved) { - try { - now[i] = await ren(now[i], vBasename(todo[i].$1)); - } catch (_) { + now[i] = await ren(now[i], '~${tempTag}_${stamp}_$i'); + } catch (e) { + if (stuck == null) rethrow; stuck.add(now[i]); + pending.remove(i); } } - throw BatchRenameException(at, e, stuck); } - for (var i = 0; i < todo.length; i++) { - try { - now[i] = await ren(now[i], '~jjren_${stamp}_$i'); - } catch (e) { - await rollback(todo[i].$1, e); - } - } - for (var i = 0; i < todo.length; i++) { - try { - now[i] = await ren(now[i], todo[i].$2); - } catch (e) { - await rollback(todo[i].$1, e); - } + try { + await settle([for (var i = 0; i < todo.length; i++) i], (i) => todo[i].$2, 'jjren'); + } catch (e) { + final moved = [for (var i = 0; i < todo.length; i++) if (now[i] != todo[i].$1) i]; + // 멈춘 곳: 아직 움직이지 않은 첫 항목 (없으면 첫 항목) + final at = [for (var i = 0; i < todo.length; i++) if (now[i] == todo[i].$1) todo[i].$1].firstOrNull ?? todo.first.$1; + final stuck = []; + await settle(moved, (i) => vBasename(todo[i].$1), 'jjback', stuck: stuck); + throw BatchRenameException(at, e, stuck); } return [for (var i = 0; i < todo.length; i++) (todo[i].$1, now[i])]; } diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 652511d..6b94126 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -298,6 +298,29 @@ String uncRoot(String path) { /// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71) bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync'); +/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게 +bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_'); + +/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수) +const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*']; +const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*']; + +/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null). +/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자. +/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음 +String? nameProblem(String name, {required bool windowsRules}) { + if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름'); + if (!windowsRules) return null; + final base = name.split('.').first.trim().toUpperCase(); + if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]); + if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음'); + if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)'); + return null; +} + +/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고) +bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path); + /// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지. /// 문제가 있으면 [SourceUnreadableException] (아무것도 지우지 않게). /// [allowEmpty]: 사용자가 지울 목록을 보고 "원본이 비어도 그래도 맞추기" 를 고른 경우 (70) - 읽지 못하는 것은 그래도 막는다 @@ -602,6 +625,7 @@ class FileOps { } for (final e in items) { final name = p.basename(e.path); + if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다 names.add(name); final target = p.join(dst, name); if (e is Directory) { @@ -641,7 +665,7 @@ class FileOps { } if (delete) { await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { - if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync')) { + if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) { await e.delete(recursive: true); } } @@ -671,9 +695,8 @@ class FileOps { /// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. static Future rename(String path, String newName) async { final name = newName.trim(); - if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) { - throw FileSystemException(tr('쓸 수 없는 이름입니다'), name); - } + final bad = nameProblem(name, windowsRules: usesWindowsNames(path)); + if (bad != null) throw FileSystemException(bad, name); if (isDav(path)) { final target = vJoin(vDirname(path), name); if (target == vNorm(path)) return target; @@ -695,7 +718,8 @@ class FileOps { static Future makeFolder(String dir, String name) async { final n = name.trim(); - if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), n); + final bad = nameProblem(n, windowsRules: usesWindowsNames(dir)); + if (bad != null) throw FileSystemException(bad, n); if (isDav(dir)) { final target = vJoin(dir, n); if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); @@ -874,6 +898,7 @@ class FileOps { } for (final e in items) { final name = vBasename(e.path); + if (isAppTempName(name)) continue; // 78 보충 names.add(name); final target = vJoin(dst, name); // 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게) @@ -900,7 +925,7 @@ class FileOps { } if (delete) { for (final name in there.keys) { - if (!names.contains(name) && !name.endsWith('.jjsync')) await vDelete(vJoin(dst, name)); + if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) await vDelete(vJoin(dst, name)); } } } diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index bbeedf0..e2ac112 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -1,6 +1,6 @@ import 'dart:io' show FileSystemEntity; -import 'file_ops.dart' show isPartialFile, SourceUnreadableException; +import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; export 'sync_tools.dart' show isSourceRemovingOption; @@ -116,7 +116,7 @@ Future> previewSync(String src, String dst, for (final e in items) { if (out.length >= limit) return; final name = vBasename(e.path); - if (isPartialFile(name)) continue; + if (isPartialFile(name) || isAppTempName(name)) continue; names.add(name); final r = rel.isEmpty ? name : '$rel/$name'; final t = there[name]; @@ -147,7 +147,7 @@ Future> previewSync(String src, String dst, } for (final e in there.values) { final name = vBasename(e.path); - if (names.contains(name) || isPartialFile(name)) continue; + if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue; out.add(PreviewItem(rel.isEmpty ? name : '$rel/$name', delete ? PreviewAction.delete : PreviewAction.onlyTarget, toRight: toRight, isDir: e.isDir, size: e.size)); } diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 329d7fb..0489171 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -1,5 +1,7 @@ import 'package:path/path.dart' as p; +import 'file_ops.dart' show appTempRsyncExcludes; + /// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) /// /// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기. @@ -83,6 +85,7 @@ List rsyncArgs({ // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', if (move) '--remove-source-files', + ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', '${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/', diff --git a/test/batch_rename_test.dart b/test/batch_rename_test.dart index 0c781cd..08347ad 100644 --- a/test/batch_rename_test.dart +++ b/test/batch_rename_test.dart @@ -58,6 +58,19 @@ void main() { }); }); + // 78 보충 (관리자 판단 a): Windows 에서 쓸 수 없는 이름 + test('이름 검사: 예약 이름 (확장자 붙어도) · 끝 점 · 끝 빈칸 · 255자 넘음 - Windows 규칙일 때만', () { + for (final n in ['CON', 'con.txt', 'Nul.mkv', 'LPT1', 'com9.srt', 'a.', 'a ', 'x' * 256]) { + expect(nameProblem(n, windowsRules: true), isNotNull, reason: n); + expect(nameProblem(n, windowsRules: false), isNull, reason: '$n (Android · WebDAV 는 지금처럼)'); + } + for (final n in ['CONSOLE.txt', 'com10', 'a.b', 'x' * 255, 'Show S01E01.mkv']) { + expect(nameProblem(n, windowsRules: true), isNull, reason: n); + } + expect(nameProblem('a:b', windowsRules: false), '쓸 수 없는 이름', reason: '쓸 수 없는 글자는 어디서나'); + expect(nameProblem('', windowsRules: false), '쓸 수 없는 이름'); + }); + group('바꾸기 (실제 파일)', () { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ren78_')); @@ -65,6 +78,34 @@ void main() { String mk(String n, [String body = '']) => (File(p.join(tmp.path, n))..writeAsStringSync(body.isEmpty ? n : body)).path; List names() => (tmp.listSync().map((e) => p.basename(e.path)).toList()..sort()); + test('78 보충 b: 엮이지 않은 이름 · 이어진 이름 (a→b, b→c) 은 임시 이름 없이 바로, 고리 (a↔b) 만 임시 이름 하나', () async { + final used = []; + Future spy(String path, String name) { + used.add(name); + return FileOps.rename(path, name); + } + + final x = mk('x.txt'), a = mk('a.txt'), b = mk('b.txt'); + await runBatchRename([(x, 'y.txt'), (a, 'b.txt'), (b, 'c.txt')], renamer: spy); + expect(used.where(isAppTempName), isEmpty, reason: '$used'); + expect(used.indexOf('c.txt'), lessThan(used.indexOf('b.txt')), reason: 'b 가 먼저 비켜야 a 가 b 로'); + expect(names(), ['b.txt', 'c.txt', 'y.txt']); + + used.clear(); + await runBatchRename([(p.join(tmp.path, 'b.txt'), 'c.txt'), (p.join(tmp.path, 'c.txt'), 'b.txt')], renamer: spy); + expect(used.where(isAppTempName), hasLength(1), reason: '$used'); + expect(File(p.join(tmp.path, 'c.txt')).readAsStringSync(), 'a.txt'); + }); + + test('Windows: 미리 보기에서 예약 이름 · 끝 점을 빨갛게 (바꾸기 전에)', () { + final a = mk('a.txt'); + final rows = planRename([a], const RenameRule(template: 'CON'), isDir: (_) => false, siblings: {tmp.path: {'a.txt'}}); + expect(rows.single.problem, Platform.isWindows ? 'Windows 가 쓰는 이름 (CON)' : isNull); + final dir = Directory(p.join(tmp.path, 'd'))..createSync(); + final dots = planRename([dir.path], const RenameRule(template: 'end.'), isDir: (_) => true, siblings: {tmp.path: {'d'}}); + expect(dots.single.problem, Platform.isWindows ? '이름 끝에 점 · 빈칸은 쓸 수 없음' : isNull); + }, skip: !Platform.isWindows); + test('여러 개 · 맞바꾸기 (a↔b) · 되돌리기', () async { final a = mk('a.txt'), b = mk('b.txt'), c = mk('c.txt'); final done = await runBatchRename([(a, 'b.txt'), (b, 'a.txt'), (c, 'Show 01.txt')]); @@ -81,8 +122,8 @@ void main() { final a = mk('a.txt'), b = mk('b.txt'), c = mk('c.txt'); var calls = 0; Future flaky(String path, String name) { - // 2단계 (새 이름으로) 의 세 번째에서 실패 - if (++calls == 6) throw const FileSystemException('잠김'); + // 세 번째 바꾸기에서 실패 (앞의 둘은 이미 바뀜) + if (++calls == 3) throw const FileSystemException('잠김'); return FileOps.rename(path, name); } diff --git a/test/monitor_test.dart b/test/monitor_test.dart index f170a85..f1385b1 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -172,6 +172,19 @@ void main() { expect(await LiveSync.diff(pair), ['− extra.txt']); live.dispose(); }); + + test('78 보충 b: lsync 는 일괄 이름 바꾸기의 임시 이름 (~jjren_ · ~jjback_) 을 맞출 것으로 세지 않고, rsync 인수에도 제외', () async { + final target = p.join(dst, 'mirror2'); + final pair = LiveSyncPair(src, target, delete: true); + File(p.join(src, '~jjren_abc_0')).writeAsStringSync('x'); + Directory(target).createSync(); + File(p.join(target, '~jjback_abc_1')).writeAsStringSync('x'); + final pending = await LiveSync.diff(pair); + expect(pending.where((x) => x.contains('~jj')), isEmpty, reason: '$pending'); + final args = LiveSync.rsyncArgsFor(pair, c.settings, windows: true); + expect(args, containsAll(['--exclude=~jjren_*', '--exclude=~jjback_*'])); + expect(LiveSync.robocopyArgsFor(pair, c.settings), containsAll(['/XF', '~jjren_*', '~jjback_*'])); + }); }); group('백그라운드 · 다시 켤 때 동기화', () { diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index e90bcdc..28315f3 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -30,10 +30,10 @@ void main() { test('rsync 인수: 옵션 · 속도 제한 · 이동 · 원본 (끝 / 없음) · 대상/', () { expect( rsyncArgs(options: '-avPog', sources: [r'C:\a\src\', r'C:\a\f.mkv'], dest: r'E:\dst', bandwidthKBps: 500, move: true), - ['-avPog', '--bwlimit=500', '--remove-source-files', '/cygdrive/c/a/src', '/cygdrive/c/a/f.mkv', '/cygdrive/e/dst/']); + ['-avPog', '--bwlimit=500', '--remove-source-files', '--exclude=~jjren_*', '--exclude=~jjback_*', '/cygdrive/c/a/src', '/cygdrive/c/a/f.mkv', '/cygdrive/e/dst/']); // 78 보충: 일괄 이름 바꾸기 임시 이름 제외 // 옵션 칸에 직접 넣은 속도 제한이 우선 expect(rsyncArgs(options: '-a --bwlimit=10', sources: ['/x'], dest: '/y', bandwidthKBps: 500, windows: false), - ['-a', '--bwlimit=10', '-8', '/x', '/y/']); // Android: 한글 이름을 그대로 출력 (-8) + ['-a', '--bwlimit=10', '-8', '--exclude=~jjren_*', '--exclude=~jjback_*', '/x', '/y/']); // Android: 한글 이름을 그대로 출력 (-8) }); test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () {