241 lines
11 KiB
Dart
241 lines
11 KiB
Dart
import 'package:path/path.dart' as p;
|
||
|
||
import '../l10n/tr.dart';
|
||
import 'audio_tags.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<String, Object> toJson() => {
|
||
'template': template,
|
||
'start': start,
|
||
'step': step,
|
||
'digits': digits,
|
||
'find': find,
|
||
'replace': replace,
|
||
'ignoreCase': ignoreCase,
|
||
'caseMode': caseMode,
|
||
'dateSource': dateSource,
|
||
};
|
||
|
||
/// 틀리거나 없는 값은 처음 값으로
|
||
factory RenameRule.fromJson(Map<String, dynamic> 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',
|
||
);
|
||
}
|
||
|
||
/// X-plore 비교 5-1 ③ (관리자 지시 10/21): 틀에 음악 태그 ([Title] · [Artist] · [Album] · [Track] · [Year]) 가 있는지 (태그를 읽어야 하는지)
|
||
bool get usesTags => _tagToken.hasMatch(template);
|
||
static final _tagToken = RegExp(r'\[(Title|Artist|Album|Track|Year)\]', caseSensitive: false);
|
||
|
||
/// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지)
|
||
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] 에 (없으면 빈칸).
|
||
/// [tags] 는 음악 태그 (없으면 빈칸). X-plore 비교 5-1 ③: [N2-5] = 원래 이름의 2~5 번째 글자, [N3-] = 3 번째부터 끝까지
|
||
String apply(String name, {required bool isDir, required int index, DateTime? date, AudioTags? tags}) {
|
||
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)}';
|
||
// 글자 범위 (1 부터, 끝 포함) - 글자는 유니코드 한 글자씩 (한글 · 이모지가 반쪽으로 잘리지 않게)
|
||
final chars = base.runes.map(String.fromCharCode).toList();
|
||
String range(Match m) {
|
||
final from = (int.parse(m[1]!) - 1).clamp(0, chars.length);
|
||
final to = m[2]!.isEmpty ? chars.length : int.parse(m[2]!).clamp(from, chars.length);
|
||
return chars.sublist(from, to).join();
|
||
}
|
||
|
||
String tag(String? v) => (v ?? '').replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||
final track = tags?.trackNumber;
|
||
var out = template
|
||
.replaceAllMapped(RegExp(r'\[N(\d+)-(\d*)\]', caseSensitive: false), range)
|
||
.replaceAll(RegExp(r'\[Title\]', caseSensitive: false), tag(tags?.title))
|
||
.replaceAll(RegExp(r'\[Artist\]', caseSensitive: false), tag(tags?.artist))
|
||
.replaceAll(RegExp(r'\[Album\]', caseSensitive: false), tag(tags?.album))
|
||
.replaceAll(RegExp(r'\[Track\]', caseSensitive: false), track == null ? '' : track.padLeft(2, '0'))
|
||
.replaceAll(RegExp(r'\[Year\]', caseSensitive: false), tag(tags?.year))
|
||
.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] 에 쓰는 항목마다의 날짜 (없으면 빈칸), [tags] 는 음악 태그 (5-1 ③)
|
||
List<RenameRow> planRename(List<String> paths, RenameRule rule,
|
||
{required bool Function(String path) isDir,
|
||
required Map<String, Set<String>> siblings,
|
||
Map<String, DateTime> dates = const {},
|
||
Map<String, AudioTags> tags = 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]], tags: tags[paths[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])];
|
||
}
|