import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart'; import 'vfs.dart'; /// 78: 일괄 이름 바꾸기 규칙 (Total Commander · X-plore 참고). /// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다. class RenameRule { /// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호. 예: "Show S01E[C]" final String template; /// 번호: 시작 · 간격 · 자릿수 (2 → 01, 02 …) final int start, step, digits; /// 찾아 바꾸기 (비우면 안 함) final String find, replace; final bool ignoreCase; /// 'keep' · 'lower' · 'upper' · 'title' (단어 첫 글자만 크게) final String caseMode; const RenameRule({ this.template = '[N]', this.start = 1, this.step = 1, this.digits = 2, this.find = '', this.replace = '', this.ignoreCase = true, this.caseMode = 'keep', }); /// 바꾸는 것이 하나도 없는 규칙 bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep'; /// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step) String apply(String name, {required bool isDir, required int index}) { final ext = isDir ? '' : p.extension(name); final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length); final n = start + index * step; final counter = n < 0 ? '-${(-n).toString().padLeft(digits, '0')}' : n.toString().padLeft(digits, '0'); var out = template.replaceAll(RegExp(r'\[N\]', caseSensitive: false), base).replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter); if (find.isNotEmpty) { out = out.replaceAll(RegExp(RegExp.escape(find), caseSensitive: !ignoreCase), replace); } out = switch (caseMode) { 'lower' => out.toLowerCase(), 'upper' => out.toUpperCase(), 'title' => out.toLowerCase().replaceAllMapped(RegExp(r'(^|[\s._\-\[(])(\p{L})', unicode: true), (m) => '${m[1]}${m[2]!.toUpperCase()}'), _ => out, }; // 확장자도 대소문자 바꾸기를 따른다 (찾아 바꾸기는 이름에만) final e = switch (caseMode) { 'lower' => ext.toLowerCase(), 'upper' => ext.toUpperCase(), _ => ext }; return out.trim() + e; } } /// 미리 보기 한 줄 class RenameRow { final String path; final String newName; /// 문제 (없으면 null): 쓸 수 없는 이름 · 겹침 · 이미 있음 final String? problem; const RenameRow(this.path, this.newName, this.problem); String get oldName => vBasename(path); bool get changed => newName != oldName; } /// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고) List planRename(List paths, RenameRule rule, {required bool Function(String path) isDir, required Map> siblings}) { bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); // Windows · 대부분의 NAS 는 대소문자를 가리지 않는다 final names = [for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i)]; final rows = []; for (var i = 0; i < paths.length; i++) { final dir = vDirname(paths[i]); final name = names[i]; String? problem; // 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])) && (siblings[dir] ?? const {}).any((s) => same(s, name) && !paths.any((x) => vDirname(x) == dir && same(vBasename(x), s)))) { // 고른 것끼리 맞바꾸는 것 (a→b, b→a) 은 괜찮다 - 고르지 않은 것과 겹치면 안 됨 problem = tr('같은 이름이 이미 있음'); } rows.add(RenameRow(paths[i], name, problem)); } return rows; } /// 일괄 이름 바꾸기 실패 (바꾼 것은 되돌림) class BatchRenameException implements Exception { final String path; final Object error; /// 되돌리지 못한 것 (있으면 알린다) final List stuck; const BatchRenameException(this.path, this.error, this.stuck); @override String toString() => '$path: $error'; } /// 바꾸기. 78 보충 (관리자 판단): 임시 이름은 꼭 필요할 때만 - 새 이름 자리가 비는 차례대로 바로 바꾸고, /// 고른 것끼리 고리로 엮여 (a↔b) 아무것도 먼저 바꿀 수 없을 때만 하나를 임시 이름 (~jjren_) 으로 비켜 둔다. /// (예전에는 모두 임시 이름을 거쳐, 실시간 동기화가 그 순간을 맞추면 백업 쪽에 지우기 · 새 파일이 생길 수 있었다) /// 도중에 실패하면 이미 움직인 것을 같은 방식으로 원래 이름으로 되돌리고 (~jjback_) [BatchRenameException]. /// 돌려주는 값: (원래 경로, 새 경로) - 되돌리기에 쓴다 Future> runBatchRename(List<(String path, String newName)> jobs, {Future Function(String path, String name)? renamer}) async { final ren = renamer ?? FileOps.rename; final todo = [for (final j in jobs) if (vBasename(j.$1) != j.$2) j]; final stamp = DateTime.now().microsecondsSinceEpoch.toRadixString(36); // 항목마다 지금 있는 곳 final now = [for (final j in todo) j.$1]; /// [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], '~${tempTag}_${stamp}_$i'); } catch (e) { if (stuck == null) rethrow; stuck.add(now[i]); pending.remove(i); } } } 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])]; }