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
parent
8f6fcdd9ed
commit
8fa270a62e
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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} 件 (サイズ除く)",
|
||||
|
|
|
|||
|
|
@ -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} 个文件夹(不计大小)",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음)
|
||||
|
|
|
|||
|
|
@ -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('기본 앱으로 열기')),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue