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; if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|]'))) { problem = tr('쓸 수 없는 이름'); } 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'; } /// 바꾸기: 먼저 모두 임시 이름으로, 그다음 새 이름으로 (맞바꾸기 a↔b 도 되게). /// 도중에 실패하면 이미 움직인 것을 원래 이름으로 되돌리고 (되돌리기도 임시 이름을 거쳐) [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]; 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) { 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 (_) { stuck.add(now[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); } } return [for (var i = 0; i < todo.length; i++) (todo[i].$1, now[i])]; }