WIP X-plore 비교 5-2 보관함 2단계: 폴더째 넣기 · 원격 파일 · 보관함 안 폴더 (검토 대기)

관리자 지시 10/21 (보관함 2단계):
- 폴더째 넣기: 안의 구조 · 빈 폴더 그대로, 폴더 이름도 암호화한 목록에만 (바깥 보관함 폴더에는 무작위 이름 파일뿐)
- 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 파일: 앱 전용 폴더 (jj_vault_view_in_…) 에 받아 넣고 받은 것은 바로 지움
- 보관함 안 새 폴더 · 파일 · 폴더 옮기기 · 폴더 이름 바꾸기 · 폴더째 꺼내기 · 폴더째 지우기 (확인 창에 파일 수), ← 는 위 폴더로
- [원본 지우기 (이동)] 는 다 들어간 것만 (폴더는 안의 것이 모두 들어갔을 때만, 읽지 못한 폴더는 반만 넣지 않음)
- 보관함 비밀번호 바꾸기는 1단계에 있음 (열쇠만 다시 감쌈)
- 버그 수정: 이름 묻는 창이 닫히는 중에 글자 칸을 버려 오류 → 창과 함께 버림
- 내 판단: 폴더 정보는 1단계 판과 맞게 - 맨 위 파일은 목록 모양 그대로, 빈 폴더 목록은 따로 (folders.jjd, 1단계의 정리가 지우지 않음).
  1단계 판으로 되돌려 쓰면 파일은 다 보이지만 폴더 구조는 그 판이 목록을 다시 쓸 때 풀린다 (내용은 잃지 않음)
- 내 판단: 넣는 폴더 이름이 지금 폴더에 있으면 합치지 않고 " (2)"
- 내 판단: 같은 폴더에 같은 이름 파일을 넣으면 " (2)" (전에는 겹친 이름이 그대로 둘)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3b
RyuJongJin 2026-10-21 13:17:39 +09:00
parent 8f6fcdd9ed
commit 8fa270a62e
9 changed files with 825 additions and 54 deletions

View File

@ -612,6 +612,8 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} didn't fit in the Recycle Bin and were deleted permanently (as chosen when Windows asked).",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".",
"{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}": "Exported {0}; {1} could not be exported because they were changed or damaged: {2}",
"{0}개를 넣었고 {1}개는 넣지 못했습니다 (그 원본은 지우지 않습니다)": "Added {0}; {1} could not be added (their originals are not deleted)",
"{0}개를 묶었습니다: {1}": "Compressed {0} files: {1}",
"{0}개를 보관함에 넣었습니다": "Added {0} to the vault",
"{0}개를 영구 삭제할까요?": "Delete {0} items permanently?",
@ -707,6 +709,7 @@
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
"같은 이름이 이미 있음": "Name already exists",
"같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …",
"같은 이름이 있어 \"{0}\" 로 옮겼습니다": "The same name exists, so it was moved as \"{0}\"",
"같은 이름이 있으면": "If the name exists",
"같은 이름이 있을 때": "When a file with the same name exists",
"같은 이름이라 건너뜀 {0}개": "{0} skipped (same name)",
@ -885,6 +888,7 @@
"길이": "Length",
"길이 {0}": "Length {0}",
"꺼내기": "Export",
"꺼내기 (폴더째)": "Export (whole folder)",
"꺼내지 못했습니다: {0}": "Could not export: {0}",
"꺼냈습니다: {0}": "Exported: {0}",
"끄기": "Off",
@ -963,9 +967,11 @@
"넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right",
"넘으면 오래 보지 않은 것부터 지웁니다": "When full, the least recently viewed are deleted first",
"넣기": "Add",
"넣기는 파일 탐색기에서 파일을 길게 눌러 [보관함에 넣기]": "To add, long-press a file in the file explorer and choose [Add to vault]",
"넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]": "To add, long-press files or folders in the file explorer and choose [Add to vault]",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP",
"넣은 날": "Date added",
"넣을 것을 세는 중…": "Counting items to add…",
"넣을 곳: {0}": "Destination: {0}",
"넣지 못했습니다: {0}": "Could not add: {0}",
"네덜란드어": "Dutch",
"네트워크 · DNS": "Network · DNS",
@ -1474,6 +1480,8 @@
"변환": "Convert",
"변환기 안에서 오류가 났습니다": "An error occurred inside the converter",
"보관한 설정으로 되살리고 다시 시작": "Restore kept settings and restart",
"보관함": "Vault",
"보관함 맨 위": "Top of the vault",
"보관함 비밀번호": "Vault password",
"보관함 비밀번호 바꾸기": "Change vault password",
"보관함 비밀번호를 바꿨습니다": "Vault password changed",
@ -2030,6 +2038,8 @@
"옮기기": "Move",
"옮기는 중": "Moving",
"옮길": "move",
"옮길 곳": "Move to",
"옮길 곳에 같은 이름이 이미 있습니다": "An item with the same name already exists at the destination",
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).",
"옮길까요?": "move?",
"옵션": "Options",
@ -2060,6 +2070,7 @@
"원격 썸네일": "Remote thumbnails",
"원격 썸네일 캐시": "Remote thumbnail cache",
"원격 썸네일 캐시 지우기": "Clear remote thumbnail cache",
"원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다": "Remote files are downloaded to this app's private folder on this device and added; the downloaded temporary files are deleted immediately",
"원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)",
@ -2233,6 +2244,7 @@
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "This folder pair uses its remembered options (edit them in Monitoring > Copy · rsync, not in settings)",
"이 폴더 전체": "This whole folder",
"이 폴더는 Android 가 막아 볼 수 없습니다": "Android blocks this folder, so it cannot be viewed",
"이 폴더는 비어 있습니다": "This folder is empty",
"이 폴더를 rsync 원본 · 대상으로 고르기": "Choose this folder as rsync source · target",
"이 폴더를 볼 권한이 없습니다": "No permission to view this folder",
"이 폴더를 읽을 권한이 없습니다": "No permission to read this folder",
@ -2349,6 +2361,7 @@
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "The original file that could not be read was not deleted; it is kept here:",
"읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} unreadable folders (permission or connection problem) - not searched inside",
"읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} folders couldn't be read (sizes may be smaller than actual)",
"읽지 못해 빼고 넣습니다: {0} ({1})": "Could not read, so it is left out: {0} ({1})",
"읽지 못했습니다": "Couldn't read",
"읽지 못했습니다: {0}": "Couldn't read: {0}",
"임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers",
@ -2599,6 +2612,7 @@
"지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "It is running now. Stop it and move to lsync (live sync)? lsync will sync the rest.",
"지금 쓰는 버전: v{0}": "Current version: v{0}",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
"지금 있는 곳": "Current location",
"지금 작업 취소: {0}": "Cancelled current job: {0}",
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
"지금 지우기 (확인)": "Delete now (confirm)",
@ -2814,6 +2828,7 @@
"파일 {0}개": "{0} files",
"파일 {0}개 · {1}": "{0} files · {1}",
"파일 {0}개 · 폴더 {1}개": "{0} files · {1} folders",
"파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)": "{0} files · {1} folders (folders keep their structure)",
"파일 관리자에서 보기": "Show in file manager",
"파일 복사 · 이동 방법": "File copy · move method",
"파일 세는 중…": "Counting files…",
@ -2870,6 +2885,7 @@
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "Included components and licenses (THIRD_PARTY_NOTICES.txt)",
"폰 세로 목록 크기": "List size (portrait phone)",
"폴더": "Folder",
"폴더 \"{0}\" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)": "Deletes the folder \"{0}\" and the {1} files inside from the vault. This cannot be undone (if you have not exported them first, the contents will be lost)",
"폴더 + 파일 목록": "Folders + file list",
"폴더 {0}개 (크기 미포함)": "{0} folders (size not included)",
"폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)",

View File

@ -612,6 +612,8 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。",
"{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}": "{0} 個を取り出しました。{1} 個は変更または破損のため取り出せませんでした: {2}",
"{0}개를 넣었고 {1}개는 넣지 못했습니다 (그 원본은 지우지 않습니다)": "{0} 個を入れました。{1} 個は入れられませんでした (その元ファイルは削除しません)",
"{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}",
"{0}개를 보관함에 넣었습니다": "{0} 件を保管庫に入れました",
"{0}개를 영구 삭제할까요?": "{0} 件を完全に削除しますか?",
@ -707,6 +709,7 @@
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
"같은 이름이 이미 있음": "同じ名前が既にあります",
"같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …",
"같은 이름이 있어 \"{0}\" 로 옮겼습니다": "同じ名前があるため \"{0}\" として移動しました",
"같은 이름이 있으면": "同じ名前がある場合",
"같은 이름이 있을 때": "同じ名前があるとき",
"같은 이름이라 건너뜀 {0}개": "同じ名前のためスキップ {0} 件",
@ -885,6 +888,7 @@
"길이": "長さ",
"길이 {0}": "長さ {0}",
"꺼내기": "取り出す",
"꺼내기 (폴더째)": "取り出す (フォルダーごと)",
"꺼내지 못했습니다: {0}": "取り出せませんでした: {0}",
"꺼냈습니다: {0}": "取り出しました: {0}",
"끄기": "オフ",
@ -963,9 +967,11 @@
"넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右",
"넘으면 오래 보지 않은 것부터 지웁니다": "上限を超えると、長く見ていないものから削除します",
"넣기": "入れる",
"넣기는 파일 탐색기에서 파일을 길게 눌러 [보관함에 넣기]": "入れるには、ファイルエクスプローラーでファイルを長押しして [保管庫に入れる]",
"넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]": "入れるには、エクスプローラーでファイル・フォルダーを長押しして [保管庫に入れる]",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP",
"넣은 날": "入れた日",
"넣을 것을 세는 중…": "入れるものを数えています…",
"넣을 곳: {0}": "入れる場所: {0}",
"넣지 못했습니다: {0}": "追加できませんでした: {0}",
"네덜란드어": "オランダ語",
"네트워크 · DNS": "ネットワーク・DNS",
@ -1474,6 +1480,8 @@
"변환": "変換",
"변환기 안에서 오류가 났습니다": "変換エンジン内でエラーが発生しました",
"보관한 설정으로 되살리고 다시 시작": "保管した設定で復元して再起動",
"보관함": "保管庫",
"보관함 맨 위": "保管庫の最上位",
"보관함 비밀번호": "保管庫のパスワード",
"보관함 비밀번호 바꾸기": "保管庫のパスワードを変更",
"보관함 비밀번호를 바꿨습니다": "保管庫のパスワードを変更しました",
@ -2030,6 +2038,8 @@
"옮기기": "移動",
"옮기는 중": "移動中",
"옮길": "移す",
"옮길 곳": "移動先",
"옮길 곳에 같은 이름이 이미 있습니다": "移動先に同じ名前がすでにあります",
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。",
"옮길까요?": "移動しますか?",
"옵션": "オプション",
@ -2060,6 +2070,7 @@
"원격 썸네일": "リモートのサムネイル",
"원격 썸네일 캐시": "リモートのサムネイルのキャッシュ",
"원격 썸네일 캐시 지우기": "リモートのサムネイルのキャッシュを削除",
"원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다": "リモートのファイルはこの端末のアプリ専用フォルダーにダウンロードして入れ、ダウンロードした一時ファイルはすぐに削除します",
"원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
@ -2233,6 +2244,7 @@
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "このフォルダーの組は記憶したオプションを使います (環境設定ではなくモニタリング > コピー・rsync で変更します)",
"이 폴더 전체": "このフォルダー全体",
"이 폴더는 Android 가 막아 볼 수 없습니다": "このフォルダーは Android によってブロックされているため表示できません",
"이 폴더는 비어 있습니다": "このフォルダーは空です",
"이 폴더를 rsync 원본 · 대상으로 고르기": "このフォルダーを rsync の元 · 対象に選ぶ",
"이 폴더를 볼 권한이 없습니다": "このフォルダーを表示する権限がありません",
"이 폴더를 읽을 권한이 없습니다": "このフォルダーを読む権限がありません",
@ -2349,6 +2361,7 @@
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:",
"읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "読めなかったフォルダー {0} 個 (権限・接続の問題) - その中は探せませんでした",
"읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "読めなかったフォルダー {0} 件 (サイズが実際より小さいことがあります)",
"읽지 못해 빼고 넣습니다: {0} ({1})": "読み取れなかったため除いて入れます: {0} ({1})",
"읽지 못했습니다": "読み込めませんでした",
"읽지 못했습니다: {0}": "読み込めませんでした: {0}",
"임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理",
@ -2599,6 +2612,7 @@
"지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "実行中です。止めて lsync (リアルタイム同期) へ移しますか? 残りは lsync が続けて同期します。",
"지금 쓰는 버전: v{0}": "使用中のバージョン: v{0}",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
"지금 있는 곳": "現在の場所",
"지금 작업 취소: {0}": "現在の作業をキャンセル: {0}",
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
"지금 지우기 (확인)": "今すぐ削除 (確認)",
@ -2814,6 +2828,7 @@
"파일 {0}개": "ファイル {0} 件",
"파일 {0}개 · {1}": "ファイル {0} 件 · {1}",
"파일 {0}개 · 폴더 {1}개": "ファイル {0} 個・フォルダー {1} 個",
"파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)": "ファイル {0} 個・フォルダー {1} 個 (フォルダーは中の構成のまま)",
"파일 관리자에서 보기": "ファイルマネージャーで表示",
"파일 복사 · 이동 방법": "ファイルのコピー · 移動方法",
"파일 세는 중…": "ファイルを数えています…",
@ -2870,6 +2885,7 @@
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "含まれるコンポーネントとライセンス(THIRD_PARTY_NOTICES.txt)",
"폰 세로 목록 크기": "縦向きスマホのリストの大きさ",
"폴더": "フォルダー",
"폴더 \"{0}\" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)": "フォルダー \"{0}\" と中のファイル {1} 個を保管庫から削除します。元に戻せません (先に [取り出す] をしていなければ内容は失われます)",
"폴더 + 파일 목록": "フォルダー + ファイル一覧",
"폴더 {0}개 (크기 미포함)": "フォルダー {0} 件 (サイズは含まない)",
"폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)",

View File

@ -612,6 +612,8 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。",
"{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}": "已导出 {0} 个,{1} 个因已更改或损坏而无法导出: {2}",
"{0}개를 넣었고 {1}개는 넣지 못했습니다 (그 원본은 지우지 않습니다)": "已放入 {0} 个,{1} 个未能放入 (不会删除其原件)",
"{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}",
"{0}개를 보관함에 넣었습니다": "已将 {0} 个放入保管库",
"{0}개를 영구 삭제할까요?": "要永久删除 {0} 项吗?",
@ -707,6 +709,7 @@
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
"같은 이름이 이미 있음": "同名项已存在",
"같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …",
"같은 이름이 있어 \"{0}\" 로 옮겼습니다": "已有同名项,已移动为 \"{0}\"",
"같은 이름이 있으면": "如果同名",
"같은 이름이 있을 때": "存在同名项目时",
"같은 이름이라 건너뜀 {0}개": "因同名跳过 {0} 个",
@ -885,6 +888,7 @@
"길이": "时长",
"길이 {0}": "时长 {0}",
"꺼내기": "导出",
"꺼내기 (폴더째)": "导出 (整个文件夹)",
"꺼내지 못했습니다: {0}": "无法导出: {0}",
"꺼냈습니다: {0}": "已导出: {0}",
"끄기": "关闭",
@ -963,9 +967,11 @@
"넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右",
"넘으면 오래 보지 않은 것부터 지웁니다": "超出时先删除最久未查看的",
"넣기": "放入",
"넣기는 파일 탐색기에서 파일을 길게 눌러 [보관함에 넣기]": "要放入,请在文件管理器中长按文件并选择 [放入保管库]",
"넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]": "要放入,请在文件管理器中长按文件或文件夹并选择 [放入保管库]",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP",
"넣은 날": "放入日期",
"넣을 것을 세는 중…": "正在统计要放入的项目…",
"넣을 곳: {0}": "放入位置: {0}",
"넣지 못했습니다: {0}": "无法加入: {0}",
"네덜란드어": "荷兰语",
"네트워크 · DNS": "网络 · DNS",
@ -1474,6 +1480,8 @@
"변환": "转换",
"변환기 안에서 오류가 났습니다": "转换器内部出错",
"보관한 설정으로 되살리고 다시 시작": "用保存的设置恢复并重新启动",
"보관함": "保管库",
"보관함 맨 위": "保管库顶层",
"보관함 비밀번호": "保管库密码",
"보관함 비밀번호 바꾸기": "更改保管库密码",
"보관함 비밀번호를 바꿨습니다": "已更改保管库密码",
@ -2030,6 +2038,8 @@
"옮기기": "移动",
"옮기는 중": "正在移动",
"옮길": "移动",
"옮길 곳": "移动到",
"옮길 곳에 같은 이름이 이미 있습니다": "目标位置已有同名项",
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。",
"옮길까요?": "移动吗?",
"옵션": "选项",
@ -2060,6 +2070,7 @@
"원격 썸네일": "远程缩略图",
"원격 썸네일 캐시": "远程缩略图缓存",
"원격 썸네일 캐시 지우기": "清除远程缩略图缓存",
"원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다": "远程文件会先下载到本设备的应用专用文件夹再放入,下载的临时文件会立即删除",
"원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
@ -2233,6 +2244,7 @@
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "此文件夹组合使用记住的选项 (请在 监视 > 复制 · rsync 中修改,而非设置)",
"이 폴더 전체": "整个文件夹",
"이 폴더는 Android 가 막아 볼 수 없습니다": "此文件夹被 Android 阻止,无法查看",
"이 폴더는 비어 있습니다": "此文件夹为空",
"이 폴더를 rsync 원본 · 대상으로 고르기": "选择此文件夹作为 rsync 源 · 目标",
"이 폴더를 볼 권한이 없습니다": "没有查看此文件夹的权限",
"이 폴더를 읽을 권한이 없습니다": "没有读取此文件夹的权限",
@ -2349,6 +2361,7 @@
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:",
"읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} 个无法读取的文件夹 (权限 · 连接问题) - 未在其中查找",
"읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} 个文件夹无法读取 (大小可能小于实际)",
"읽지 못해 빼고 넣습니다: {0} ({1})": "无法读取,已排除: {0} ({1})",
"읽지 못했습니다": "无法读取",
"읽지 못했습니다: {0}": "无法读取: {0}",
"임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片",
@ -2599,6 +2612,7 @@
"지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "正在运行。停止并移到 lsync (实时同步) 吗? 剩余部分由 lsync 继续同步。",
"지금 쓰는 버전: v{0}": "当前版本: v{0}",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
"지금 있는 곳": "当前位置",
"지금 작업 취소: {0}": "已取消当前任务: {0}",
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
"지금 지우기 (확인)": "立即删除 (确认)",
@ -2814,6 +2828,7 @@
"파일 {0}개": "{0} 个文件",
"파일 {0}개 · {1}": "{0} 个文件 · {1}",
"파일 {0}개 · 폴더 {1}개": "{0} 个文件 · {1} 个文件夹",
"파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)": "{0} 个文件 · {1} 个文件夹 (文件夹保留内部结构)",
"파일 관리자에서 보기": "在文件管理器中显示",
"파일 복사 · 이동 방법": "文件复制 · 移动方式",
"파일 세는 중…": "正在统计文件…",
@ -2870,6 +2885,7 @@
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "包含的组件与许可(THIRD_PARTY_NOTICES.txt)",
"폰 세로 목록 크기": "竖屏手机列表大小",
"폴더": "文件夹",
"폴더 \"{0}\" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)": "将从保管库中删除文件夹 \"{0}\" 及其中的 {1} 个文件。无法撤销 (如果未先 [导出],内容将丢失)",
"폴더 + 파일 목록": "文件夹 + 文件列表",
"폴더 {0}개 (크기 미포함)": "{0} 个文件夹 (不含大小)",
"폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)",

View File

@ -9,33 +9,48 @@ import 'package:path/path.dart' as p;
import 'vault_crypto.dart';
/// 보관함 안의 파일 하나 (원래 이름 · 크기 · 넣은 날은 암호화한 목록 index.jjv 에만)
/// 보관함 안의 파일 하나 (원래 이름 · 크기 · 넣은 날 · 보관함 안 폴더는 암호화한 목록 index.jjv 에만)
class VaultEntry {
final String blob; // 보관함 폴더 안의 무작위 이름 (확장자 .jjv 뺌)
final String name;
final int size;
final DateTime added;
const VaultEntry(this.blob, this.name, this.size, this.added);
Map<String, Object> toJson() => {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch};
/// 2단계: 보관함 안 폴더 ("" = 맨 위, "가/나" 처럼 / 로 이음). 바깥 폴더에는 만들지 않는다 - 목록에만
final String folder;
const VaultEntry(this.blob, this.name, this.size, this.added, {this.folder = ''});
/// 보관함 안 경로 (폴더/이름)
String get path => folder.isEmpty ? name : '$folder/$name';
VaultEntry copyWith({String? name, String? folder}) => VaultEntry(blob, name ?? this.name, size, added, folder: folder ?? this.folder);
// 맨 위 파일은 'folder' 를 쓰지 않는다 (1단계 판이 읽는 목록과 같게)
Map<String, Object> toJson() =>
{'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch, if (folder.isNotEmpty) 'folder': folder};
factory VaultEntry.fromJson(Map<String, dynamic> j) => VaultEntry(
'${j['blob']}', '${j['name']}', (j['size'] as num?)?.toInt() ?? 0, DateTime.fromMillisecondsSinceEpoch((j['added'] as num?)?.toInt() ?? 0));
'${j['blob']}', '${j['name']}', (j['size'] as num?)?.toInt() ?? 0, DateTime.fromMillisecondsSinceEpoch((j['added'] as num?)?.toInt() ?? 0),
folder: Vault.cleanFolder('${j['folder'] ?? ''}') ?? '');
}
/// X-plore 비교 5-2 암호 보관함 - 폴더 하나 (처음 자리: Android Download/JJ_MKVMaker/vault, Windows 문서\JJ_MKVMaker\vault).
/// - vault.json: 형식 번호 · PBKDF2 반복 수 · 솔트 · 감싼 보관함 열쇠 (비밀번호를 바꾸면 이것만 바뀜)
/// - index.jjv: 암호화한 목록 (원래 이름 · 크기 · 넣은 날)
/// - <무작위>.jjv: 파일마다 암호화한 내용 (이름은 바깥에서 보이지 않음)
/// - folders.jjd (2단계): 암호화한 빈 폴더 목록 (파일이 든 폴더는 index 의 folder 로 안다). 1단계 판의 [recover] 는 .jjv 만 지우므로 남는다
/// 쓰기는 늘 ".tmp" 에 다 쓴 뒤 이름을 바꾼다. 목록 (index) 이 기준 - 넣을 때는 내용 → 목록 차례로, 지울 때는 목록 → 내용 차례로 바꿔
/// 중간에 끊겨도 목록과 다른 것은 다음에 열 때 ([recover]) 지운다
class Vault {
final Directory dir;
final Uint8List _key;
final List<VaultEntry> _entries;
Vault._(this.dir, this._key, this._entries);
/// 사용자가 만든 폴더 (비어도 남게). 파일이 든 폴더 · 그 위 폴더는 [folders] 가 따로 센다
final Set<String> _folders;
Vault._(this.dir, this._key, this._entries, [Set<String>? folders]) : _folders = folders ?? {};
static const format = 1;
static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv';
static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv', _folderFile = 'folders.jjd';
List<VaultEntry> get entries => List.unmodifiable(_entries);
@ -98,7 +113,15 @@ class Vault {
static Future<Vault> _open(Directory dir, Uint8List key) async {
final data = await File(p.join(dir.path, _index)).readAsBytes();
final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, data))) as List;
final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map<String, dynamic>)]);
final folders = <String>{};
final ff = File(p.join(dir.path, _folderFile));
if (await ff.exists()) {
for (final x in jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, await ff.readAsBytes()))) as List) {
final f = cleanFolder('$x');
if (f != null && f.isNotEmpty) folders.add(f);
}
}
final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map<String, dynamic>)], folders);
await v.recover();
return v;
}
@ -125,6 +148,148 @@ class Vault {
await _atomicWrite(File(p.join(dir.path, _index)), VaultCrypto.encryptAll(_key, Uint8List.fromList(plain)));
}
Future<void> _saveFolders() async {
final plain = utf8.encode(jsonEncode(_folders.toList()..sort()));
await _atomicWrite(File(p.join(dir.path, _folderFile)), VaultCrypto.encryptAll(_key, Uint8List.fromList(plain)));
}
/// 보관함 안 이름 한 칸으로 쓸 수 있는지 (비었거나 . · .. · / · \ · 줄바꿈 · \0 이 들면 안 됨 - 앞뒤 빈칸은 Android 파일 이름에 있을 수 있어 받음)
static bool validName(String n) =>
n.isNotEmpty && n != '.' && n != '..' && !n.contains(RegExp(r'[/\\\u0000\n\r]'));
/// "가/나" 꼴로 고른다 (틀리면 null, "" = 맨 위)
static String? cleanFolder(String f) {
if (f.isEmpty) return '';
final parts = f.split('/');
return parts.every(validName) ? parts.join('/') : null;
}
static String _join(String folder, String name) => folder.isEmpty ? name : '$folder/$name';
static bool _inside(String folder, String parent) => parent.isEmpty || folder == parent || folder.startsWith('$parent/');
/// 있는 폴더 모두 (만든 것 + 파일이 든 것 + 그 위 폴더)
List<String> get folders {
final out = <String>{};
void addUp(String f) {
while (f.isNotEmpty && out.add(f)) {
final cut = f.lastIndexOf('/');
f = cut < 0 ? '' : f.substring(0, cut);
}
}
_folders.forEach(addUp);
for (final e in _entries) {
addUp(e.folder);
}
return out.toList()..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
}
/// [parent] 바로 아래 폴더 이름
List<String> subfolders(String parent) => [
for (final f in folders)
if (f != parent && _inside(f, parent) && !f.substring(parent.isEmpty ? 0 : parent.length + 1).contains('/'))
f.substring(parent.isEmpty ? 0 : parent.length + 1),
];
/// [folder] 바로 안의 파일
List<VaultEntry> filesIn(String folder) => [for (final e in _entries) if (e.folder == folder) e];
/// [folder] 와 그 안쪽 모든 파일
List<VaultEntry> filesUnder(String folder) => [for (final e in _entries) if (_inside(e.folder, folder)) e];
bool _taken(String folder, String name, {String? except}) =>
_entries.any((e) => e.folder == folder && e.name == name && e.blob != except) || folders.contains(_join(folder, name));
/// [folder] 안에서 겹치지 않는 이름 ("사진.jpg" → "사진 (2).jpg")
String freeName(String folder, String name, {String? except}) {
if (!_taken(folder, name, except: except)) return name;
final ext = p.extension(name), base = p.basenameWithoutExtension(name);
for (var i = 2;; i++) {
final n = '$base ($i)$ext';
if (!_taken(folder, n, except: except)) return n;
}
}
/// 보관함 안 폴더 만들기 (이미 있으면 그대로, 같은 이름 파일이 있으면 [StateError])
Future<void> mkdir(String folder) async {
final f = cleanFolder(folder);
if (f == null || f.isEmpty) throw ArgumentError.value(folder, 'folder');
if (folders.contains(f)) return;
if (_entries.any((e) => e.path == f)) throw StateError('name taken');
_folders.add(f);
try {
await _saveFolders();
} catch (_) {
_folders.remove(f);
rethrow;
}
}
/// 파일을 다른 폴더로 (목록만 - 이름이 겹치면 " (2)")
Future<VaultEntry> moveEntry(VaultEntry e, String folder) async {
final f = cleanFolder(folder);
if (f == null) throw ArgumentError.value(folder, 'folder');
final i = _entries.indexWhere((x) => x.blob == e.blob);
if (i < 0) return e;
final old = _entries[i];
if (old.folder == f) return old;
final moved = old.copyWith(folder: f, name: freeName(f, old.name, except: old.blob));
_entries[i] = moved;
try {
await _saveIndex();
} catch (_) {
_entries[i] = old;
rethrow;
}
return moved;
}
/// 폴더 이름 바꾸기 · 옮기기 ([from] → [to], 안의 것 모두 - 목록만). [to] 가 [from] 안이거나 이미 있으면 [StateError]
Future<void> moveFolder(String from, String to) async {
final a = cleanFolder(from), b = cleanFolder(to);
if (a == null || a.isEmpty || b == null || b.isEmpty) throw ArgumentError.value('$from -> $to');
if (a == b) return;
if (_inside(b, a)) throw StateError('into itself');
if (folders.contains(b) || _entries.any((e) => e.path == b)) throw StateError('name taken');
String re(String f) => _inside(f, a) ? b + f.substring(a.length) : f;
final oldEntries = [..._entries], oldFolders = {..._folders};
for (var i = 0; i < _entries.length; i++) {
if (_inside(_entries[i].folder, a)) _entries[i] = _entries[i].copyWith(folder: re(_entries[i].folder));
}
final moved = {for (final f in _folders) re(f)};
_folders
..clear()
..addAll(moved)
..add(b); // 빈 폴더였어도 남게
try {
await _saveIndex();
await _saveFolders();
} catch (_) {
_entries
..clear()
..addAll(oldEntries);
_folders
..clear()
..addAll(oldFolders);
rethrow;
}
}
/// 폴더와 안의 것 모두 지우기 (목록 먼저 - 그 뒤 내용 파일). 지운 파일 수
Future<int> removeFolder(String folder) async {
final f = cleanFolder(folder);
if (f == null || f.isEmpty) throw ArgumentError.value(folder, 'folder');
final gone = filesUnder(f);
_entries.removeWhere((e) => _inside(e.folder, f));
_folders.removeWhere((x) => _inside(x, f));
await _saveIndex();
await _saveFolders();
for (final e in gone) {
await File(p.join(dir.path, '${e.blob}$_ext')).delete().catchError((_) => File(''));
}
return gone.length;
}
static Future<void> _atomicWrite(File f, List<int> bytes) async {
final tmp = File('${f.path}.tmp');
final raf = await tmp.open(mode: FileMode.write);
@ -147,8 +312,14 @@ class Vault {
return b;
}
/// [src] 를 암호화해 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). [onProgress] (넣은 바이트, 전체)
Future<VaultEntry> add(String src, {String? name, int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async {
/// [src] 를 암호화해 [folder] 에 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). 이름이 겹치면 " (2)".
/// [onProgress] (넣은 바이트, 전체)
Future<VaultEntry> add(String src,
{String? name, String folder = '', int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async {
final f = cleanFolder(folder);
if (f == null) throw ArgumentError.value(folder, 'folder');
final want = name ?? p.basename(src);
if (!validName(want)) throw ArgumentError.value(want, 'name');
final blob = _newBlob();
final out = File(p.join(dir.path, '$blob$_ext'));
final tmp = File('${out.path}.tmp');
@ -180,7 +351,7 @@ class Vault {
}
await w.close();
await tmp.rename(out.path);
final e = VaultEntry(blob, name ?? p.basename(src), len, DateTime.now());
final e = VaultEntry(blob, freeName(f, want), len, DateTime.now(), folder: f);
_entries.add(e);
try {
await _saveIndex();
@ -238,12 +409,20 @@ class Vault {
await File(p.join(dir.path, '${e.blob}$_ext')).delete().catchError((_) => File(''));
}
/// 이름 바꾸기 (목록만)
/// 이름 바꾸기 (목록만). 같은 폴더에 같은 이름이 있으면 [StateError]
Future<void> rename(VaultEntry e, String name) async {
if (!validName(name)) throw ArgumentError.value(name, 'name');
final i = _entries.indexWhere((x) => x.blob == e.blob);
if (i < 0) return;
_entries[i] = VaultEntry(e.blob, name, e.size, e.added);
final old = _entries[i];
if (_taken(old.folder, name, except: old.blob)) throw StateError('name taken');
_entries[i] = old.copyWith(name: name);
try {
await _saveIndex();
} catch (_) {
_entries[i] = old;
rethrow;
}
}
/// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음)

View File

@ -2166,8 +2166,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')),
// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용)
if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')),
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기 - 이 기기의 파일만 (폴더째 넣기는 2단계)
if (c.settings.vaultEnabled && !dav && !inArchive && targets.every((x) => !vIsDirSync(x)))
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음)
if (c.settings.vaultEnabled && !inArchive)
('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')),
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),

View File

@ -131,7 +131,7 @@ class SecuritySettings extends StatelessWidget {
ListTile(
contentPadding: const EdgeInsets.only(left: 56, right: 16),
title: Text(tr('보관함 열기')),
subtitle: Text(tr('넣기는 파일 탐색기에서 파일을 길게 눌러 [보관함에 넣기]')),
subtitle: Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]')),
trailing: const Icon(Icons.chevron_right),
onTap: () => openVault(context, c),
),

View File

@ -17,7 +17,7 @@ import '../core/secret_gate.dart';
import '../core/vault_crypto.dart';
import '../core/vault_store.dart';
import '../core/vault_view.dart';
import '../core/vfs.dart' show vDisplay;
import '../core/vfs.dart' show isDav, vBasename, vDisplay, vIsDir, vList, vLocalCopy;
import '../l10n/tr.dart';
import 'player_page.dart';
import 'reader_page.dart' show openReader;
@ -263,11 +263,68 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
}
}
/// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도.
/// 맨 위 폴더 이름이 지금 폴더에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게)
Future<_AddPlan> _plan(List<String> targets) async {
final plan = _AddPlan();
final v = _vault!;
final taken = <String>{};
for (final t in targets) {
try {
if (!await vIsDir(t)) {
plan.files.add((src: t, folder: _cwd, top: t));
continue;
}
var name = v.freeName(_cwd, vBasename(t));
for (var i = 2; taken.contains(name); i++) {
name = v.freeName(_cwd, '${vBasename(t)} ($i)');
}
taken.add(name);
final root = _cwd.isEmpty ? name : '$_cwd/$name';
if (Vault.cleanFolder(root) == null) throw FileSystemException(tr('쓸 수 없는 이름입니다'), t);
plan.folders.add(root);
Future<void> walk(String dir, String folder) async {
for (final x in await vList(dir, strict: true)) {
final n = vBasename(x.path);
if (!Vault.validName(n)) throw FileSystemException(tr('쓸 수 없는 이름입니다'), x.path);
if (x.isDir) {
plan.folders.add('$folder/$n');
await walk(x.path, '$folder/$n');
} else {
plan.files.add((src: x.path, folder: folder, top: t));
}
}
}
await walk(t, root);
} catch (e) {
plan.failed[t] = e;
}
}
// 읽지 못한 폴더는 반만 넣지 않는다
plan.files.removeWhere((x) => plan.failed.containsKey(x.top));
return plan;
}
/// 넣는 동안 위에 보이는 줄 ("3 / 10 · 사진.jpg")
String? _progress;
/// 탐색기에서 고른 것 넣기 - [원본 지우기 (이동)] 체크 (처음 끔)
Future<void> _addPending() async {
final paths = _pendingAdd;
if (paths.isEmpty || _vault == null) return;
_pendingAdd = [];
setState(() {
_busy = true;
_progress = tr('넣을 것을 세는 중…');
});
final plan = await _plan(paths);
if (!mounted) return;
setState(() {
_busy = false;
_progress = null;
});
final remote = paths.any(isDav);
var move = false;
// 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게)
var permanent = false;
@ -278,8 +335,18 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
scrollable: true,
title: Text(trf('보관함에 넣기 · {0}개', [paths.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final x in paths.take(8)) Text('· ${p.basename(x)}', maxLines: 1, overflow: TextOverflow.ellipsis),
for (final x in paths.take(8)) Text('· ${vBasename(x)}', maxLines: 1, overflow: TextOverflow.ellipsis),
if (paths.length > 8) Text(trf('… 외 {0}개', [paths.length - 8])),
const SizedBox(height: 6),
Text(trf('파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)', [plan.files.length, plan.folders.length]),
style: const TextStyle(fontSize: 12.5)),
Text(trf('넣을 곳: {0}', [_cwdLabel()]), style: const TextStyle(fontSize: 12.5)),
if (remote)
Text(tr('원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final f in plan.failed.entries.take(3))
Text(trf('읽지 못해 빼고 넣습니다: {0} ({1})', [vBasename(f.key), f.value]),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent), maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
@ -309,26 +376,75 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('넣기'))),
FilledButton(
onPressed: plan.files.isEmpty && plan.folders.isEmpty ? null : () => Navigator.pop(ctx, true), child: Text(tr('넣기'))),
],
),
),
);
if (go != true || !mounted) return;
setState(() => _busy = true);
final done = <String>[];
final bad = <String>{...plan.failed.keys};
var done = 0;
Directory? tmp;
try {
for (final x in paths) {
await _vault!.add(x);
done.add(x);
for (final f in plan.folders) {
await _vault!.mkdir(f);
}
for (var i = 0; i < plan.files.length; i++) {
final x = plan.files[i];
if (_vault == null) break; // 넣는 동안 잠갔으면 멈춤
if (mounted) setState(() => _progress = '${i + 1} / ${plan.files.length} · ${vBasename(x.src)}');
String? fetched;
try {
var src = x.src;
if (isDav(src)) {
// 원격은 앱 전용 폴더 (jj_vault_view_… - 강제로 꺼져 남아도 다음에 켤 때 지움) 에 받고, 넣은 뒤 바로 지운다
tmp ??= await Directory(p.join((await vaultViewRootOf()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}'))
.create(recursive: true);
src = fetched = await vLocalCopy(x.src, tmp.path);
}
await _vault!.add(src, name: vBasename(x.src), folder: x.folder);
done++;
} catch (e) {
bad.add(x.top);
if (bad.length == 1 + plan.failed.length) _snack(trf('넣지 못했습니다: {0}', [e]));
} finally {
if (fetched != null) await File(fetched).delete().catchError((_) => File(fetched!));
}
}
} catch (e) {
bad.addAll(paths);
_snack(trf('넣지 못했습니다: {0}', [e]));
} finally {
if (mounted) setState(() => _busy = false);
if (tmp != null) await _removeDir(tmp);
if (mounted) {
setState(() {
_busy = false;
_progress = null;
});
}
}
if (done > 0 || bad.isEmpty) {
_snack(bad.isEmpty
? trf('{0}개를 보관함에 넣었습니다', [done])
: trf('{0}개를 넣었고 {1}개는 넣지 못했습니다 (그 원본은 지우지 않습니다)', [done, bad.length]));
}
// 다 넣은 것만 원본을 지운다 (폴더는 안의 것이 모두 들어갔을 때만)
final ok = [for (final x in paths) if (!bad.contains(x)) x];
if (move && ok.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(ok, permanent: permanent);
}
/// 앱 전용 임시 폴더 지우기 (백신 · 색인이 잠깐 잡고 있으면 몇 번 더)
static Future<void> _removeDir(Directory d) async {
for (var i = 0; i < 10; i++) {
try {
if (await d.exists()) await d.delete(recursive: true);
return;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
}
if (done.isNotEmpty) _snack(trf('{0}개를 보관함에 넣었습니다', [done.length]));
if (move && done.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(done, permanent: permanent);
}
Future<void> _view(VaultEntry e) async {
@ -409,24 +525,167 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
}
Future<void> _rename(VaultEntry e) async {
final ctl = TextEditingController(text: e.name);
final name = await showDialog<String>(
final name = await _askName(tr('이름 변경'), text: e.name);
if (name == null || name == e.name) return;
try {
await _vault!.rename(e, name);
} on StateError {
_snack(tr('같은 이름이 이미 있습니다'));
} on ArgumentError {
_snack(tr('쓸 수 없는 이름입니다'));
}
if (mounted) setState(() {});
}
/// 지금 보는 보관함 안 폴더 ("" = 맨 위)
String _cwd = '';
String _cwdLabel([String? f]) {
final x = f ?? _cwd;
return x.isEmpty ? tr('보관함 맨 위') : '${tr('보관함')} › ${x.split('/').join(' › ')}';
}
String _parentOf(String f) => f.contains('/') ? f.substring(0, f.lastIndexOf('/')) : '';
/// 이름 묻기 (글자 칸은 창이 닫히는 그림이 끝난 뒤 창과 함께 버린다 - 닫는 중에 버리면 오류)
Future<String?> _askName(String title, {String text = ''}) async {
final name = await showDialog<String>(context: context, builder: (_) => _NameDialog(title: title, text: text));
return name == null || name.isEmpty ? null : name;
}
/// 2단계: 보관함 안 새 폴더 (지금 폴더 안에)
Future<void> _mkdir() async {
final name = await _askName(tr('새 폴더'));
if (name == null) return;
if (!Vault.validName(name)) return _snack(tr('쓸 수 없는 이름입니다'));
final f = _cwd.isEmpty ? name : '$_cwd/$name';
if (_vault!.folders.contains(f)) return _snack(tr('같은 이름이 이미 있습니다'));
try {
await _vault!.mkdir(f);
} on StateError {
_snack(tr('같은 이름이 이미 있습니다'));
}
if (mounted) setState(() {});
}
/// 옮길 곳 고르기 (맨 위 + 모든 폴더). [except] 폴더와 그 안은 뺀다 (폴더를 자기 안으로 옮기지 않게)
Future<String?> _pickTarget({String? except, required String from}) {
final all = ['', ..._vault!.folders].where((f) => except == null || !(f == except || f.startsWith('$except/'))).toList();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('이름 변경')),
content: TextField(controller: ctl, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, ctl.text.trim()), child: Text(tr('확인'))),
builder: (ctx) => SimpleDialog(
title: Text(tr('옮길 곳')),
children: [
for (final f in all)
ListTile(
dense: true,
enabled: f != from,
contentPadding: EdgeInsets.only(left: 16.0 + 16 * (f.isEmpty ? 0 : f.split('/').length), right: 16),
leading: Icon(f.isEmpty ? Icons.lock_outline : Icons.folder_outlined, size: 20),
title: Text(f.isEmpty ? tr('보관함 맨 위') : f.split('/').last),
subtitle: f == from ? Text(tr('지금 있는 곳')) : null,
onTap: () => Navigator.pop(ctx, f),
),
],
),
);
ctl.dispose();
if (name == null || name.isEmpty || name == e.name) return;
await _vault!.rename(e, name);
}
Future<void> _moveEntry(VaultEntry e) async {
final to = await _pickTarget(from: e.folder);
if (to == null || to == e.folder) return;
final moved = await _vault!.moveEntry(e, to);
if (moved.name != e.name) _snack(trf('같은 이름이 있어 "{0}" 로 옮겼습니다', [moved.name]));
if (mounted) setState(() {});
}
Future<void> _moveFolder(String f) async {
final to = await _pickTarget(except: f, from: _parentOf(f));
if (to == null || to == _parentOf(f)) return;
final name = f.split('/').last;
try {
await _vault!.moveFolder(f, to.isEmpty ? name : '$to/$name');
} on StateError {
_snack(tr('옮길 곳에 같은 이름이 이미 있습니다'));
}
if (mounted) setState(() {});
}
Future<void> _renameFolder(String f) async {
final name = await _askName(tr('이름 변경'), text: f.split('/').last);
if (name == null || name == f.split('/').last) return;
if (!Vault.validName(name)) return _snack(tr('쓸 수 없는 이름입니다'));
final parent = _parentOf(f);
try {
await _vault!.moveFolder(f, parent.isEmpty ? name : '$parent/$name');
} on StateError {
_snack(tr('같은 이름이 이미 있습니다'));
}
if (mounted) setState(() {});
}
Future<void> _removeFolder(String f) async {
final n = _vault!.filesUnder(f).length;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('보관함에서 지우기')),
content: Text(trf('폴더 "{0}" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)',
[f.split('/').last, n])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
await _vault!.removeFolder(f);
if (mounted) setState(() {});
}
/// 폴더째 꺼내기 - 고른 곳에 같은 구조로 (이름이 겹치면 " (2)")
Future<void> _exportFolder(String f) async {
final dir = await vaultPickFolder();
if (dir == null || !mounted) return;
final name = f.split('/').last;
var root = p.join(dir, name);
for (var i = 2; await FileSystemEntity.type(root) != FileSystemEntityType.notFound; i++) {
root = p.join(dir, '$name ($i)');
}
setState(() => _busy = true);
var done = 0, failed = 0;
try {
String local(String folder) => p.joinAll([root, ...folder.substring(f.length).split('/').where((x) => x.isNotEmpty)]);
for (final sub in _vault!.folders.where((x) => x == f || x.startsWith('$f/'))) {
await Directory(local(sub)).create(recursive: true);
}
final files = _vault!.filesUnder(f);
for (var i = 0; i < files.length; i++) {
final e = files[i];
if (_vault == null) break;
if (mounted) setState(() => _progress = '${i + 1} / ${files.length} · ${e.name}');
try {
await _vault!.export(e, p.join(local(e.folder), e.name));
done++;
} catch (_) {
failed++;
}
}
_snack(failed == 0
? trf('꺼냈습니다: {0}', [vDisplay(root)])
: trf('{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}', [done, failed, vDisplay(root)]));
} catch (err) {
_snack(trf('꺼내지 못했습니다: {0}', [err]));
} finally {
if (mounted) {
setState(() {
_busy = false;
_progress = null;
});
}
}
}
Future<void> _changePassword() async {
final a = TextEditingController(), b = TextEditingController();
final ok = await showDialog<bool>(
@ -454,8 +713,11 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
if (mounted) setState(() => _busy = false);
}
}
// 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류)
Future<void>.delayed(const Duration(seconds: 1), () {
a.dispose();
b.dispose();
});
}
/// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬)
@ -492,7 +754,14 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
_route ??= ModalRoute.of(context);
return _scaffold();
// 2단계: 보관함 안 폴더에서 ← 는 위 폴더로
return PopScope(
canPop: _stage != _Stage.open || _cwd.isEmpty,
onPopInvokedWithResult: (did, _) {
if (!did && _cwd.isNotEmpty) setState(() => _cwd = _parentOf(_cwd));
},
child: _scaffold(),
);
}
Widget _scaffold() => Scaffold(
@ -504,11 +773,13 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
PopupMenuButton<String>(
tooltip: tr('더 보기'),
onSelected: (w) => switch (w) {
'mkdir' => _mkdir(),
'pw' => _changePassword(),
'master' => _toggleMasterCopy(!c.settings.vaultMasterCopy),
_ => null,
},
itemBuilder: (_) => [
PopupMenuItem(value: 'mkdir', child: Text(tr('새 폴더'))),
PopupMenuItem(value: 'pw', child: Text(tr('보관함 비밀번호 바꾸기'))),
CheckedPopupMenuItem(value: 'master', checked: c.settings.vaultMasterCopy, child: Text(tr('마스터로도 열기'))),
],
@ -530,7 +801,23 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
),
_Stage.open => _list(),
},
if (_busy && _stage == _Stage.open) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator()),
if (_busy && _stage == _Stage.open)
Positioned(
left: 0,
right: 0,
top: 0,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
const LinearProgressIndicator(),
if (_progress != null)
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_progress!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
),
),
]),
),
]),
);
@ -588,7 +875,13 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Widget _list() {
final list = [..._vault!.entries];
final v = _vault!;
// 지운 · 옮긴 폴더 안에 있었으면 남은 가장 가까운 위 폴더로
while (_cwd.isNotEmpty && !v.folders.contains(_cwd)) {
_cwd = _parentOf(_cwd);
}
final list = v.filesIn(_cwd);
final dirs = v.subfolders(_cwd)..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
if (_sort == 'name') {
list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
} else {
@ -596,11 +889,17 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(children: [
if (_cwd.isNotEmpty)
IconButton(
tooltip: tr('위 폴더로'), icon: const Icon(Icons.arrow_upward), onPressed: () => setState(() => _cwd = _parentOf(_cwd))),
if (_cwd.isEmpty) const SizedBox(width: 8),
Expanded(
child: Text(tr('넣기는 파일 탐색기에서 파일을 길게 눌러 [보관함에 넣기]'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
child: _cwd.isEmpty
? Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim))
: Text(_cwdLabel(), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
),
DropdownButton<String>(
value: _sort,
@ -612,13 +911,41 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
),
]),
),
if (list.isEmpty) Expanded(child: Center(child: Text(tr('보관함이 비어 있습니다')))),
if (list.isNotEmpty)
if (list.isEmpty && dirs.isEmpty)
Expanded(child: Center(child: Text(_cwd.isEmpty ? tr('보관함이 비어 있습니다') : tr('이 폴더는 비어 있습니다')))),
if (list.isNotEmpty || dirs.isNotEmpty)
Expanded(
child: ListView.builder(
itemCount: list.length,
itemCount: dirs.length + list.length,
itemBuilder: (_, i) {
final e = list[i];
if (i < dirs.length) {
final f = _cwd.isEmpty ? dirs[i] : '$_cwd/${dirs[i]}';
final n = v.filesUnder(f).length;
return ListTile(
leading: const Icon(Icons.folder, color: JjColors.accent),
title: Text(dirs[i], maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(trf('파일 {0}개', [n])),
onTap: _busy ? null : () => setState(() => _cwd = f),
trailing: PopupMenuButton<String>(
tooltip: tr('더 보기'),
enabled: !_busy,
onSelected: (w) => switch (w) {
'export' => _exportFolder(f),
'rename' => _renameFolder(f),
'move' => _moveFolder(f),
'remove' => _removeFolder(f),
_ => null,
},
itemBuilder: (_) => [
PopupMenuItem(value: 'export', child: Text(tr('꺼내기 (폴더째)'))),
PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))),
PopupMenuItem(value: 'move', child: Text(tr('옮기기'))),
PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))),
],
),
);
}
final e = list[i - dirs.length];
return ListTile(
leading: const Icon(Icons.lock, color: JjColors.accent),
title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis),
@ -631,6 +958,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
'view' => _view(e),
'export' => _export(e),
'rename' => _rename(e),
'move' => _moveEntry(e),
'remove' => _remove(e),
_ => null,
},
@ -638,6 +966,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
PopupMenuItem(value: 'view', child: Text(tr('보기'))),
PopupMenuItem(value: 'export', child: Text(tr('꺼내기'))),
PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))),
PopupMenuItem(value: 'move', child: Text(tr('옮기기'))),
PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))),
],
),
@ -649,6 +978,40 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
}
}
class _NameDialog extends StatefulWidget {
final String title, text;
const _NameDialog({required this.title, required this.text});
@override
State<_NameDialog> createState() => _NameDialogState();
}
class _NameDialogState extends State<_NameDialog> {
late final _ctl = TextEditingController(text: widget.text);
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: Text(widget.title),
content: TextField(controller: _ctl, autofocus: true, onSubmitted: (v) => Navigator.pop(context, v.trim())),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(context, _ctl.text.trim()), child: Text(tr('확인'))),
],
);
}
/// 2단계: 넣을 것 (폴더를 펼친 것). [failed] 읽지 못한 고른 것 → 오류 (그 원본은 지우지 않음)
class _AddPlan {
final files = <({String src, String folder, String top})>[];
final folders = <String>[];
final failed = <String, Object>{};
}
/// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음)
class _VaultImageSource extends ReaderSource {
final String name;

View File

@ -8,6 +8,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:jj_mkvmaker/core/shizuku.dart';
import 'package:jj_mkvmaker/core/vault_view.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/ui/explorer_page.dart';
@ -194,7 +195,7 @@ void main() {
await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp')));
await t.pumpWidget(const SizedBox());
});
testWidgets('파일 탐색기: [보관함에 넣기] 는 보관함을 켰을 때 · 이 기기의 파일에만 (폴더는 2단계)', (t) async {
testWidgets('파일 탐색기: [보관함에 넣기] 는 보관함을 켰을 때 · 파일과 폴더 (2단계)', (t) async {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
@ -216,7 +217,7 @@ void main() {
if (on) {
await t.longPress(find.text('sub').last);
await settle(t);
expect(find.text('보관함에 넣기'), findsNothing, reason: '폴더는 아직');
expect(find.text('보관함에 넣기'), findsOneWidget, reason: '2단계: 폴더째');
await t.tapAt(const Offset(5, 5));
await settle(t);
}
@ -280,4 +281,119 @@ void main() {
expect(leftovers(), isEmpty, reason: '잠그면 잠깐 푼 폴더도 지움');
await t.pumpWidget(const SizedBox());
});
testWidgets('2단계: 폴더째 넣기 (안의 구조 · 빈 폴더 그대로) · 원격 파일 (받아 넣고 임시는 바로 지움) · 폴더 열기 · 새 폴더 · 옮기기 · ← 는 위로 · 폴더째 꺼내기 · 폴더째 지우기', (t) async {
t.view.physicalSize = const Size(900, 1400);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final viewRoot = Directory(p.join(tmp.path, 'viewroot'));
final wasRoot = vaultViewRootOf;
vaultViewRootOf = () async => viewRoot;
addTearDown(() => vaultViewRootOf = wasRoot);
final remote = _Remote({'/r/remote.txt': 'from nas'});
final wasBackend = ShizukuRemote.backend, wasTemp = ShizukuRemote.tempDir;
ShizukuRemote.backend = remote;
ShizukuRemote.tempDir = () async => tmp.path;
addTearDown(() {
ShizukuRemote.backend = wasBackend;
ShizukuRemote.tempDir = wasTemp;
});
final album = Directory(p.join(tmp.path, 'album'));
File(p.join(album.path, 'a.jpg'))
..createSync(recursive: true)
..writeAsStringSync('photo');
File(p.join(album.path, 'sub', 'b.txt'))
..createSync(recursive: true)
..writeAsStringSync('bee');
Directory(p.join(album.path, 'empty')).createSync();
const remotePath = '${shizukuScheme}sys/r/remote.txt';
final c = ctl();
final deleted = <List<String>>[];
await t.pumpWidget(MaterialApp(
home: VaultPage(c: c, add: [album.path, remotePath], deleteOriginals: (paths, {required permanent}) async => deleted.add(paths))));
await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
await t.enterText(find.byType(TextField).at(0), 'pw');
await t.enterText(find.byType(TextField).at(1), 'pw');
await t.tap(find.text('비밀번호를 잊으면 되살릴 수 없음을 압니다'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '만들기'));
await until(t, () => find.widgetWithText(FilledButton, '넣기').evaluate().isNotEmpty);
expect(find.text('파일 3개 · 폴더 3개 (폴더는 안의 구조 그대로)'), findsOneWidget);
expect(find.textContaining('받은 임시 파일은 바로 지웁니다'), findsOneWidget);
await t.tap(find.text('원본 지우기 (이동)'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(t, () => deleted.isNotEmpty);
expect(deleted.single, [album.path, remotePath], reason: '다 넣은 것만 원본 지우기로');
expect(find.text('album'), findsOneWidget);
expect(find.text('remote.txt'), findsOneWidget);
expect(find.text('파일 2개'), findsOneWidget, reason: '폴더 줄에 안의 파일 수');
expect(viewRoot.existsSync() ? viewRoot.listSync() : const <FileSystemEntity>[], isEmpty, reason: '원격에서 받은 임시는 지움');
expect(tmp.listSync().where((e) => p.basename(e.path).startsWith('jj_shz_')), isEmpty);
expect(Directory(p.join(tmp.path, 'vault')).listSync().map((f) => p.basename(f.path)).join(), isNot(contains('album')));
// 폴더 열기 → 안의 구조
await t.tap(find.text('album'));
await until(t, () => find.text('a.jpg').evaluate().isNotEmpty);
expect(find.text('보관함 › album'), findsOneWidget);
expect(find.text('sub'), findsOneWidget);
expect(find.text('empty'), findsOneWidget);
// 새 폴더 (지금 폴더 안)
await t.tap(find.descendant(of: find.byType(AppBar), matching: find.byTooltip('더 보기')));
await settle(t);
await t.tap(find.text('새 폴더').last);
await settle(t);
await t.enterText(find.byType(TextField), '새것');
await t.tap(find.widgetWithText(FilledButton, '확인'));
await until(t, () => find.text('새것').evaluate().isNotEmpty);
// 파일 옮기기 → 새것
await menu(t, 'a.jpg', '옮기기');
await settle(t);
await t.tap(find.descendant(of: find.byType(SimpleDialog), matching: find.text('새것')));
await until(t, () => find.text('a.jpg').evaluate().isEmpty);
expect(find.text('파일 1개'), findsWidgets);
// ← 는 위 폴더로 (보관함 화면을 닫지 않음)
await t.binding.handlePopRoute();
await until(t, () => find.text('remote.txt').evaluate().isNotEmpty);
expect(find.byType(VaultPage), findsOneWidget);
// 폴더째 꺼내기: 같은 구조 (빈 폴더 포함)
await menu(t, 'album', '꺼내기 (폴더째)');
final out = p.join(tmp.path, 'out', 'album');
await until(t, () => File(p.join(out, '새것', 'a.jpg')).existsSync() && File(p.join(out, 'sub', 'b.txt')).existsSync());
expect(File(p.join(out, '새것', 'a.jpg')).readAsStringSync(), 'photo');
expect(File(p.join(out, 'sub', 'b.txt')).readAsStringSync(), 'bee');
expect(Directory(p.join(out, 'empty')).existsSync(), isTrue);
// 원격 파일 꺼내기도 같은 내용
await menu(t, 'remote.txt', '꺼내기');
await until(t, () => File(p.join(tmp.path, 'out', 'remote.txt')).existsSync());
expect(File(p.join(tmp.path, 'out', 'remote.txt')).readAsStringSync(), 'from nas');
// 폴더째 지우기 (확인 창에 파일 수)
await menu(t, 'album', '보관함에서 지우기');
await settle(t);
expect(find.textContaining('안의 파일 2개'), findsOneWidget);
await t.tap(find.widgetWithText(FilledButton, '지우기'));
await until(t, () => find.text('album').evaluate().isEmpty);
expect(find.text('remote.txt'), findsOneWidget);
await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp')));
await t.pumpWidget(const SizedBox());
});
}
/// 원격 (시스템 폴더 길을 빌림) - 파일 몇 개만
class _Remote implements ShizukuBackend {
final Map<String, String> files;
_Remote(this.files);
@override
Future<({bool running, bool granted})> status() async => (running: true, granted: true);
@override
Future<bool> request() async => true;
@override
Future<String> list(String path) async => '';
@override
Future<String> stat(String path) async =>
files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.posix.basename(path)}' : '';
@override
Future<int> copyTo(String path, String dest) async {
File(dest).writeAsStringSync(files[path]!);
return files[path]!.length;
}
}

View File

@ -198,5 +198,70 @@ void main() {
j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 1', '"format": 2'));
await expectLater(Vault.unlock(dir, 'pw'), throwsA(isA<VaultFormatException>()));
});
test('2단계 폴더: 넣기 · 빈 폴더 만들기 · 다시 열어도 그대로 · 이름 겹침 " (2)" · 파일 · 폴더 옮기기 · 자기 안으로는 안 됨 · 폴더째 지우기', () async {
final v = await Vault.create(dir, 'pw', iterations: 1000);
await v.add(src('a.jpg', 100).path, folder: '여행/바다');
await v.add(src('a.jpg', 100).path, folder: '여행/바다');
await v.add(src('top.txt', 5).path);
await v.mkdir('빈 폴더');
expect(v.folders, ['빈 폴더', '여행', '여행/바다']);
expect(v.filesIn('여행/바다').map((e) => e.name), ['a.jpg', 'a (2).jpg']);
expect(v.subfolders(''), ['빈 폴더', '여행']);
expect(v.subfolders('여행'), ['바다']);
// 바깥에서는 폴더 이름도 보이지 않음 (목록 · 폴더 목록은 암호화)
final names = dir.listSync().map((f) => p.basename(f.path)).join(' ');
expect(names, isNot(contains('여행')));
expect(names, isNot(contains('빈')));
var again = await Vault.unlock(dir, 'pw');
expect(again.folders, ['빈 폴더', '여행', '여행/바다']);
expect(again.filesUnder('여행').length, 2);
// 파일 옮기기: 겹치면 " (2)"
await again.add(src('a.jpg', 100).path, folder: '빈 폴더');
final moved = await again.moveEntry(again.filesIn('빈 폴더').single, '여행/바다');
expect(moved.name, 'a (3).jpg');
expect(again.folders, contains('빈 폴더'), reason: '만든 폴더는 비어도 남음');
// 폴더 옮기기 · 이름 바꾸기 (안의 것 모두)
await expectLater(again.moveFolder('여행', '여행/바다/깊이'), throwsA(isA<StateError>()), reason: '자기 안으로');
await expectLater(again.moveFolder('여행', '빈 폴더'), throwsA(isA<StateError>()), reason: '이미 있음');
await again.moveFolder('여행', '빈 폴더/2026 여행');
expect(again.folders, ['빈 폴더', '빈 폴더/2026 여행', '빈 폴더/2026 여행/바다']);
expect(again.filesIn('빈 폴더/2026 여행/바다').length, 3);
again = await Vault.unlock(dir, 'pw');
expect(again.filesUnder('빈 폴더').map((e) => e.path).toSet(),
{'빈 폴더/2026 여행/바다/a.jpg', '빈 폴더/2026 여행/바다/a (2).jpg', '빈 폴더/2026 여행/바다/a (3).jpg'});
// 이름 바꾸기: 같은 폴더에 같은 이름이면 안 됨 · 쓸 수 없는 이름
final f = again.filesIn('빈 폴더/2026 여행/바다');
await expectLater(again.rename(f.first, f.last.name), throwsA(isA<StateError>()));
await expectLater(again.rename(f.first, '../x'), throwsA(isA<ArgumentError>()));
await expectLater(again.mkdir('a/../b'), throwsA(isA<ArgumentError>()));
// 폴더째 지우기: 목록 · 내용 파일 모두
expect(await again.removeFolder('빈 폴더'), 3);
expect(again.folders, isEmpty);
again = await Vault.unlock(dir, 'pw');
expect(again.entries.map((e) => e.path), ['top.txt']);
expect(dir.listSync().where((x) => x.path.endsWith('.jjv') && !x.path.endsWith('index.jjv')).length, 1);
});
test('2단계 목록은 1단계 판과 맞음: 맨 위 파일은 "folder" 를 쓰지 않고, 폴더 목록 (folders.jjd) 은 1단계의 정리 (recover) 가 지우지 않음', () async {
final v = await Vault.create(dir, 'pw', iterations: 1000);
await v.add(src('top.txt', 5).path);
await v.add(src('in.txt', 5).path, folder: '가');
await v.mkdir('나');
final key = v.keyForMasterCopy;
final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, File(p.join(dir.path, 'index.jjv')).readAsBytesSync()))) as List;
expect((list[0] as Map).containsKey('folder'), isFalse);
expect((list[1] as Map)['folder'], '가');
await v.recover();
expect(File(p.join(dir.path, 'folders.jjd')).existsSync(), isTrue);
// 목록에 이상한 폴더 이름이 들어 있으면 맨 위로 (밖으로 나가는 경로를 만들지 않게)
final bad = [
...list,
{'blob': (list[0] as Map)['blob'], 'name': 'x', 'size': 5, 'added': 0, 'folder': '../밖'},
];
File(p.join(dir.path, 'index.jjv')).writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(bad)))));
final again = await Vault.unlock(dir, 'pw');
expect(again.entries.last.folder, '');
});
});
}