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] 번호 · [D] 날짜 · [T] 시각) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다. class RenameRule { /// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호, [D] = 날짜 (2026-10-18), [T] = 시각 (13-05-09). 예: "Show S01E[C]" final String template; /// X-plore 비교 i: [D] · [T] 의 날짜 - 'photo' 찍은 때 (사진 Exif, 없으면 바뀐 때) / 'modified' 바뀐 때 final String dateSource; /// 번호: 시작 · 간격 · 자릿수 (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', this.dateSource = 'photo', }); /// X-plore 비교 i: 마지막에 쓴 규칙 기억 (설정 batchRenameLast) Map toJson() => { 'template': template, 'start': start, 'step': step, 'digits': digits, 'find': find, 'replace': replace, 'ignoreCase': ignoreCase, 'caseMode': caseMode, 'dateSource': dateSource, }; /// 틀리거나 없는 값은 처음 값으로 factory RenameRule.fromJson(Map j) { int n(String k, int d) => j[k] is int ? j[k] as int : d; String s(String k, String d) => j[k] is String ? j[k] as String : d; return RenameRule( template: s('template', '[N]').isEmpty ? '[N]' : s('template', '[N]'), start: n('start', 1), step: n('step', 1), digits: n('digits', 2).clamp(1, 9), find: s('find', ''), replace: s('replace', ''), ignoreCase: j['ignoreCase'] != false, caseMode: const ['keep', 'lower', 'upper', 'title'].contains(j['caseMode']) ? j['caseMode'] as String : 'keep', dateSource: j['dateSource'] == 'modified' ? 'modified' : 'photo', ); } /// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지) bool get usesDate => RegExp(r'\[[DT]\]', caseSensitive: false).hasMatch(template); /// 바꾸는 것이 하나도 없는 규칙 bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep'; /// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸) String apply(String name, {required bool isDir, required int index, DateTime? date}) { 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'); String two(int v) => v.toString().padLeft(2, '0'); final day = date == null ? '' : '${date.year.toString().padLeft(4, '0')}-${two(date.month)}-${two(date.day)}'; // 시각은 ':' 를 쓸 수 없어 (Windows) '-' 로 final time = date == null ? '' : '${two(date.hour)}-${two(date.minute)}-${two(date.second)}'; var out = template .replaceAll(RegExp(r'\[N\]', caseSensitive: false), base) .replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter) .replaceAll(RegExp(r'\[D\]', caseSensitive: false), day) .replaceAll(RegExp(r'\[T\]', caseSensitive: false), time); 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] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고) /// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸) List planRename(List paths, RenameRule rule, {required bool Function(String path) isDir, required Map> siblings, Map dates = const {}}) { 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, date: dates[paths[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])]; }