262 lines
10 KiB
Dart
262 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../core/batch_rename.dart';
|
|
import '../core/vfs.dart';
|
|
import '../l10n/tr.dart';
|
|
import 'theme.dart';
|
|
|
|
/// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과.
|
|
/// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null.
|
|
/// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다.
|
|
Future<List<(String, String)>?> showBatchRename(BuildContext context, List<String> paths, {required bool Function(String) isDir}) =>
|
|
showDialog<List<(String, String)>>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir),
|
|
);
|
|
|
|
class _BatchRenameDialog extends StatefulWidget {
|
|
final List<String> paths;
|
|
final bool Function(String) isDir;
|
|
const _BatchRenameDialog({required this.paths, required this.isDir});
|
|
|
|
@override
|
|
State<_BatchRenameDialog> createState() => _BatchRenameDialogState();
|
|
}
|
|
|
|
class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|
final _template = TextEditingController(text: '[N]');
|
|
final _start = TextEditingController(text: '1');
|
|
final _step = TextEditingController(text: '1');
|
|
final _digits = TextEditingController(text: '2');
|
|
final _find = TextEditingController();
|
|
final _replace = TextEditingController();
|
|
bool _ignoreCase = true;
|
|
String _case = 'keep';
|
|
|
|
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
|
|
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 {
|
|
_siblings[dir] = {for (final e in await vList(dir)) vBasename(e.path)};
|
|
} catch (_) {
|
|
_siblings[dir] = {};
|
|
}
|
|
}
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
|
|
@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,
|
|
);
|
|
|
|
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;
|
|
});
|
|
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;
|
|
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings);
|
|
final changed = rows.where((r) => r.changed).length;
|
|
final bad = rows.where((r) => r.problem != null).length;
|
|
final canRun = !_loading && !_running && bad == 0 && changed > 0;
|
|
return AlertDialog(
|
|
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
|
|
content: SizedBox(
|
|
width: 640,
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
// 이름 틀 + [N] [C] 넣기
|
|
Row(children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _template,
|
|
enabled: !_running,
|
|
decoration: InputDecoration(
|
|
labelText: tr('이름 틀 (확장자는 그대로)'),
|
|
helperText: tr('[N] 원래 이름 · [C] 번호 예: Show S01E[C]'),
|
|
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]')),
|
|
]),
|
|
const SizedBox(height: 6),
|
|
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
|
|
_num(_start, tr('번호 시작')),
|
|
_num(_step, tr('간격')),
|
|
_num(_digits, tr('자릿수')),
|
|
DropdownButton<String>(
|
|
value: _case,
|
|
items: [
|
|
DropdownMenuItem(value: 'keep', child: Text(tr('대소문자 그대로'))),
|
|
DropdownMenuItem(value: 'lower', child: Text(tr('모두 작게 (abc)'))),
|
|
DropdownMenuItem(value: 'upper', child: Text(tr('모두 크게 (ABC)'))),
|
|
DropdownMenuItem(value: 'title', child: Text(tr('단어 첫 글자만 크게 (Abc)'))),
|
|
],
|
|
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('바꿀 글 (비우면 지움)'), isDense: true),
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Checkbox(value: _ignoreCase, onChanged: _running ? null : (v) => setState(() => _ignoreCase = v!)),
|
|
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),
|
|
// 미리 보기
|
|
Flexible(
|
|
child: 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: const 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: const 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])),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|