diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 986306f..1d59640 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0b1208d..ff3920b 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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} 件 (サイズ除く)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 5d8b973..1b4c2df 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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} 个文件夹(不计大小)", diff --git a/lib/core/vault_store.dart b/lib/core/vault_store.dart index 5dc08d0..a45fa85 100644 --- a/lib/core/vault_store.dart +++ b/lib/core/vault_store.dart @@ -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 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 toJson() => + {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch, if (folder.isNotEmpty) 'folder': folder}; factory VaultEntry.fromJson(Map 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 _entries; - Vault._(this.dir, this._key, this._entries); + + /// 사용자가 만든 폴더 (비어도 남게). 파일이 든 폴더 · 그 위 폴더는 [folders] 가 따로 센다 + final Set _folders; + Vault._(this.dir, this._key, this._entries, [Set? 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 get entries => List.unmodifiable(_entries); @@ -98,7 +113,15 @@ class Vault { static Future _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)]); + final folders = {}; + 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)], 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 _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 get folders { + final out = {}; + 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 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 filesIn(String folder) => [for (final e in _entries) if (e.folder == folder) e]; + + /// [folder] 와 그 안쪽 모든 파일 + List 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 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 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 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 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 _atomicWrite(File f, List 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 add(String src, {String? name, int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async { + /// [src] 를 암호화해 [folder] 에 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). 이름이 겹치면 " (2)". + /// [onProgress] (넣은 바이트, 전체) + Future 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 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); - await _saveIndex(); + 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; + } } /// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음) diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ca3421c..288cc56 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2166,8 +2166,8 @@ class _ExplorerPageState extends State { 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('기본 앱으로 열기')), diff --git a/lib/ui/security_settings.dart b/lib/ui/security_settings.dart index f321c3c..5807df2 100644 --- a/lib/ui/security_settings.dart +++ b/lib/ui/security_settings.dart @@ -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), ), diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index cb1a8b8..12fbf2a 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -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 with WidgetsBindingObserver { } } + /// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도. + /// 맨 위 폴더 이름이 지금 폴더에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게) + Future<_AddPlan> _plan(List targets) async { + final plan = _AddPlan(); + final v = _vault!; + final taken = {}; + 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 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 _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 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 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 = []; + final bad = {...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 _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.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 _view(VaultEntry e) async { @@ -409,24 +525,167 @@ class _VaultPageState extends State with WidgetsBindingObserver { } Future _rename(VaultEntry e) async { - final ctl = TextEditingController(text: e.name); - final name = await showDialog( + 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 _askName(String title, {String text = ''}) async { + final name = await showDialog(context: context, builder: (_) => _NameDialog(title: title, text: text)); + return name == null || name.isEmpty ? null : name; + } + + /// 2단계: 보관함 안 새 폴더 (지금 폴더 안에) + Future _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 _pickTarget({String? except, required String from}) { + final all = ['', ..._vault!.folders].where((f) => except == null || !(f == except || f.startsWith('$except/'))).toList(); + return showDialog( 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 _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 _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 _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 _removeFolder(String f) async { + final n = _vault!.filesUnder(f).length; + final ok = await showDialog( + 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 _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 _changePassword() async { final a = TextEditingController(), b = TextEditingController(); final ok = await showDialog( @@ -454,8 +713,11 @@ class _VaultPageState extends State with WidgetsBindingObserver { if (mounted) setState(() => _busy = false); } } - a.dispose(); - b.dispose(); + // 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류) + Future.delayed(const Duration(seconds: 1), () { + a.dispose(); + b.dispose(); + }); } /// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬) @@ -492,7 +754,14 @@ class _VaultPageState extends State 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 with WidgetsBindingObserver { PopupMenuButton( 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 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 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 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( value: _sort, @@ -612,13 +911,41 @@ class _VaultPageState extends State 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( + 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 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 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 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 = []; + final failed = {}; +} + /// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음) class _VaultImageSource extends ReaderSource { final String name; diff --git a/test/vault_page_test.dart b/test/vault_page_test.dart index f8f8545..a399aa7 100644 --- a/test/vault_page_test.dart +++ b/test/vault_page_test.dart @@ -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 = >[]; + 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 [], 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 files; + _Remote(this.files); + @override + Future<({bool running, bool granted})> status() async => (running: true, granted: true); + @override + Future request() async => true; + @override + Future list(String path) async => ''; + @override + Future stat(String path) async => + files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.posix.basename(path)}' : ''; + @override + Future copyTo(String path, String dest) async { + File(dest).writeAsStringSync(files[path]!); + return files[path]!.length; + } } diff --git a/test/vault_test.dart b/test/vault_test.dart index 450f86b..6f7a7b3 100644 --- a/test/vault_test.dart +++ b/test/vault_test.dart @@ -198,5 +198,70 @@ void main() { j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 1', '"format": 2')); await expectLater(Vault.unlock(dir, 'pw'), throwsA(isA())); }); + + 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()), reason: '자기 안으로'); + await expectLater(again.moveFolder('여행', '빈 폴더'), throwsA(isA()), 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())); + await expectLater(again.rename(f.first, '../x'), throwsA(isA())); + await expectLater(again.mkdir('a/../b'), throwsA(isA())); + // 폴더째 지우기: 목록 · 내용 파일 모두 + 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, ''); + }); }); }