diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 0fdb828..d472892 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -402,6 +402,9 @@ class AppSettings { /// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다 bool explorerRetapCollapse = false; + /// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값) + Map 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.from(j['batchRenameLast'] as Map) : {} ..explorerRootsInTree = j['explorerRootsInTree'] != false ..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false ..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree' diff --git a/lib/core/batch_rename.dart b/lib/core/batch_rename.dart index 1fedd30..44b07c9 100644 --- a/lib/core/batch_rename.dart +++ b/lib/core/batch_rename.dart @@ -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 toJson() => { + 'template': template, + 'start': start, + 'step': step, + 'digits': digits, + 'find': find, + 'replace': replace, + 'ignoreCase': ignoreCase, + 'caseMode': caseMode, + 'dateSource': dateSource, + }; + + /// 틀리거나 없는 값은 처음 값으로 + factory RenameRule.fromJson(Map 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 planRename(List paths, RenameRule rule, - {required bool Function(String path) isDir, required Map> siblings}) { + {required bool Function(String path) isDir, required Map> siblings, Map 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 = []; for (var i = 0; i < paths.length; i++) { final dir = vDirname(paths[i]); diff --git a/lib/core/photo_date.dart b/lib/core/photo_date.dart new file mode 100644 index 0000000..52c6065 --- /dev/null +++ b/lib/core/photo_date.dart @@ -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 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]); +} diff --git a/lib/ui/batch_rename_dialog.dart b/lib/ui/batch_rename_dialog.dart index e55b980..7628042 100644 --- a/lib/ui/batch_rename_dialog.dart +++ b/lib/ui/batch_rename_dialog.dart @@ -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?> showBatchRename(BuildContext context, List paths, {required bool Function(String) isDir}) => +/// X-plore 비교 i: [initial] 은 마지막에 쓴 규칙 (창이 그 값으로 열림), [onRun] 은 바꾸기를 누를 때의 규칙 (기억하려고) +Future?> showBatchRename(BuildContext context, List paths, + {required bool Function(String) isDir, RenameRule initial = const RenameRule(), void Function(RenameRule rule)? onRun}) => showDialog>( 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 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 = {}; + final _taken = {}; + bool _takenLoading = false; /// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음) final _siblings = >{}; @@ -50,7 +61,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { Future _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 _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 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( + 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('번호 시작')), diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index c92d8fc..06f9c34 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2248,7 +2248,10 @@ class _ExplorerPageState extends State { 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; diff --git a/test/batch_rename_date_test.dart b/test/batch_rename_date_test.dart new file mode 100644 index 0000000..3fc95b1 --- /dev/null +++ b/test/batch_rename_date_test.dart @@ -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(); + 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(find.widgetWithText(FilledButton, '바꾸기 (1개)')).onPressed != null; + for (var i = 0; i < 60 && !ready(); i++) { + await tester.runAsync(() => Future.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(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.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]'); + }); +}