JJ_MKVMaker/lib/core/batch_rename.dart

168 lines
7.4 KiB
Dart
Raw Permalink 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;
// 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<String> stuck;
const BatchRenameException(this.path, this.error, this.stuck);
@override
String toString() => '$path: $error';
}
/// 바꾸기. 78 보충 (관리자 판단): 임시 이름은 꼭 필요할 때만 - 새 이름 자리가 비는 차례대로 바로 바꾸고,
/// 고른 것끼리 고리로 엮여 (a↔b) 아무것도 먼저 바꿀 수 없을 때만 하나를 임시 이름 (~jjren_) 으로 비켜 둔다.
/// (예전에는 모두 임시 이름을 거쳐, 실시간 동기화가 그 순간을 맞추면 백업 쪽에 지우기 · 새 파일이 생길 수 있었다)
/// 도중에 실패하면 이미 움직인 것을 같은 방식으로 원래 이름으로 되돌리고 (~jjback_) [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];
/// [idx] 의 항목을 [want] 이름으로. [stuck] 이 있으면 (되돌리기) 실패한 것을 거기 남기고 계속
Future<void> settle(List<int> idx, String Function(int) want, String tempTag, {List<String>? 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 = <String>[];
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])];
}