diff --git a/assets/l10n/en.json b/assets/l10n/en.json index fcb8249..99acbbd 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -29,6 +29,7 @@ " 지금 설치됨 ({0}MB)": " Installed now ({0}MB)", "\"{0}\" 검색 결과 {1}개": "{1} results for \"{0}\"", "\"{0}\" 안에 만듭니다": "Created inside \"{0}\"", + "\"{0}\" 에 맞는 항목이 없습니다": "No items match \"{0}\"", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "These items are not in \"{0}\" but only in the target \"{1}\". Syncing with delete removes them from the target (cannot be undone).", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "Move \"{0}\" to the \"{1}\" folder?", "\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?", @@ -51,6 +52,7 @@ "(설명 없음)": "(no description)", "(지워짐)": "(deleted)", "(현재 방식은 옵션 없음)": "(no options for the built-in method)", + "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)", "+ 여기에 새 줄": "+ New line here", "-1 은 아무렇게나": "-1 = random", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", @@ -434,6 +436,8 @@ "같은 폴더입니다": "Same folder", "같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "The same items are already being copied/moved to the same place.", "같은 화질이면 H.264 먼저 받기": "Prefer H.264 at the same quality", + "같은 확장자 모두": "All with the same extension", + "같은 확장자 모두 (.{0})": "All with the same extension (.{0})", "개수": "Count", "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", @@ -463,6 +467,7 @@ "계속 보기를 멈췄습니다.": "Slideshow stopped.", "계속 편집": "Keep editing", "고르기": "Choose", + "고르기 도구": "Selection tools", "고르기 취소": "Unselect", "고르지 않음": "Not chosen", "고른 것 시작": "Start selected", @@ -871,6 +876,7 @@ "바탕 그림 고르기 (그림 → 그림)": "Choose a base picture (image → image)", "바탕 그림 빼기": "Remove base picture", "반대로 90° 회전": "Rotate 90° (counterclockwise)", + "반전 (고른 것 ↔ 안 고른 것)": "Invert (selected ↔ not selected)", "받기": "Download", "받기 ({0})": "Download ({0})", "받기 화면으로": "Go to downloads", @@ -1231,6 +1237,7 @@ "없음": "None", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "엔진": "Engine", + "여기까지 범위 고르기": "Select range up to here", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", "여러 개를 골랐을 때": "When several are selected", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)", @@ -1875,6 +1882,8 @@ "팝업 보기": "Pop-up view", "패키지 라이선스": "Package licenses", "패키지에 {0} 이 없습니다": "{0} is missing from the package", + "패턴으로 고르기": "Select by pattern", + "패턴으로 고르기 (예: *.srt)": "Select by pattern (e.g. *.srt)", "페르시아어": "Persian", "페이지 열기": "Open page", "페이지 지우기": "Delete page", @@ -1889,6 +1898,7 @@ "포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "Included components and licenses (THIRD_PARTY_NOTICES.txt)", "폴더": "Folder", "폴더 + 파일 목록": "Folders + file list", + "폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)", "폴더 고르기": "Choose folder", "폴더 만들기": "New folder", "폴더 복사 · 이동 방법": "Folder copy · move method", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0368377..85469f7 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -29,6 +29,7 @@ " 지금 설치됨 ({0}MB)": " インストール済み({0}MB)", "\"{0}\" 검색 결과 {1}개": "「{0}」の検索結果 {1}件", "\"{0}\" 안에 만듭니다": "「{0}」の中に作成します", + "\"{0}\" 에 맞는 항목이 없습니다": "「{0}」に合う項目はありません", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?", "\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?", @@ -51,6 +52,7 @@ "(설명 없음)": "(説明なし)", "(지워짐)": "(削除)", "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", + "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)", "+ 여기에 새 줄": "+ ここに新しい行", "-1 은 아무렇게나": "-1 はランダム", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", @@ -434,6 +436,8 @@ "같은 폴더입니다": "同じフォルダーです", "같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "同じ項目を同じ場所へコピー・移動中です。", "같은 화질이면 H.264 먼저 받기": "同じ画質なら H.264 を優先", + "같은 확장자 모두": "同じ拡張子をすべて", + "같은 확장자 모두 (.{0})": "同じ拡張子をすべて (.{0})", "개수": "枚数", "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", @@ -463,6 +467,7 @@ "계속 보기를 멈췄습니다.": "連続表示を停止しました。", "계속 편집": "編集を続ける", "고르기": "選ぶ", + "고르기 도구": "選択ツール", "고르기 취소": "選択を解除", "고르지 않음": "未選択", "고른 것 시작": "選んだものを開始", @@ -871,6 +876,7 @@ "바탕 그림 고르기 (그림 → 그림)": "元の画像を選択 (画像 → 画像)", "바탕 그림 빼기": "元の画像を外す", "반대로 90° 회전": "反対に 90° 回転", + "반전 (고른 것 ↔ 안 고른 것)": "反転 (選択 ↔ 未選択)", "받기": "ダウンロード", "받기 ({0})": "ダウンロード ({0})", "받기 화면으로": "ダウンロード画面へ", @@ -1231,6 +1237,7 @@ "없음": "なし", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "엔진": "エンジン", + "여기까지 범위 고르기": "ここまで範囲選択", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", "여러 개를 골랐을 때": "複数選んだとき", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", @@ -1875,6 +1882,8 @@ "팝업 보기": "ポップアップ表示", "패키지 라이선스": "パッケージのライセンス", "패키지에 {0} 이 없습니다": "パッケージに {0} がありません", + "패턴으로 고르기": "パターンで選択", + "패턴으로 고르기 (예: *.srt)": "パターンで選択 (例: *.srt)", "페르시아어": "ペルシャ語", "페이지 열기": "ページを開く", "페이지 지우기": "ページを削除", @@ -1889,6 +1898,7 @@ "포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "含まれるコンポーネントとライセンス(THIRD_PARTY_NOTICES.txt)", "폴더": "フォルダー", "폴더 + 파일 목록": "フォルダー + ファイル一覧", + "폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)", "폴더 고르기": "フォルダーを選択", "폴더 만들기": "フォルダーを作成", "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 00a03a3..742efda 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -29,6 +29,7 @@ " 지금 설치됨 ({0}MB)": " 已安装({0}MB)", "\"{0}\" 검색 결과 {1}개": "“{0}”的搜索结果 {1} 个", "\"{0}\" 안에 만듭니다": "在“{0}”中创建", + "\"{0}\" 에 맞는 항목이 없습니다": "没有与“{0}”匹配的项目", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?", "\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?", @@ -51,6 +52,7 @@ "(설명 없음)": "(无说明)", "(지워짐)": "(将删除)", "(현재 방식은 옵션 없음)": "(当前方式没有选项)", + "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)", "+ 여기에 새 줄": "+ 在此新增一行", "-1 은 아무렇게나": "-1 为随机", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", @@ -434,6 +436,8 @@ "같은 폴더입니다": "同一文件夹", "같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "正在将相同项目复制 · 移动到同一位置。", "같은 화질이면 H.264 먼저 받기": "相同画质时优先 H.264", + "같은 확장자 모두": "所有相同扩展名", + "같은 확장자 모두 (.{0})": "所有相同扩展名(.{0})", "개수": "数量", "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", @@ -463,6 +467,7 @@ "계속 보기를 멈췄습니다.": "已停止连续浏览。", "계속 편집": "继续编辑", "고르기": "选择", + "고르기 도구": "选择工具", "고르기 취소": "取消选择", "고르지 않음": "未选择", "고른 것 시작": "开始所选", @@ -871,6 +876,7 @@ "바탕 그림 고르기 (그림 → 그림)": "选择基础图片 (图片 → 图片)", "바탕 그림 빼기": "移除基础图片", "반대로 90° 회전": "逆时针旋转 90°", + "반전 (고른 것 ↔ 안 고른 것)": "反选(已选 ↔ 未选)", "받기": "下载", "받기 ({0})": "下载 ({0})", "받기 화면으로": "前往下载画面", @@ -1231,6 +1237,7 @@ "없음": "无", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "엔진": "引擎", + "여기까지 범위 고르기": "选择到此处的范围", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", "여러 개를 골랐을 때": "选择多个时", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", @@ -1875,6 +1882,8 @@ "팝업 보기": "弹出窗口观看", "패키지 라이선스": "软件包许可", "패키지에 {0} 이 없습니다": "包中缺少 {0}", + "패턴으로 고르기": "按模式选择", + "패턴으로 고르기 (예: *.srt)": "按模式选择(例:*.srt)", "페르시아어": "波斯语", "페이지 열기": "打开页面", "페이지 지우기": "删除页面", @@ -1889,6 +1898,7 @@ "포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "包含的组件与许可(THIRD_PARTY_NOTICES.txt)", "폴더": "文件夹", "폴더 + 파일 목록": "文件夹 + 文件列表", + "폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)", "폴더 고르기": "选择文件夹", "폴더 만들기": "新建文件夹", "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b721509..7ecc02b 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -174,6 +174,9 @@ class _Pane extends ChangeNotifier { final errors = {}; final marked = {}; + /// 74: 범위 고르기의 시작 (마지막으로 표시한 것) + String? anchor; + /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) final scroll = ScrollController(); @@ -789,6 +792,12 @@ class _ExplorerPageState extends State { await _pick(pane, e); return; } + // 74: Shift + 누르기 = 마지막으로 표시한 것부터 여기까지 (PC) + if (!row.isRoot && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) { + if (!_selecting) setState(() => _selecting = true); + _markRange(pane, e); + return; + } if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) { if (!_selecting) setState(() => _selecting = true); await _toggleMark(pane, e); @@ -825,6 +834,50 @@ class _ExplorerPageState extends State { } } + /// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목 + bool _canRange(_Pane pane, FileEntry e) { + final a = pane.anchor; + return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a); + } + + /// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시 + void _markRange(_Pane pane, FileEntry to) { + final list = pane.cache[vDirname(to.path)] ?? const []; + final a = list.indexWhere((x) => samePath(x.path, pane.anchor!)); + final b = list.indexWhere((x) => samePath(x.path, to.path)); + if (a < 0 || b < 0) return; + setState(() { + for (var i = a < b ? a : b; i <= (a < b ? b : a); i++) { + pane.marked.add(list[i].path); + } + _normalizeMarks(pane); + pane.anchor = to.path; + pane.focused = to.path; + }); + } + + /// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서 + void _invertMarks(_Pane pane, String dir) => setState(() { + for (final x in pane.cache[dir] ?? const []) { + if (!pane.marked.remove(x.path)) pane.marked.add(x.path); + } + _normalizeMarks(pane); + }); + + void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => setState(() { + pane.marked.addAll([for (final x in pane.cache[dir] ?? const []) if (test(x)) x.path]); + _normalizeMarks(pane); + }); + + Future _markPattern(_Pane pane, String dir) async { + final pattern = await showDialog(context: context, builder: (_) => const _PatternDialog()); + if (pattern == null || pattern.trim().isEmpty) return; + final re = globToRegExp(pattern); + final before = pane.marked.length; + _markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); + if (pane.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); + } + /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) void _normalizeMarks(_Pane pane) { final all = pane.marked.toList(); @@ -841,6 +894,7 @@ class _ExplorerPageState extends State { setState(() { pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로 pane.marked.add(e.path); + pane.anchor = e.path; }); if (e.isDir) { await _load(pane, e.path); @@ -1084,6 +1138,8 @@ class _ExplorerPageState extends State { ('info', Icons.info_outline, tr('정보')), ] : <(String, IconData, String)>[ + // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 + if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), @@ -1146,6 +1202,8 @@ class _ExplorerPageState extends State { if (local != null && !await c.services.shell.openWith(local)) _snack(_noAppMessage(e.path)); case 'addMkv': await _addMkv([e.path]); + case 'range': + _markRange(pane, e); case 'select': setState(() => _selecting = true); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); @@ -2662,6 +2720,7 @@ class _ExplorerPageState extends State { selected: (b) => (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting), twoPanes: dual || split, + pinned: {ExplorerButton.orient, if (_clip.isNotEmpty) ExplorerButton.paste}, ); final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle'); final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge'; @@ -2706,14 +2765,68 @@ class _ExplorerPageState extends State { } /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 - Widget _marksBar(_Pane pane, {String? listDir}) => Container( + Widget _marksBar(_Pane pane, {String? listDir}) { + final dir = listDir ?? pane.current; + // 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) + var size = 0, folders = 0; + final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x}; + for (final m in pane.marked) { + final x = byPath[m]; + if (x == null) continue; + if (x.isDir) { + folders++; + } else { + size += x.size; + } + } + final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!); + return Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row(children: [ Icon(Icons.checklist, size: 18, color: JjColors.accent), const SizedBox(width: 6), - Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)), + Flexible( + child: Text( + [ + trf('{0}개 표시함', [pane.marked.length]), + if (size > 0) formatSize(size), + if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), const Spacer(), + PopupMenuButton( + tooltip: tr('고르기 도구'), + onSelected: (v) { + switch (v) { + case 'invert': + _invertMarks(pane, dir); + case 'pattern': + _markPattern(pane, dir); + case 'sameExt': + _markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))), + PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))), + PopupMenuItem( + value: 'sameExt', + enabled: focusedExt.isNotEmpty, + child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])), + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(tr('고르기 도구'), style: const TextStyle(fontSize: 13, color: JjColors.accent)), + const Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), + ]), + ), + ), TextButton( onPressed: () => setState(() { final dir = listDir ?? pane.current; @@ -2726,6 +2839,7 @@ class _ExplorerPageState extends State { TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), ]), ); + } /// 트리 창 (저장 장치 고르기 + 트리) Widget _paneView(int i, {bool foldersOnly = false}) { @@ -3128,13 +3242,17 @@ class _Toolbar extends StatelessWidget { /// 창이 둘인지 (두 창 · 폴더 + 파일 목록). 하나면 [orient] 는 "두 창으로" final bool twoPanes; + + /// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기) + final Set pinned; const _Toolbar( {required this.buttons, required this.vertical, required this.onPressed, required this.enabled, required this.selected, - this.twoPanes = true}); + this.twoPanes = true, + this.pinned = const {ExplorerButton.orient}}); /// 118: 지금 배치를 보여 준다 (누르면 반대로) String _orientLabel() => !twoPanes ? '두 창으로' : vertical ? '좌우 ⇆' : '위아래 ⇅'; @@ -3250,10 +3368,14 @@ class _Toolbar extends StatelessWidget { if (fit >= 1) { shown = buttons.take(fit).toList(); rest = buttons.skip(fit).toList(); - // 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다 - if (rest.contains(ExplorerButton.orient)) { - rest = [shown.removeLast(), ...rest.where((b) => b != ExplorerButton.orient)]; - shown = [...shown, ExplorerButton.orient]; + // 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다. + // 73 (관리자): 복사 · 이동으로 담아 둔 것이 있으면 [붙여넣기] 도 (담아 두고 붙여넣기를 찾지 못하는 일이 없게) + for (final b in buttons.where((b) => pinned.contains(b) && rest.contains(b))) { + final i = shown.lastIndexWhere((x) => !pinned.contains(x)); + if (i < 0) break; + final out = shown[i]; + shown = [...shown.sublist(0, i), ...shown.sublist(i + 1), b]; + rest = [out, ...rest.where((x) => x != b)]; } } } @@ -3482,6 +3604,46 @@ class _Thumb extends StatelessWidget { } /// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다" +/// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시) +RegExp globToRegExp(String pattern) { + final parts = pattern.split(RegExp(r'[;,]')).map((x) => x.trim()).where((x) => x.isNotEmpty); + String one(String g) => RegExp.escape(g).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); + return RegExp('^(?:${parts.map(one).join('|')})\$', caseSensitive: false); +} + +/// 74: 패턴 넣기 +class _PatternDialog extends StatefulWidget { + const _PatternDialog(); + + @override + State<_PatternDialog> createState() => _PatternDialogState(); +} + +class _PatternDialogState extends State<_PatternDialog> { + final _ctl = TextEditingController(text: '*.srt'); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(tr('패턴으로 고르기')), + content: TextField( + controller: _ctl, + autofocus: true, + decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')), + onSubmitted: (v) => Navigator.pop(context, v), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('고르기'))), + ], + ); +} + class _PathDialog extends StatefulWidget { const _PathDialog({required this.initial}); final String initial; diff --git a/test/explorer_test.dart b/test/explorer_test.dart index be965d1..fc52dff 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; @@ -164,14 +165,9 @@ void main() { await tester.pumpAndSettle(); await act(tester, () => tester.tap(find.text('..'))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - // 줄에 자리가 모자라면 [더 보기] 안에 있다 (버튼이 늘면 - 73 즐겨찾기) - if (find.text('붙여넣기').evaluate().isEmpty) { - // 아래 버튼 줄을 가리는 "담았습니다" 알림을 먼저 닫는다 - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await tester.pumpAndSettle(); - await tester.tap(find.text('더 보기')); - await tester.pumpAndSettle(); - } + // 73 (관리자): 담아 둔 것이 있으면 [붙여넣기] 는 좁아도 [더 보기] 로 가지 않고 줄에 남는다 + expect(find.text('더 보기'), findsOneWidget, reason: '이 너비에서는 줄이 넘친다'); + expect(find.text('붙여넣기'), findsOneWidget); await act(tester, () => tester.tap(find.text('붙여넣기'))); await tester.pumpAndSettle(); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); @@ -201,9 +197,9 @@ void main() { expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 동그라미가 보인다 // 파일: 누르면 선택, 다시 누르면 취소 (실행하지 않음) await act(tester, () => tester.tap(find.text('doc.txt'))); - expect(find.text('1개 표시함'), findsOneWidget); + expect(find.textContaining('1개 표시함'), findsOneWidget); await act(tester, () => tester.tap(find.text('doc.txt'))); - expect(find.text('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) + expect(find.textContaining('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) expect(shell.opened, isEmpty); IconData markIcon(String name) => (tester @@ -216,7 +212,7 @@ void main() { // 폴더: 누르면 자기 자신과 안의 것 모두 선택 (펼쳐서 ✔ 로 보여 줌) await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('1개 표시함'), findsOneWidget); // 폴더 하나로 안의 것까지 + expect(find.textContaining('1개 표시함'), findsOneWidget); // 폴더 하나로 안의 것까지 expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle)); // 안의 것 하나를 누르면 그것만 빠지고 나머지는 고른 채로 await act(tester, () => tester.tap(find.text('inner.txt'))); @@ -227,11 +223,11 @@ void main() { // 폴더를 다시 누르면 다시 전체 선택, 한 번 더 누르면 자기 자신과 안의 것 모두 취소 await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => false, rounds: 5); - expect(find.text('1개 표시함'), findsOneWidget); + expect(find.textContaining('1개 표시함'), findsOneWidget); expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle)); await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => false, rounds: 5); - expect(find.text('0개 표시함'), findsNWidgets(2)); + expect(find.textContaining('0개 표시함'), findsNWidgets(2)); expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle_outline)); // [선택 끝] → 동그라미가 사라진다 await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 @@ -247,7 +243,7 @@ void main() { await act(tester, () => tester.tap(markOf('doc.txt'))); await act(tester, () => tester.tap(markOf('locked.txt'))); await act(tester, () => tester.tap(markOf('sub'))); - await settle(tester, () => find.text('3개 표시함').evaluate().isNotEmpty); + await settle(tester, () => find.textContaining('3개 표시함').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.text('삭제').first)); await tester.pumpAndSettle(); await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); @@ -326,6 +322,67 @@ void main() { expect(c.settings.rsyncPaths, [right, left]); }); + testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async { + for (final n in ['a.srt', 'b.srt', 'c.mkv']) { + File(p.join(left, n)).writeAsStringSync('12345678'); // 8B + } + await open(tester); + await settle(tester, () => find.text('c.mkv').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('a.srt'))); + await settle(tester, () => find.textContaining('1개 표시함').evaluate().isNotEmpty); + expect(find.text('1개 표시함 · 8B'), findsOneWidget); + + Future tool(String label) async { + await tester.tap(find.text('고르기 도구').first); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining(label).last); + await tester.pumpAndSettle(); + } + + // 같은 확장자 모두 (.srt) - 마지막으로 누른 a.srt 기준 + await tool('같은 확장자 모두'); + expect(find.text('2개 표시함 · 16B'), findsOneWidget); + // 반전: 이 폴더의 나머지 (doc.txt · c.mkv · sub) + await tool('반전'); + expect(find.textContaining('3개 표시함'), findsOneWidget); + expect(find.textContaining('폴더 1개 (크기 빼고)'), findsOneWidget); + // 패턴 + await tester.tap(find.text('표시 지우기').first); + await tester.pump(); + await tool('패턴으로 고르기'); + await tester.enterText(find.byType(TextField).last, '*.MKV; doc.*'); + await tester.tap(find.widgetWithText(FilledButton, '고르기')); + await tester.pumpAndSettle(); + expect(find.text('2개 표시함 · 13B'), findsOneWidget); + + // 범위: a.srt 를 표시한 뒤 Shift + c.mkv → a.srt · b.srt · c.mkv (보이는 순서) + await tester.tap(find.text('표시 지우기').first); + await tester.pump(); + await act(tester, () => tester.tap(markOf('a.srt'))); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await act(tester, () => tester.tap(find.text('c.mkv').first)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(find.text('3개 표시함 · 24B'), findsOneWidget); + + // 폰: 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 [여기까지 범위 고르기] + await tester.tap(find.text('표시 지우기').first); + await tester.pump(); + await act(tester, () => tester.tap(markOf('b.srt'))); + await tester.longPress(find.text('doc.txt').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('여기까지 범위 고르기')); + await tester.pumpAndSettle(); + expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt'); + }); + + test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () { + final re = globToRegExp('*.srt; ep??.mkv'); + expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue); + expect(['a.srt.bak', 'ep1.mkv', 'x.ass'].any(re.hasMatch), isFalse); + }); + test('73: 즐겨찾기 저장 · 읽기 (Rsync 짝 포함)', () { final s = AppSettings() ..folderBookmarks = const [FolderBookmark(name: 'NAS/영화', path: r'\\nas\movies'), FolderBookmark(name: '백업', path: r'D:\a', right: r'E:\b')]; @@ -340,8 +397,8 @@ void main() { await act(tester, () => tester.tap(markOf('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); // 폴더를 고르면 펼쳐서 안의 것도 ✔ await act(tester, () => tester.tap(markOf('doc.txt'))); - await settle(tester, () => find.text('2개 표시함').evaluate().isNotEmpty); - expect(find.text('2개 표시함'), findsOneWidget); + await settle(tester, () => find.textContaining('2개 표시함').evaluate().isNotEmpty); + expect(find.textContaining('2개 표시함'), findsOneWidget); await act(tester, () => tester.tap(find.text('삭제').first)); await tester.pumpAndSettle(); // 지울 항목 이름이 확인 창에 보인다. 65: Windows 는 기본이 휴지통 (시험에서는 영구 삭제를 골라 휴지통을 어지럽히지 않음) diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index 3e8e502..5cbb9b8 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/secret_gate.dart'; import 'package:jj_mkvmaker/core/webdav.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; @@ -147,6 +149,39 @@ void main() { expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue, reason: '체크를 끄면 원본은 그대로 (복사)'); }); + testWidgets('73: WebDAV 폴더도 즐겨찾기로 연다 - 비밀번호가 있는 서버는 마스터 문 (124) 을 지난다', (tester) async { + final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); + var asked = 0, allow = false; + SecretGate.check = (force) async { + asked++; + return allow; + }; + addTearDown(() => SecretGate.check = null); + c.settings + ..webdavServers = [s] + ..explorerPaths = [left, left] + ..folderBookmarks = [const FolderBookmark(name: 'NAS 원격', path: 'dav://nas/원격')]; + DavRegistry.configure([s]); + await pump(tester, ExplorerPage(c: c)); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + Future openBookmark() async { + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.tap(find.text('NAS 원격'))); + } + + // 마스터 문을 지나지 못하면 열지 않고 알린다 (저장된 비밀번호를 쓰지 않음) + await openBookmark(); + await settle(tester, () => find.textContaining('마스터 비밀번호가 필요합니다').evaluate().isNotEmpty, rounds: 200); + expect(asked, greaterThan(0)); + expect(find.text('note.txt'), findsNothing); + // 지나면 그 폴더 목록 + allow = true; + await openBookmark(); + await settle(tester, () => find.text('note.txt').evaluate().isNotEmpty, rounds: 300); + expect(find.text('note.txt'), findsOneWidget); + }); + testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c.settings