358 lines
16 KiB
Dart
358 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../core/audio_tags.dart';
|
|
import '../core/batch_rename.dart';
|
|
import '../core/photo_date.dart';
|
|
import '../core/vfs.dart';
|
|
import '../l10n/tr.dart';
|
|
import 'theme.dart';
|
|
import 'jj_field.dart';
|
|
|
|
/// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과.
|
|
/// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null.
|
|
/// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다.
|
|
/// X-plore 비교 i: [initial] 은 마지막에 쓴 규칙 (창이 그 값으로 열림), [onRun] 은 바꾸기를 누를 때의 규칙 (기억하려고)
|
|
Future<List<(String, String)>?> showBatchRename(BuildContext context, List<String> paths,
|
|
{required bool Function(String) isDir, RenameRule initial = const RenameRule(), void Function(RenameRule rule)? onRun}) =>
|
|
showDialog<List<(String, String)>>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir, initial: initial, onRun: onRun),
|
|
);
|
|
|
|
class _BatchRenameDialog extends StatefulWidget {
|
|
final List<String> paths;
|
|
final bool Function(String) isDir;
|
|
final RenameRule initial;
|
|
final void Function(RenameRule rule)? onRun;
|
|
const _BatchRenameDialog({required this.paths, required this.isDir, required this.initial, this.onRun});
|
|
|
|
@override
|
|
State<_BatchRenameDialog> createState() => _BatchRenameDialogState();
|
|
}
|
|
|
|
class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|
late final _template = TextEditingController(text: widget.initial.template);
|
|
late final _start = TextEditingController(text: '${widget.initial.start}');
|
|
late final _step = TextEditingController(text: '${widget.initial.step}');
|
|
late final _digits = TextEditingController(text: '${widget.initial.digits}');
|
|
late final _find = TextEditingController(text: widget.initial.find);
|
|
late final _replace = TextEditingController(text: widget.initial.replace);
|
|
late bool _ignoreCase = widget.initial.ignoreCase;
|
|
late String _case = widget.initial.caseMode;
|
|
late String _dateSource = widget.initial.dateSource;
|
|
|
|
/// X-plore 비교 i: 항목마다 바뀐 때 (목록에서) · 찍은 때 (사진 Exif, 틀에 [D] · [T] 가 있을 때 읽음)
|
|
final _modified = <String, DateTime>{};
|
|
final _taken = <String, DateTime?>{};
|
|
bool _takenLoading = false;
|
|
|
|
/// X-plore 비교 5-1 ③ (관리자 지시 10/21): 음악 태그 (틀에 [Title] · [Artist] … 가 있을 때 한 번만 읽음, 이 기기의 파일만)
|
|
final _tags = <String, AudioTags>{};
|
|
bool _tagsLoading = false;
|
|
|
|
Future<void> _loadTags() async {
|
|
if (_tagsLoading || _tags.isNotEmpty) return;
|
|
_tagsLoading = true;
|
|
for (final x in widget.paths) {
|
|
_tags[x] = isDav(x) ? const AudioTags() : await readAudioTags(x);
|
|
}
|
|
if (mounted) setState(() => _tagsLoading = false);
|
|
}
|
|
|
|
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
|
|
final _siblings = <String, Set<String>>{};
|
|
bool _loading = true;
|
|
bool _running = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSiblings();
|
|
}
|
|
|
|
Future<void> _loadSiblings() async {
|
|
for (final dir in {for (final x in widget.paths) vDirname(x)}) {
|
|
try {
|
|
final list = await vList(dir);
|
|
_siblings[dir] = {for (final e in list) vBasename(e.path)};
|
|
for (final e in list) {
|
|
if (widget.paths.contains(e.path)) _modified[e.path] = e.modified;
|
|
}
|
|
} catch (_) {
|
|
_siblings[dir] = {};
|
|
}
|
|
}
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
|
|
/// 사진의 찍은 때를 한 번만 읽는다 (틀에 [D] · [T] 를 넣고 "찍은 때" 일 때)
|
|
Future<void> _loadTaken() async {
|
|
if (_takenLoading || _taken.isNotEmpty) return;
|
|
_takenLoading = true;
|
|
for (final x in widget.paths) {
|
|
_taken[x] = await photoTakenAt(x);
|
|
}
|
|
if (mounted) setState(() => _takenLoading = false);
|
|
}
|
|
|
|
Map<String, DateTime> get _dates => {
|
|
for (final x in widget.paths) x: ?((_dateSource == 'photo' ? _taken[x] : null) ?? _modified[x]),
|
|
};
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final x in [_template, _start, _step, _digits, _find, _replace]) {
|
|
x.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
RenameRule get _rule => RenameRule(
|
|
template: _template.text.isEmpty ? '[N]' : _template.text,
|
|
start: int.tryParse(_start.text.trim()) ?? 1,
|
|
step: int.tryParse(_step.text.trim()) ?? 1,
|
|
digits: (int.tryParse(_digits.text.trim()) ?? 1).clamp(1, 9),
|
|
find: _find.text,
|
|
replace: _replace.text,
|
|
ignoreCase: _ignoreCase,
|
|
caseMode: _case,
|
|
dateSource: _dateSource,
|
|
);
|
|
|
|
void _insert(String tag) {
|
|
final t = _template;
|
|
final sel = t.selection.isValid ? t.selection : TextSelection.collapsed(offset: t.text.length);
|
|
t.value = TextEditingValue(
|
|
text: t.text.replaceRange(sel.start, sel.end, tag),
|
|
selection: TextSelection.collapsed(offset: sel.start + tag.length),
|
|
);
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _run(List<RenameRow> rows) async {
|
|
setState(() {
|
|
_running = true;
|
|
_error = null;
|
|
});
|
|
widget.onRun?.call(_rule);
|
|
try {
|
|
final done = await runBatchRename([for (final r in rows) (r.path, r.newName)]);
|
|
if (mounted) Navigator.pop(context, done);
|
|
} on BatchRenameException catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_running = false;
|
|
_error = [
|
|
trf('"{0}" 의 이름을 바꾸지 못해 멈췄습니다: {1}', [vBasename(e.path), e.error]),
|
|
if (e.stuck.isEmpty)
|
|
tr('이미 바꾼 것은 모두 원래 이름으로 되돌렸습니다.')
|
|
else
|
|
trf('원래 이름으로 되돌리지 못한 것: {0}', [e.stuck.map(vBasename).join(', ')]),
|
|
].join('\n');
|
|
});
|
|
// 되돌린 뒤의 실제 이름으로 다시 본다
|
|
_siblings.clear();
|
|
await _loadSiblings();
|
|
}
|
|
}
|
|
|
|
Widget _num(TextEditingController c, String label) => SizedBox(
|
|
width: 72,
|
|
child: TextField(
|
|
controller: c,
|
|
keyboardType: const TextInputType.numberWithOptions(signed: true),
|
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^-?\d{0,6}'))],
|
|
decoration: InputDecoration(labelText: label, isDense: true),
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final rule = _rule;
|
|
if (rule.usesDate && _dateSource == 'photo') _loadTaken();
|
|
if (rule.usesTags) _loadTags();
|
|
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates, tags: _tags);
|
|
final changed = rows.where((r) => r.changed).length;
|
|
final bad = rows.where((r) => r.problem != null).length;
|
|
final canRun = !_loading && !_takenLoading && !_tagsLoading && !_running && bad == 0 && changed > 0;
|
|
return AlertDialog(
|
|
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
|
|
// 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게
|
|
content: SizedBox(
|
|
width: 640,
|
|
child: SingleChildScrollView(
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
// 이름 틀 + [N] [C] 넣기
|
|
Row(children: [
|
|
Expanded(
|
|
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 (넣는 법이 길어 4줄까지)
|
|
child: JjField(
|
|
controller: _template,
|
|
enabled: !_running,
|
|
label: tr('이름 틀'),
|
|
helper: tr('[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]'),
|
|
helperLines: 4,
|
|
isDense: true,
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
OutlinedButton(onPressed: _running ? null : () => _insert('[N]'), child: const Text('[N]')),
|
|
const SizedBox(width: 4),
|
|
OutlinedButton(onPressed: _running ? null : () => _insert('[C]'), child: const Text('[C]')),
|
|
]),
|
|
// X-plore 비교 i: 날짜 · 시각 넣기와 어느 날짜를 쓸지
|
|
Row(children: [
|
|
OutlinedButton(onPressed: _running ? null : () => _insert('[D]'), child: const Text('[D]')),
|
|
const SizedBox(width: 4),
|
|
OutlinedButton(onPressed: _running ? null : () => _insert('[T]'), child: const Text('[T]')),
|
|
const SizedBox(width: 8),
|
|
// 좁은 화면 (412) 에서 긴 항목이 창을 넘었다 - 남은 폭 안에서 줄임
|
|
Expanded(
|
|
child: DropdownButton<String>(
|
|
isExpanded: true,
|
|
value: _dateSource,
|
|
// 412 캡처 (10/18): 고른 뒤 상자에는 짧게 (펼친 목록은 긴 설명 그대로)
|
|
selectedItemBuilder: (_) => [
|
|
Text(tr('날짜: 찍은 때'), maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
Text(tr('날짜: 바뀐 때'), maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
],
|
|
items: [
|
|
DropdownMenuItem(value: 'photo', child: Text(tr('날짜: 찍은 때 (사진, 없으면 바뀐 때)'), overflow: TextOverflow.ellipsis)),
|
|
DropdownMenuItem(value: 'modified', child: Text(tr('날짜: 바뀐 때'), overflow: TextOverflow.ellipsis)),
|
|
],
|
|
onChanged: _running ? null : (v) => setState(() => _dateSource = v!),
|
|
),
|
|
),
|
|
// X-plore 비교 5-1 ③: 음악 태그 넣기 (이 기기의 MP3 · FLAC · M4A)
|
|
PopupMenuButton<String>(
|
|
enabled: !_running,
|
|
tooltip: tr('음악 태그 넣기'),
|
|
onSelected: _insert,
|
|
itemBuilder: (_) => [
|
|
for (final (t, label) in [
|
|
('[Artist]', tr('가수')),
|
|
('[Title]', tr('제목')),
|
|
('[Album]', tr('앨범')),
|
|
('[Track]', tr('트랙 번호')),
|
|
('[Year]', tr('해')),
|
|
])
|
|
PopupMenuItem(value: t, child: Text('$t $label')),
|
|
],
|
|
// 412 · 큰 글자에서 이 줄이 넘쳐 (253 시험) 글 대신 작은 아이콘
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
|
child: Icon(Icons.music_note_outlined, size: 20, color: JjColors.accent),
|
|
),
|
|
),
|
|
]),
|
|
const SizedBox(height: 6),
|
|
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
|
|
_num(_start, tr('번호 시작')),
|
|
_num(_step, tr('간격')),
|
|
_num(_digits, tr('자릿수')),
|
|
]),
|
|
// 대소문자 고르기는 한 줄 전체 (가장 긴 항목 폭이 좁은 화면 · 큰 글자에서 창을 넘었다 - 253 시험)
|
|
DropdownButton<String>(
|
|
isExpanded: true,
|
|
value: _case,
|
|
items: [
|
|
DropdownMenuItem(value: 'keep', child: Text(tr('대소문자 그대로'), overflow: TextOverflow.ellipsis)),
|
|
DropdownMenuItem(value: 'lower', child: Text(tr('모두 작게 (abc)'), overflow: TextOverflow.ellipsis)),
|
|
DropdownMenuItem(value: 'upper', child: Text(tr('모두 크게 (ABC)'), overflow: TextOverflow.ellipsis)),
|
|
DropdownMenuItem(value: 'title', child: Text(tr('단어 첫 글자만 크게 (Abc)'), overflow: TextOverflow.ellipsis)),
|
|
],
|
|
onChanged: _running ? null : (v) => setState(() => _case = v!),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
|
SizedBox(
|
|
width: 200,
|
|
child: TextField(
|
|
controller: _find,
|
|
enabled: !_running,
|
|
decoration: InputDecoration(labelText: tr('찾을 글'), isDense: true),
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 200,
|
|
child: TextField(
|
|
controller: _replace,
|
|
enabled: !_running,
|
|
decoration: InputDecoration(labelText: tr('바꿀 글'), hintText: tr('비우면 지움'), isDense: true),
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Checkbox(value: _ignoreCase, onChanged: _running ? null : (v) => setState(() => _ignoreCase = v!)),
|
|
Flexible(child: Text(tr('대소문자 무시'))),
|
|
]),
|
|
]),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
_loading
|
|
? tr('폴더를 읽는 중…')
|
|
: bad > 0
|
|
? trf('문제 {0}개 - 고치기 전에는 바꾸지 않습니다', [bad])
|
|
: trf('바뀌는 것 {0}개', [changed]),
|
|
style: TextStyle(fontSize: 12, color: bad > 0 ? JjColors.danger : JjColors.textDim),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// 미리 보기 (창 전체가 스크롤 - 낮은 화면 · 큰 글자에서 아래가 넘쳤다, 253 시험)
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 280),
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: rows.length,
|
|
itemBuilder: (_, i) {
|
|
final r = rows[i];
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Expanded(
|
|
child: Text(r.oldName,
|
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
),
|
|
const Padding(padding: EdgeInsets.symmetric(horizontal: 6), child: Text('→', style: TextStyle(fontSize: 12))),
|
|
Expanded(
|
|
child: Text(
|
|
r.problem == null ? r.newName : '${r.newName} · ${r.problem}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: r.problem != null ? JjColors.danger : (r.changed ? JjColors.text : JjColors.textDim),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(_error!, style: TextStyle(fontSize: 12, color: JjColors.danger)),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: _running ? null : () => Navigator.pop(context), child: Text(tr('취소'))),
|
|
FilledButton(
|
|
onPressed: canRun ? () => _run(rows) : null,
|
|
child: _running
|
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: Text(trf('바꾸기 ({0}개)', [changed])),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|