WIP X-plore i: 일괄 이름 바꾸기에 날짜 · 시각 ([D] · [T], 사진은 찍은 때) · 마지막 규칙 기억 (검토 대기)

- [D] = 2026-10-18, [T] = 13-05-09 (Windows 는 ':' 를 못 써서 '-'). 날짜는 고름: 찍은 때 (JPEG Exif DateTimeOriginal →
  DateTimeDigitized → DateTime, 없으면 바뀐 때, 처음 값) / 바뀐 때. Exif 는 파일 앞 256KB 만 읽음 (image 패키지, 새 의존성 없음).
- 찍은 때를 다 읽기 전에는 [바꾸기] 를 누를 수 없음 (미리 보기와 실제가 다르지 않게).
- 마지막에 [바꾸기] 한 규칙을 설정 batchRenameLast 에 기억해 다음에 그 값으로 창이 열림. 틀리거나 없는 값은 처음 값.
- 내 판단 (캡처에서 발견): 412 폭에서 날짜 고르기가 창을 넘어 [D] [T] 옆 남은 폭에 맞춤, 이름 틀 설명은 4줄까지.
- 시험: batch_rename_date_test (토큰 · 기억 · Exif · 창), batch_rename_test 그대로 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore
RyuJongJin 2026-10-18 00:55:28 +09:00
parent 78486be27a
commit e512d75d57
6 changed files with 249 additions and 24 deletions

View File

@ -402,6 +402,9 @@ class AppSettings {
/// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다 /// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다
bool explorerRetapCollapse = false; bool explorerRetapCollapse = false;
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
Map<String, dynamic> batchRenameLast = {};
/// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께). /// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께).
/// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로) /// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로)
bool explorerRootsInTree = true; bool explorerRootsInTree = true;
@ -772,6 +775,7 @@ class AppSettings {
'explorerLayout': explorerLayout, 'explorerLayout': explorerLayout,
'explorerOnePaneCopy': explorerOnePaneCopy, 'explorerOnePaneCopy': explorerOnePaneCopy,
'explorerRetapCollapse': explorerRetapCollapse, 'explorerRetapCollapse': explorerRetapCollapse,
'batchRenameLast': batchRenameLast,
'explorerRootsInTree': explorerRootsInTree, 'explorerRootsInTree': explorerRootsInTree,
'explorerAlwaysMarks': explorerAlwaysMarks, 'explorerAlwaysMarks': explorerAlwaysMarks,
'explorerFolderMarkRule': explorerFolderMarkRule, 'explorerFolderMarkRule': explorerFolderMarkRule,
@ -988,6 +992,7 @@ class AppSettings {
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로 // 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip' ..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip'
..explorerRetapCollapse = j['explorerRetapCollapse'] == true ..explorerRetapCollapse = j['explorerRetapCollapse'] == true
..batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {}
..explorerRootsInTree = j['explorerRootsInTree'] != false ..explorerRootsInTree = j['explorerRootsInTree'] != false
..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false ..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false
..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree' ..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree'

View File

@ -5,11 +5,14 @@ import 'file_ops.dart';
import 'vfs.dart'; import 'vfs.dart';
/// 78: 일괄 이름 바꾸기 규칙 (Total Commander · X-plore 참고). /// 78: 일괄 이름 바꾸기 규칙 (Total Commander · X-plore 참고).
/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다. /// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다.
class RenameRule { class RenameRule {
/// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호. 예: "Show S01E[C]" /// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호, [D] = 날짜 (2026-10-18), [T] = 시각 (13-05-09). 예: "Show S01E[C]"
final String template; final String template;
/// X-plore 비교 i: [D] · [T] 의 날짜 - 'photo' 찍은 때 (사진 Exif, 없으면 바뀐 때) / 'modified' 바뀐 때
final String dateSource;
/// 번호: 시작 · 간격 · 자릿수 (2 → 01, 02 …) /// 번호: 시작 · 간격 · 자릿수 (2 → 01, 02 …)
final int start, step, digits; final int start, step, digits;
@ -29,18 +32,60 @@ class RenameRule {
this.replace = '', this.replace = '',
this.ignoreCase = true, this.ignoreCase = true,
this.caseMode = 'keep', 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',
);
}
/// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지)
bool get usesDate => RegExp(r'\[[DT]\]', caseSensitive: false).hasMatch(template);
/// 바꾸는 것이 하나도 없는 규칙 /// 바꾸는 것이 하나도 없는 규칙
bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep'; bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep';
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step) /// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸)
String apply(String name, {required bool isDir, required int index}) { String apply(String name, {required bool isDir, required int index, DateTime? date}) {
final ext = isDir ? '' : p.extension(name); final ext = isDir ? '' : p.extension(name);
final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length); final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length);
final n = start + index * step; final n = start + index * step;
final counter = n < 0 ? '-${(-n).toString().padLeft(digits, '0')}' : n.toString().padLeft(digits, '0'); 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); 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)}';
var out = template
.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) { if (find.isNotEmpty) {
out = out.replaceAll(RegExp(RegExp.escape(find), caseSensitive: !ignoreCase), replace); out = out.replaceAll(RegExp(RegExp.escape(find), caseSensitive: !ignoreCase), replace);
} }
@ -70,10 +115,13 @@ class RenameRow {
} }
/// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고) /// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고)
/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸)
List<RenameRow> planRename(List<String> paths, RenameRule rule, List<RenameRow> planRename(List<String> paths, RenameRule rule,
{required bool Function(String path) isDir, required Map<String, Set<String>> siblings}) { {required bool Function(String path) isDir, required Map<String, Set<String>> siblings, Map<String, DateTime> dates = const {}}) {
bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); // Windows · 대부분의 NAS 는 대소문자를 가리지 않는다 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 names = [
for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i, date: dates[paths[i]])
];
final rows = <RenameRow>[]; final rows = <RenameRow>[];
for (var i = 0; i < paths.length; i++) { for (var i = 0; i < paths.length; i++) {
final dir = vDirname(paths[i]); final dir = vDirname(paths[i]);

43
lib/core/photo_date.dart Normal file
View File

@ -0,0 +1,43 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:image/image.dart' show decodeJpgExif;
/// X-plore 비교 i: 일괄 이름 바꾸기의 날짜 - 사진은 찍은 때 (JPEG Exif DateTimeOriginal), 없으면 null.
/// 파일 앞부분만 읽는다 (Exif 는 맨 앞 APP1, 64KB 를 넘지 않음).
Future<DateTime?> photoTakenAt(String path) async {
final ext = path.toLowerCase();
if (!(ext.endsWith('.jpg') || ext.endsWith('.jpeg'))) return null;
RandomAccessFile? f;
try {
f = await File(path).open();
final head = await f.read(256 * 1024);
return parseExifDate(_exifDate(head));
} catch (_) {
return null;
} finally {
await f?.close();
}
}
String? _exifDate(Uint8List head) {
try {
final exif = decodeJpgExif(head);
if (exif == null) return null;
// 찍은 때 → 디지털로 만든 때 → 바뀐 때 (ifd0 의 DateTime)
for (final v in [exif.exifIfd[0x9003], exif.exifIfd[0x9004], exif.imageIfd[0x0132]]) {
final s = v?.toString().trim();
if (s != null && s.isNotEmpty) return s;
}
} catch (_) {}
return null;
}
/// "2024:05:01 12:34:56" → DateTime (틀리거나 0 이면 null)
DateTime? parseExifDate(String? s) {
final m = RegExp(r'^(\d{4})[:\-](\d{2})[:\-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})').firstMatch(s ?? '');
if (m == null) return null;
final n = [for (var i = 1; i <= 6; i++) int.parse(m[i]!)];
if (n[0] < 1900 || n[1] < 1 || n[1] > 12 || n[2] < 1 || n[2] > 31) return null;
return DateTime(n[0], n[1], n[2], n[3], n[4], n[5]);
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../core/batch_rename.dart'; import '../core/batch_rename.dart';
import '../core/photo_date.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'theme.dart'; import 'theme.dart';
@ -9,31 +10,41 @@ import 'theme.dart';
/// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과. /// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과.
/// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null. /// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null.
/// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다. /// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다.
Future<List<(String, String)>?> showBatchRename(BuildContext context, List<String> paths, {required bool Function(String) isDir}) => /// 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)>>( showDialog<List<(String, String)>>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir), builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir, initial: initial, onRun: onRun),
); );
class _BatchRenameDialog extends StatefulWidget { class _BatchRenameDialog extends StatefulWidget {
final List<String> paths; final List<String> paths;
final bool Function(String) isDir; final bool Function(String) isDir;
const _BatchRenameDialog({required this.paths, required this.isDir}); final RenameRule initial;
final void Function(RenameRule rule)? onRun;
const _BatchRenameDialog({required this.paths, required this.isDir, required this.initial, this.onRun});
@override @override
State<_BatchRenameDialog> createState() => _BatchRenameDialogState(); State<_BatchRenameDialog> createState() => _BatchRenameDialogState();
} }
class _BatchRenameDialogState extends State<_BatchRenameDialog> { class _BatchRenameDialogState extends State<_BatchRenameDialog> {
final _template = TextEditingController(text: '[N]'); late final _template = TextEditingController(text: widget.initial.template);
final _start = TextEditingController(text: '1'); late final _start = TextEditingController(text: '${widget.initial.start}');
final _step = TextEditingController(text: '1'); late final _step = TextEditingController(text: '${widget.initial.step}');
final _digits = TextEditingController(text: '2'); late final _digits = TextEditingController(text: '${widget.initial.digits}');
final _find = TextEditingController(); late final _find = TextEditingController(text: widget.initial.find);
final _replace = TextEditingController(); late final _replace = TextEditingController(text: widget.initial.replace);
bool _ignoreCase = true; late bool _ignoreCase = widget.initial.ignoreCase;
String _case = 'keep'; 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;
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음) /// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
final _siblings = <String, Set<String>>{}; final _siblings = <String, Set<String>>{};
@ -50,7 +61,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
Future<void> _loadSiblings() async { Future<void> _loadSiblings() async {
for (final dir in {for (final x in widget.paths) vDirname(x)}) { for (final dir in {for (final x in widget.paths) vDirname(x)}) {
try { try {
_siblings[dir] = {for (final e in await vList(dir)) vBasename(e.path)}; 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 (_) { } catch (_) {
_siblings[dir] = {}; _siblings[dir] = {};
} }
@ -58,6 +73,20 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
if (mounted) setState(() => _loading = false); 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 @override
void dispose() { void dispose() {
for (final x in [_template, _start, _step, _digits, _find, _replace]) { for (final x in [_template, _start, _step, _digits, _find, _replace]) {
@ -75,6 +104,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
replace: _replace.text, replace: _replace.text,
ignoreCase: _ignoreCase, ignoreCase: _ignoreCase,
caseMode: _case, caseMode: _case,
dateSource: _dateSource,
); );
void _insert(String tag) { void _insert(String tag) {
@ -92,6 +122,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
_running = true; _running = true;
_error = null; _error = null;
}); });
widget.onRun?.call(_rule);
try { try {
final done = await runBatchRename([for (final r in rows) (r.path, r.newName)]); final done = await runBatchRename([for (final r in rows) (r.path, r.newName)]);
if (mounted) Navigator.pop(context, done); if (mounted) Navigator.pop(context, done);
@ -127,10 +158,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final rule = _rule; final rule = _rule;
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings); if (rule.usesDate && _dateSource == 'photo') _loadTaken();
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates);
final changed = rows.where((r) => r.changed).length; final changed = rows.where((r) => r.changed).length;
final bad = rows.where((r) => r.problem != null).length; final bad = rows.where((r) => r.problem != null).length;
final canRun = !_loading && !_running && bad == 0 && changed > 0; final canRun = !_loading && !_takenLoading && !_running && bad == 0 && changed > 0;
return AlertDialog( return AlertDialog(
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])), title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
// 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게 // 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게
@ -147,8 +179,8 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
decoration: InputDecoration( decoration: InputDecoration(
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 // 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에
labelText: tr('이름 틀'), labelText: tr('이름 틀'),
helperText: tr('[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]'), helperText: tr('[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]'),
helperMaxLines: 3, helperMaxLines: 4,
isDense: true, isDense: true,
), ),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
@ -159,6 +191,25 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
const SizedBox(width: 4), const SizedBox(width: 4),
OutlinedButton(onPressed: _running ? null : () => _insert('[C]'), child: const Text('[C]')), 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,
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!),
),
),
]),
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [ Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
_num(_start, tr('번호 시작')), _num(_start, tr('번호 시작')),

View File

@ -2248,7 +2248,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
final d = vDirname(a).compareTo(vDirname(b)); final d = vDirname(a).compareTo(vDirname(b));
return d != 0 ? d : at(a).compareTo(at(b)); return d != 0 ? d : at(a).compareTo(at(b));
}); });
final done = await showBatchRename(context, ordered, isDir: vIsDirSync); final done = await showBatchRename(context, ordered,
isDir: vIsDirSync,
initial: RenameRule.fromJson(c.settings.batchRenameLast),
onRun: (r) => c.updateSettings((s) => s.batchRenameLast = r.toJson()));
if (done == null || done.isEmpty || !mounted) return; if (done == null || done.isEmpty || !mounted) return;
await _afterBatchRename(pane, done); await _afterBatchRename(pane, done);
if (!mounted) return; if (!mounted) return;

View File

@ -0,0 +1,75 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/core/batch_rename.dart';
import 'package:jj_mkvmaker/core/photo_date.dart';
import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 i: 일괄 이름 바꾸기의 날짜 · 시각 ([D] · [T], 사진은 찍은 때) · 마지막 규칙 기억
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_rendate_'));
tearDown(() => tmp.deleteSync(recursive: true));
test('[D] · [T]: 날짜 2026-10-18 · 시각 13-05-09 (":" 는 못 써서 "-"), 날짜가 없으면 빈칸', () {
const r = RenameRule(template: '[D] [T] [N]');
expect(r.usesDate, isTrue);
expect(r.apply('a.jpg', isDir: false, index: 0, date: DateTime(2026, 10, 18, 13, 5, 9)), '2026-10-18 13-05-09 a.jpg');
expect(const RenameRule(template: '[d]_[C]').apply('a.jpg', isDir: false, index: 0, date: DateTime(2026, 1, 2)), '2026-01-02_01.jpg');
expect(const RenameRule(template: 'x[D]').apply('a.jpg', isDir: false, index: 0), 'x.jpg');
expect(const RenameRule().usesDate, isFalse);
});
test('규칙 기억: toJson → fromJson 그대로, 틀린 값은 처음 값으로', () {
const r = RenameRule(template: '[D]_[C]', start: 3, step: 2, digits: 4, find: 'a', replace: 'b', ignoreCase: false, caseMode: 'upper', dateSource: 'modified');
expect(RenameRule.fromJson(r.toJson()).toJson(), r.toJson());
final bad = RenameRule.fromJson({'template': '', 'digits': 99, 'caseMode': '??', 'dateSource': 1, 'start': 'x'});
expect((bad.template, bad.digits, bad.caseMode, bad.dateSource, bad.start), ('[N]', 9, 'keep', 'photo', 1));
});
test('사진의 찍은 때 (JPEG Exif DateTimeOriginal) · 없거나 JPEG 이 아니면 null', () async {
expect(parseExifDate('2024:05:01 12:34:56'), DateTime(2024, 5, 1, 12, 34, 56));
expect(parseExifDate('0000:00:00 00:00:00'), isNull);
final image = img.Image(width: 8, height: 8);
image.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56');
final withExif = File(p.join(tmp.path, 'a.jpg'))..writeAsBytesSync(img.encodeJpg(image));
expect(await photoTakenAt(withExif.path), DateTime(2024, 5, 1, 12, 34, 56));
final plain = File(p.join(tmp.path, 'b.jpg'))..writeAsBytesSync(img.encodeJpg(img.Image(width: 8, height: 8)));
expect(await photoTakenAt(plain.path), isNull);
expect(await photoTakenAt(p.join(tmp.path, 'none.png')), isNull);
});
testWidgets('창: 마지막 규칙으로 열리고, [바꾸기] 때의 규칙을 돌려준다 · [D] 는 사진의 찍은 때', (tester) async {
tester.view.physicalSize = const Size(1000, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final image = img.Image(width: 8, height: 8);
image.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56');
final photo = File(p.join(tmp.path, 'IMG_1.jpg'))..writeAsBytesSync(img.encodeJpg(image));
RenameRule? used;
final nav = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold()));
showBatchRename(nav.currentContext!, [photo.path],
isDir: (_) => false, initial: const RenameRule(template: '[D]_[N]'), onRun: (r) => used = r);
bool ready() =>
find.text('2024-05-01_IMG_1.jpg').evaluate().isNotEmpty &&
tester.widget<FilledButton>(find.widgetWithText(FilledButton, '바꾸기 (1개)')).onPressed != null;
for (var i = 0; i < 60 && !ready(); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
expect(find.widgetWithText(TextField, '[D]_[N]'), findsOneWidget, reason: '마지막 규칙으로 열림');
expect(find.text('2024-05-01_IMG_1.jpg'), findsOneWidget);
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '바꾸기 (1개)')).onPressed, isNotNull, reason: '찍은 때를 다 읽으면 켜짐');
await tester.tap(find.widgetWithText(FilledButton, '바꾸기 (1개)'));
for (var i = 0; i < 100 && !File(p.join(tmp.path, '2024-05-01_IMG_1.jpg')).existsSync(); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
expect(File(p.join(tmp.path, '2024-05-01_IMG_1.jpg')).existsSync(), isTrue);
expect(used?.template, '[D]_[N]');
});
}