JJ_MKVMaker/lib/core/batch_rename.dart

154 lines
6.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<RenameRow> planRename(List<String> paths, RenameRule rule,
{required bool Function(String path) isDir, required Map<String, Set<String>> 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 = <RenameRow>[];
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<String> stuck;
const BatchRenameException(this.path, this.error, this.stuck);
@override
String toString() => '$path: $error';
}
/// 바꾸기: 먼저 모두 임시 이름으로, 그다음 새 이름으로 (맞바꾸기 a↔b 도 되게).
/// 도중에 실패하면 이미 움직인 것을 원래 이름으로 되돌리고 (되돌리기도 임시 이름을 거쳐) [BatchRenameException].
/// 돌려주는 값: (원래 경로, 새 경로) - 되돌리기에 쓴다
Future<List<(String, String)>> runBatchRename(List<(String path, String newName)> jobs,
{Future<String> 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<Never> rollback(String at, Object e) async {
final stuck = <String>[];
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])];
}