154 lines
6.1 KiB
Dart
154 lines
6.1 KiB
Dart
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])];
|
||
}
|