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
parent
78486be27a
commit
e512d75d57
|
|
@ -402,6 +402,9 @@ class AppSettings {
|
|||
/// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다
|
||||
bool explorerRetapCollapse = false;
|
||||
|
||||
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
|
||||
Map<String, dynamic> batchRenameLast = {};
|
||||
|
||||
/// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께).
|
||||
/// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로)
|
||||
bool explorerRootsInTree = true;
|
||||
|
|
@ -772,6 +775,7 @@ class AppSettings {
|
|||
'explorerLayout': explorerLayout,
|
||||
'explorerOnePaneCopy': explorerOnePaneCopy,
|
||||
'explorerRetapCollapse': explorerRetapCollapse,
|
||||
'batchRenameLast': batchRenameLast,
|
||||
'explorerRootsInTree': explorerRootsInTree,
|
||||
'explorerAlwaysMarks': explorerAlwaysMarks,
|
||||
'explorerFolderMarkRule': explorerFolderMarkRule,
|
||||
|
|
@ -988,6 +992,7 @@ class AppSettings {
|
|||
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
|
||||
..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip'
|
||||
..explorerRetapCollapse = j['explorerRetapCollapse'] == true
|
||||
..batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {}
|
||||
..explorerRootsInTree = j['explorerRootsInTree'] != false
|
||||
..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false
|
||||
..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree'
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import 'file_ops.dart';
|
|||
import 'vfs.dart';
|
||||
|
||||
/// 78: 일괄 이름 바꾸기 규칙 (Total Commander · X-plore 참고).
|
||||
/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다.
|
||||
/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다.
|
||||
class RenameRule {
|
||||
/// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호. 예: "Show S01E[C]"
|
||||
/// 이름 틀 (확장자 뺀 이름). [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;
|
||||
|
||||
|
|
@ -29,18 +32,60 @@ class RenameRule {
|
|||
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',
|
||||
);
|
||||
}
|
||||
|
||||
/// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지)
|
||||
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)
|
||||
String apply(String name, {required bool isDir, required int index}) {
|
||||
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸)
|
||||
String apply(String name, {required bool isDir, required int index, DateTime? date}) {
|
||||
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);
|
||||
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) {
|
||||
out = out.replaceAll(RegExp(RegExp.escape(find), caseSensitive: !ignoreCase), replace);
|
||||
}
|
||||
|
|
@ -70,10 +115,13 @@ class RenameRow {
|
|||
}
|
||||
|
||||
/// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고)
|
||||
/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸)
|
||||
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 는 대소문자를 가리지 않는다
|
||||
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>[];
|
||||
for (var i = 0; i < paths.length; i++) {
|
||||
final dir = vDirname(paths[i]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/batch_rename.dart';
|
||||
import '../core/photo_date.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
|
@ -9,31 +10,41 @@ import 'theme.dart';
|
|||
/// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과.
|
||||
/// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 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)>>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir),
|
||||
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir, initial: initial, onRun: onRun),
|
||||
);
|
||||
|
||||
class _BatchRenameDialog extends StatefulWidget {
|
||||
final List<String> paths;
|
||||
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
|
||||
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';
|
||||
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;
|
||||
|
||||
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
|
||||
final _siblings = <String, Set<String>>{};
|
||||
|
|
@ -50,7 +61,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
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)};
|
||||
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] = {};
|
||||
}
|
||||
|
|
@ -58,6 +73,20 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
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]) {
|
||||
|
|
@ -75,6 +104,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
replace: _replace.text,
|
||||
ignoreCase: _ignoreCase,
|
||||
caseMode: _case,
|
||||
dateSource: _dateSource,
|
||||
);
|
||||
|
||||
void _insert(String tag) {
|
||||
|
|
@ -92,6 +122,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
_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);
|
||||
|
|
@ -127,10 +158,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 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(
|
||||
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
|
||||
// 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게
|
||||
|
|
@ -147,8 +179,8 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
decoration: InputDecoration(
|
||||
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에
|
||||
labelText: tr('이름 틀'),
|
||||
helperText: tr('[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]'),
|
||||
helperMaxLines: 3,
|
||||
helperText: tr('[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]'),
|
||||
helperMaxLines: 4,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
|
|
@ -159,6 +191,25 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
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,
|
||||
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),
|
||||
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
|
||||
_num(_start, tr('번호 시작')),
|
||||
|
|
|
|||
|
|
@ -2248,7 +2248,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final d = vDirname(a).compareTo(vDirname(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;
|
||||
await _afterBatchRename(pane, done);
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue