diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 1710c54..b5037a3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -497,6 +497,7 @@ "{0}개 · {1}": "{0} · {1}", "{0}개 · {1} …": "{0} items · {1} …", "{0}개 · 주소 {1}개": "{0} · {1} links", + "{0}개 고름": "{0} selected", "{0}개 더 보기": "Show {0} more", "{0}개 맞춤": "{0} synced", "{0}개 보임": "{0} shown", @@ -526,12 +527,15 @@ "{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}": "Compressed {0} files: {1}", + "{0}개를 영구 삭제할까요?": "Delete {0} items permanently?", "{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}개를 옮겼습니다.": "Moved {0} items.", + "{0}개를 원래 자리로 되살렸습니다": "Restored {0} items to their original location", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.", "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", + "{0}개를 휴지통으로 옮기지 못했습니다": "Couldn't move {0} items to the trash", "{0}개에서 멈췄습니다": "Stopped at {0}", "{0}개의 이름을 바꿨습니다.": "Renamed {0} items.", "{0}배": "{0}×", @@ -539,6 +543,7 @@ "{0}분": "{0} min", "{0}분 {1}초": "{0} min {1} s", "{0}시간 {1}분": "{0} h {1} min", + "{0}일": "{0} days", "{0}일 안": "within {0} days", "{0}일 지남": "older than {0} days", "{0}장 모두 해상도 올리기": "Upscale all {0} pages", @@ -750,6 +755,7 @@ "끌어 놓기 기본": "Drag and drop default", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", "끌어서 다른 창으로": "Drag to the other pane", + "끔": "Off", "끝": "End", "끝 (오른쪽 · 아래)": "Edge (right · bottom)", "끝 = 현재": "End = now", @@ -987,6 +993,8 @@ "되돌릴 수 없습니다.": "This can't be undone.", "되살리고 다시 시작": "Restore and restart", "되살리기": "Restore", + "되살리지 못함: {0}": "Couldn't restore: {0}", + "되살림": "restored", "두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match", "두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)", "두 쪽 나눠 보기": "Split two-page spreads", @@ -1318,6 +1326,7 @@ "비어 있는 폴더": "Empty folder", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있음": "Empty", + "비우기": "Empty", "비우면 지움": "Empty = remove", "빈 이름": "Empty name", "빈 폴더 {0}개 지움": "Deleted {0} empty folders", @@ -1563,6 +1572,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)", "앱 지우기": "Uninstall app", + "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).", "앱에 들어 있는 것 (기본)": "Built into the app (default)", "앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "Built into the app (Tor {0}, tor {1}). Turning it on starts nothing until the Tor screen is opened.", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "After reinstalling the app, Android Auto Backup (Google backup) restored your previous settings.\n· {0} videos in the list\n· WebDAV servers: {1}", @@ -1617,6 +1627,7 @@ "열기 · 재생": "Open · Play", "영구 삭제": "Delete permanently", "영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)", + "영구 삭제하면 되살릴 수 없습니다.": "Permanently deleted items can't be restored.", "영상": "Video", "영상 {0}": "Video {0}", "영상 · 음성 합치는 중": "Merging video · audio", @@ -2024,6 +2035,7 @@ "저장 안 함": "Don't save", "저장 위치": "Save locations", "저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "Storage, servers and favorites at the top of the tree", + "저장 장치를 알 수 없습니다": "Unknown storage", "저장 폴더": "Save folder", "저장 형식": "Save format", "저장: {0}": "Save: {0}", @@ -2182,6 +2194,8 @@ "지우지 못했습니다: {0}": "Could not delete: {0}", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "Synced without deleting · confirm whether to delete {0} items only in the target", "지우지 않기 (지우기 끄기)": "Don't delete (turn off delete)", + "지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "Deleted items are kept here for {0} days and then removed automatically (Settings > File explorer > Keep in trash).", + "지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "Deleted items go to the app trash on that storage and are removed after this many days. Off = delete permanently right away", "지울 목록 보고 결정": "Review the list and decide", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})", @@ -2540,10 +2554,20 @@ "회전 없음": "No rotation", "회차": "Episode", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "Phones and tablets often can't decode AV1/VP9 in hardware, causing stutter or heavy battery use. YouTube's H.264 goes up to 1080p, so for higher qualities size comes first.", + "휴지통": "Trash", + "휴지통 · {0}개 · {1}": "Trash · {0} items · {1}", + "휴지통 보관": "Keep in trash", + "휴지통 비우기": "Empty trash", + "휴지통에서 지운 것은 되살릴 수 없습니다.": "Items deleted from the trash can't be restored.", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)", "휴지통으로": "To Recycle Bin", "휴지통으로 보내기": "Move to Recycle Bin", + "휴지통으로 옮기지 못했습니다: {0} ({1})": "Couldn't move to the trash: {0} ({1})", + "휴지통을 거치지 않고 영구 삭제": "Delete permanently without the trash", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "Delete permanently without the Recycle Bin (Shift+Delete)", + "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off (Settings > File explorer > Keep in trash).", + "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off - deleting removes items permanently right away (Settings > File explorer > Keep in trash).", + "휴지통이 비어 있습니다": "The trash is empty", "휴지통이 없는 곳입니다": "This location has no Recycle Bin", "휴지통이 없습니다": "No Recycle Bin", "히브리어": "Hebrew", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b950a91..7faf995 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -497,6 +497,7 @@ "{0}개 · {1}": "{0}個・{1}", "{0}개 · {1} …": "{0} 件 · {1} …", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", + "{0}개 고름": "{0} 件選択", "{0}개 더 보기": "さらに {0} 件表示", "{0}개 맞춤": "{0} 件を同期", "{0}개 보임": "{0} 件表示", @@ -526,12 +527,15 @@ "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}", + "{0}개를 영구 삭제할까요?": "{0} 件を完全に削除しますか?", "{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}개를 옮겼습니다.": "{0} 件を移動しました。", + "{0}개를 원래 자리로 되살렸습니다": "{0} 件を元の場所に戻しました", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。", "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", + "{0}개를 휴지통으로 옮기지 못했습니다": "{0} 件をゴミ箱に移せませんでした", "{0}개에서 멈췄습니다": "{0} 件で停止しました", "{0}개의 이름을 바꿨습니다.": "{0} 件の名前を変更しました。", "{0}배": "{0} 倍", @@ -539,6 +543,7 @@ "{0}분": "{0} 分", "{0}분 {1}초": "{0}分 {1}秒", "{0}시간 {1}분": "{0}時間 {1}分", + "{0}일": "{0} 日", "{0}일 안": "{0} 日以内", "{0}일 지남": "{0} 日以上前", "{0}장 모두 해상도 올리기": "{0} ページすべてを高解像度化", @@ -750,6 +755,7 @@ "끌어 놓기 기본": "ドラッグ&ドロップの既定", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", "끌어서 다른 창으로": "ドラッグしてもう一方のウィンドウへ", + "끔": "オフ", "끝": "終了", "끝 (오른쪽 · 아래)": "端 (右 · 下)", "끝 = 현재": "終了 = 現在", @@ -987,6 +993,8 @@ "되돌릴 수 없습니다.": "元に戻せません。", "되살리고 다시 시작": "復元して再起動", "되살리기": "元に戻す", + "되살리지 못함: {0}": "元に戻せませんでした: {0}", + "되살림": "復元", "두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います", "두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)", "두 쪽 나눠 보기": "見開きを分けて表示", @@ -1318,6 +1326,7 @@ "비어 있는 폴더": "空のフォルダー", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있음": "空", + "비우기": "空にする", "비우면 지움": "空なら削除", "빈 이름": "空の名前", "빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除", @@ -1563,6 +1572,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 지우기": "アプリを削除", + "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", "앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "アプリに内蔵されています (Tor {0}、tor {1})。オンにしても Tor 画面を開くまで何も起動しません。", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "アプリを再インストールしたため、Android 自動バックアップ (Google バックアップ) が以前の設定を復元しました。\n・動画リスト {0} 件\n・WebDAV サーバー: {1}", @@ -1617,6 +1627,7 @@ "열기 · 재생": "開く・再生", "영구 삭제": "完全に削除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)", + "영구 삭제하면 되살릴 수 없습니다.": "完全に削除すると元に戻せません。", "영상": "映像", "영상 {0}": "映像 {0}", "영상 · 음성 합치는 중": "映像・音声を結合中", @@ -2024,6 +2035,7 @@ "저장 안 함": "保存しない", "저장 위치": "保存先", "저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "ストレージ・サーバー・お気に入りをツリーの最上位に", + "저장 장치를 알 수 없습니다": "ストレージが分かりません", "저장 폴더": "保存フォルダー", "저장 형식": "保存形式", "저장: {0}": "保存: {0}", @@ -2182,6 +2194,8 @@ "지우지 못했습니다: {0}": "削除できませんでした: {0}", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "削除せずに同期・対象にだけある {0} 個を削除するか確認が必要", "지우지 않기 (지우기 끄기)": "削除しない (削除をオフ)", + "지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "削除したものはここに {0} 日間置いてから自動で削除します (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", + "지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "削除したものはそのストレージのアプリのゴミ箱に置き、この日数が過ぎると削除します。オフにするとすぐに完全削除", "지울 목록 보고 결정": "削除する一覧を見て決める", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", @@ -2540,10 +2554,20 @@ "회전 없음": "回転なし", "회차": "話数", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "スマホ・タブレットは AV1・VP9 をハードウェアで再生できず、途切れたりバッテリーを多く使うことがあります。YouTube の H.264 は 1080p までなので、それより高い画質を選ぶとサイズが優先です。", + "휴지통": "ゴミ箱", + "휴지통 · {0}개 · {1}": "ゴミ箱 · {0} 件 · {1}", + "휴지통 보관": "ゴミ箱の保管", + "휴지통 비우기": "ゴミ箱を空にする", + "휴지통에서 지운 것은 되살릴 수 없습니다.": "ゴミ箱から削除したものは元に戻せません。", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)", "휴지통으로": "ごみ箱へ", "휴지통으로 보내기": "ごみ箱に移動", + "휴지통으로 옮기지 못했습니다: {0} ({1})": "ゴミ箱に移せませんでした: {0} ({1})", + "휴지통을 거치지 않고 영구 삭제": "ゴミ箱を使わずに完全に削除", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)", + "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", + "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです - 削除するとすぐに完全削除されます (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", + "휴지통이 비어 있습니다": "ゴミ箱は空です", "휴지통이 없는 곳입니다": "ごみ箱がない場所です", "휴지통이 없습니다": "ごみ箱がありません", "히브리어": "ヘブライ語", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index da7a4d4..06eeb70 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -497,6 +497,7 @@ "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1} …": "{0} 项 · {1} …", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", + "{0}개 고름": "已选 {0} 个", "{0}개 더 보기": "再显示 {0} 个", "{0}개 맞춤": "已同步 {0} 个", "{0}개 보임": "显示 {0} 项", @@ -526,12 +527,15 @@ "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}", + "{0}개를 영구 삭제할까요?": "要永久删除 {0} 项吗?", "{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}개를 옮겼습니다.": "已移动 {0} 项。", + "{0}개를 원래 자리로 되살렸습니다": "已将 {0} 项还原到原位置", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。", "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", + "{0}개를 휴지통으로 옮기지 못했습니다": "无法将 {0} 项移到回收站", "{0}개에서 멈췄습니다": "已在 {0} 个处暂停", "{0}개의 이름을 바꿨습니다.": "已重命名 {0} 项。", "{0}배": "{0} 倍", @@ -539,6 +543,7 @@ "{0}분": "{0} 分钟", "{0}분 {1}초": "{0} 分 {1} 秒", "{0}시간 {1}분": "{0} 小时 {1} 分", + "{0}일": "{0} 天", "{0}일 안": "{0} 天内", "{0}일 지남": "超过 {0} 天", "{0}장 모두 해상도 올리기": "提高全部 {0} 页的分辨率", @@ -750,6 +755,7 @@ "끌어 놓기 기본": "拖放默认操作", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", "끌어서 다른 창으로": "拖到另一个窗格", + "끔": "关闭", "끝": "结束", "끝 (오른쪽 · 아래)": "边缘 (右 · 下)", "끝 = 현재": "结束 = 当前", @@ -987,6 +993,8 @@ "되돌릴 수 없습니다.": "无法撤销。", "되살리고 다시 시작": "恢复并重新启动", "되살리기": "恢复", + "되살리지 못함: {0}": "无法还原: {0}", + "되살림": "已还原", "두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致", "두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)", "두 쪽 나눠 보기": "拆分双页", @@ -1318,6 +1326,7 @@ "비어 있는 폴더": "空文件夹", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있음": "空", + "비우기": "清空", "비우면 지움": "留空则删除", "빈 이름": "空名称", "빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹", @@ -1563,6 +1572,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 지우기": "卸载应用", + "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。", "앱에 들어 있는 것 (기본)": "应用内置 (默认)", "앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "已内置于应用 (Tor {0},tor {1})。开启后在打开 Tor 画面之前不会启动任何内容。", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "重新安装应用后,Android 自动备份 (Google 备份) 恢复了之前的设置。\n· 视频列表 {0} 个\n· WebDAV 服务器: {1}", @@ -1617,6 +1627,7 @@ "열기 · 재생": "打开 · 播放", "영구 삭제": "永久删除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)", + "영구 삭제하면 되살릴 수 없습니다.": "永久删除后无法还原。", "영상": "视频", "영상 {0}": "视频 {0}", "영상 · 음성 합치는 중": "正在合并视频 · 音频", @@ -2024,6 +2035,7 @@ "저장 안 함": "不保存", "저장 위치": "保存位置", "저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "将存储 · 服务器 · 收藏放在树的最上层", + "저장 장치를 알 수 없습니다": "未知的存储", "저장 폴더": "保存文件夹", "저장 형식": "保存格式", "저장: {0}": "保存: {0}", @@ -2182,6 +2194,8 @@ "지우지 못했습니다: {0}": "无法删除: {0}", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "已同步但未删除 · 需确认是否删除仅在目标中的 {0} 个项目", "지우지 않기 (지우기 끄기)": "不删除 (关闭删除)", + "지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "删除的项目会在此保留 {0} 天后自动删除 (设置 > 文件资源管理器 > 回收站保留)。", + "지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "删除的项目放入该存储的应用回收站,超过此天数后删除。关闭则立即永久删除", "지울 목록 보고 결정": "查看要删除的列表并决定", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", @@ -2540,10 +2554,20 @@ "회전 없음": "不旋转", "회차": "集数", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "手机 · 平板常无法硬件解码 AV1 · VP9,会卡顿或耗电。YouTube 的 H.264 最高 1080p,选择更高画质时以尺寸优先。", + "휴지통": "回收站", + "휴지통 · {0}개 · {1}": "回收站 · {0} 项 · {1}", + "휴지통 보관": "回收站保留", + "휴지통 비우기": "清空回收站", + "휴지통에서 지운 것은 되살릴 수 없습니다.": "从回收站删除的项目无法还原。", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)", "휴지통으로": "移到回收站", "휴지통으로 보내기": "移到回收站", + "휴지통으로 옮기지 못했습니다: {0} ({1})": "无法移到回收站: {0} ({1})", + "휴지통을 거치지 않고 영구 삭제": "不经回收站永久删除", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)", + "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 (设置 > 文件资源管理器 > 回收站保留)。", + "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 - 删除会立即永久删除 (设置 > 文件资源管理器 > 回收站保留)。", + "휴지통이 비어 있습니다": "回收站为空", "휴지통이 없는 곳입니다": "此位置没有回收站", "휴지통이 없습니다": "没有回收站", "히브리어": "希伯来语", diff --git a/integration_test/windows_xplore_shot_test.dart b/integration_test/windows_xplore_shot_test.dart new file mode 100644 index 0000000..6f53ce1 --- /dev/null +++ b/integration_test/windows_xplore_shot_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +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/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더). +/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a). +/// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xplore'); + + for (final lang in ['ko', 'en']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('X-plore 화면 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = Directory.systemTemp.createTempSync('jj_xpshot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final a = Directory(p.join(tmp.path, 'Videos'))..createSync(); + final b = Directory(p.join(tmp.path, 'Backup'))..createSync(); + for (final n in ['Show S01', 'Movies']) { + Directory(p.join(a.path, n)).createSync(); + } + File(p.join(a.path, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0)); + File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo'); + ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerStyle = 'xplore' + ..explorerLayout = w < 720 ? 'single' : 'dual' + ..explorerOnePaneCopy = 'other' + ..explorerPaths = [a.path, b.path] + ..folderBookmarks = [FolderBookmark(name: 'Show S01', path: p.join(a.path, 'Show S01'))]; + final shotKey = GlobalKey(); + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)), + ))); + Future settle([int n = 12]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'xplore_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(20); + // Show S01 펼치기 · 즐겨찾기 펼치기 + await t.tap(find.text('Show S01').first); + await settle(); + await t.tap(find.byKey(const ValueKey('k2:\u0000favorites')).first); + await settle(); + await shot('tree'); + await t.pumpWidget(const SizedBox()); + await settle(4); + }); + } + } +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index bf21613..165fa2b 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -416,6 +416,9 @@ class AppSettings { /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (처음 값 켬). 화면 사이 이동은 위 막대에서 밀기 (10/8 결정 유지) bool explorerSwipePanes = true; + /// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통 + int androidTrashDays = 14; + /// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본) / 'open' 한 번 = 바로 열기. /// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴. String explorerClick = Platform.isAndroid ? 'open' : 'select'; @@ -773,6 +776,7 @@ class AppSettings { 'explorerAlwaysMarks': explorerAlwaysMarks, 'explorerFolderMarkRule': explorerFolderMarkRule, 'explorerSwipePanes': explorerSwipePanes, + 'androidTrashDays': androidTrashDays, 'explorerClick': explorerClick, // 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다 'explorerV2': true, @@ -988,6 +992,7 @@ class AppSettings { ..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false ..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree' ..explorerSwipePanes = j['explorerSwipePanes'] != false + ..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 } ..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout']) ? j['explorerLayout'] as String : j['explorerLayout'] == 'dual' && j['explorerV2'] == true diff --git a/lib/core/app_trash.dart b/lib/core/app_trash.dart new file mode 100644 index 0000000..618f55c --- /dev/null +++ b/lib/core/app_trash.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; +import 'file_ops.dart' show isSameOrInside, samePath; + +/// X-plore 비교 e (관리자 결정 10/18): Android 앱 휴지통. Windows 는 시스템 휴지통 그대로. +/// - 저장 장치마다 맨 위의 `.jj_trash` 폴더 (같은 저장 장치 안에서 이름만 바꿔 옮긴다 - 빠르고 공간이 들지 않는다). +/// - 항목마다 `<시각>_<무작위>/` 폴더에 원래 것 + `info.json` (원래 경로 · 지운 때 · 폴더인지 · 크기). +/// - `.nomedia` 를 두어 갤러리 · 음악 앱이 휴지통의 그림 · 동영상을 보이지 않게. +/// - 보관 일 (설정, 처음 14일) 이 지나면 지운다. 0 = 휴지통 끔 (늘 영구 삭제, 예전처럼). +const trashFolderName = '.jj_trash'; + +class TrashItem { + /// 휴지통 안의 항목 폴더 (`.jj_trash/`) + final String entryDir; + + /// 원래 경로 + final String original; + final DateTime deletedAt; + final bool isDir; + final int size; + const TrashItem({required this.entryDir, required this.original, required this.deletedAt, required this.isDir, required this.size}); + + String get name => p.basename(original); + + /// 휴지통 안에 있는 원래 것 + String get stored => p.join(entryDir, name); +} + +/// 휴지통으로 옮기지 못함 (다른 저장 장치 · 권한 · 공간). 부르는 쪽이 영구 삭제할지 묻는다 +class TrashMoveException implements Exception { + final String path; + final Object cause; + const TrashMoveException(this.path, this.cause); + @override + String toString() => trf('휴지통으로 옮기지 못했습니다: {0} ({1})', [p.basename(path), cause]); +} + +class AppTrash { + /// 저장 장치 맨 위들 (Android: /storage/emulated/0 · /storage/XXXX-XXXX) + final List volumeRoots; + AppTrash(this.volumeRoots); + + /// [path] 가 있는 저장 장치의 휴지통 폴더 (어느 저장 장치에도 없으면 null) + String? trashDirFor(String path) { + String? best; + for (final r in volumeRoots) { + if (isSameOrInside(path, r) && (best == null || r.length > best.length)) best = r; + } + return best == null ? null : p.join(best, trashFolderName); + } + + /// 휴지통 안의 것인지 (휴지통 폴더 자신 포함 - 다시 휴지통으로 보내지 않게) + bool isInTrash(String path) { + final t = trashDirFor(path); + return t != null && isSameOrInside(path, t); + } + + static Future _sizeOf(String path) async { + final t = await FileSystemEntity.type(path, followLinks: false); + if (t == FileSystemEntityType.file) return File(path).length(); + if (t != FileSystemEntityType.directory) return 0; + var n = 0; + await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) { + if (e is File) { + try { + n += await e.length(); + } catch (_) {} + } + } + return n; + } + + /// [path] 를 휴지통으로 (같은 저장 장치 안 이름 바꾸기). 못 옮기면 [TrashMoveException] - 원래 것은 그대로 + Future moveToTrash(String path, {DateTime? now}) async { + final trash = trashDirFor(path); + if (trash == null) throw TrashMoveException(path, tr('저장 장치를 알 수 없습니다')); + final when = now ?? DateTime.now(); + final id = '${when.millisecondsSinceEpoch}_${Random().nextInt(1 << 30).toRadixString(36)}'; + final entry = Directory(p.join(trash, id)); + final isDir = await FileSystemEntity.isDirectory(path); + final size = await _sizeOf(path); + try { + await entry.create(recursive: true); + final nomedia = File(p.join(trash, '.nomedia')); + if (!await nomedia.exists()) await nomedia.writeAsString(''); + // 정보 먼저 (옮긴 뒤 앱이 꺼져도 어디서 왔는지 남게) + final info = TrashItem(entryDir: entry.path, original: path, deletedAt: when, isDir: isDir, size: size); + await File(p.join(entry.path, 'info.json')).writeAsString(jsonEncode({ + 'original': path, + 'deletedAt': when.toIso8601String(), + 'isDir': isDir, + 'size': size, + })); + if (isDir) { + await Directory(path).rename(info.stored); + } else { + await File(path).rename(info.stored); + } + return info; + } catch (e) { + // 옮기지 못했으면 (이름 바꾸기가 마지막 일이라 원래 것은 제자리) 만든 항목 폴더만 지운다 + try { + if (await entry.exists()) await entry.delete(recursive: true); + } catch (_) {} + throw TrashMoveException(path, e); + } + } + + /// 휴지통의 항목들 (지운 때가 최근인 것부터) + Future> list() async { + final out = []; + for (final r in volumeRoots) { + final dir = Directory(p.join(r, trashFolderName)); + if (!await dir.exists()) continue; + await for (final e in dir.list(followLinks: false).handleError((_) {})) { + if (e is! Directory) continue; + try { + final j = jsonDecode(await File(p.join(e.path, 'info.json')).readAsString()) as Map; + out.add(TrashItem( + entryDir: e.path, + original: j['original'] as String, + deletedAt: DateTime.parse(j['deletedAt'] as String), + isDir: j['isDir'] == true, + size: (j['size'] as num?)?.toInt() ?? 0, + )); + } catch (_) { + // 정보가 없는 항목 (깨짐) 은 건너뛴다 - 지우지 않는다 + } + } + } + out.sort((a, b) => b.deletedAt.compareTo(a.deletedAt)); + return out; + } + + /// 되살리기: 원래 자리로. 그 자리에 같은 이름이 있으면 "이름 (되살림).확장자". 돌려준 것은 되살린 경로 + Future restore(TrashItem item) async { + var target = item.original; + if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) { + final ext = item.isDir ? '' : p.extension(target); + final base = item.isDir ? target : p.join(p.dirname(target), p.basenameWithoutExtension(target)); + target = '$base (${tr('되살림')})$ext'; + for (var i = 2; await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound; i++) { + target = '$base (${tr('되살림')} $i)$ext'; + } + } + await Directory(p.dirname(target)).create(recursive: true); + if (item.isDir) { + await Directory(item.stored).rename(target); + } else { + await File(item.stored).rename(target); + } + await Directory(item.entryDir).delete(recursive: true); + return target; + } + + /// 영구 삭제 + Future purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true); + + /// 보관 일이 지난 것을 지운다. 지운 수 + Future purgeOlderThan(int days, {DateTime? now}) async { + final limit = (now ?? DateTime.now()).subtract(Duration(days: days)); + var n = 0; + for (final x in await list()) { + if (x.deletedAt.isBefore(limit)) { + try { + await purge(x); + n++; + } catch (_) {} + } + } + return n; + } + + /// 휴지통 크기 합 + Future totalSize() async => (await list()).fold(0, (a, b) => a + b.size); + + /// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에) + bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName))); +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 6587721..b033aee 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -31,6 +31,7 @@ import 'rsync_setup.dart'; import 'theme.dart'; import 'file_error.dart'; import 'folder_picker.dart' show pickFolderOrDav; +import 'trash_page.dart'; import 'dav_external.dart'; import '../platform/windows/recycle_bin.dart'; import '../core/sync_preview.dart'; @@ -51,6 +52,7 @@ import 'archive_ui.dart'; import 'storage_page.dart'; import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; import 'batch_rename_dialog.dart'; +import '../core/app_trash.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; @@ -92,6 +94,10 @@ class ExplorerPage extends StatefulWidget { @visibleForTesting static bool? debugAndroidPaths; + /// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기 + @visibleForTesting + static bool? debugAndroidTrash; + /// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유) @visibleForTesting static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input); @@ -165,7 +171,9 @@ enum ExplorerButton { layout(Icons.view_quilt_outlined, '창 배치'), buttons(Icons.tune, '버튼 구성'), // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 - storage(Icons.pie_chart_outline, '저장 공간 분석'); + storage(Icons.pie_chart_outline, '저장 공간 분석'), + // X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) + trash(Icons.restore_from_trash_outlined, '휴지통'); final IconData icon; final String label; @@ -433,6 +441,8 @@ class _ExplorerPageState extends State { Future _init() async { _local = await _loadVolumes(); if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; + // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) + if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); if (Platform.isAndroid) unawaited(_checkAccess()); final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 @@ -1781,6 +1791,10 @@ class _ExplorerPageState extends State { /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other'; + /// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말) + String _folderName(String dir) => + _volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _displayPath(dir) : vBasename(dir))).$2; + /// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더] Widget _targetBar() { final t = _panes[1 - _active]; @@ -1794,7 +1808,7 @@ class _ExplorerPageState extends State { Expanded( child: Tooltip( message: _displayPath(t.current), - child: Text(trf('복사 · 이동 갈 곳: {0}', [_displayPath(t.current)]), + child: Text(trf('복사 · 이동 갈 곳: {0}', [_folderName(t.current)]), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), ), ), @@ -1957,6 +1971,8 @@ class _ExplorerPageState extends State { await _search(pane); case ExplorerButton.storage: await _storage(pane, pane.current); + case ExplorerButton.trash: + await _openTrash(); case ExplorerButton.sort: await _sortDialog(); case ExplorerButton.hidden: @@ -2099,7 +2115,10 @@ class _ExplorerPageState extends State { } // 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다) final local = Platform.isWindows && paths.every((x) => !isDav(x)); - final canRecycle = local && c.settings.recycleOnDelete && paths.every(hasRecycleBin); + // X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔) + final trash = _appTrash; + final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x)); + final canRecycle = (local && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash; var permanent = !canRecycle || shift; final ok = await showDialog( context: context, @@ -2133,12 +2152,18 @@ class _ExplorerPageState extends State { child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), + if (canTrash && !permanent) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), if (canRecycle) CheckboxListTile( contentPadding: EdgeInsets.zero, value: permanent, onChanged: (v) => set(() => permanent = v ?? false), - title: Text(tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), + title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), ), ]), actions: [ @@ -2161,7 +2186,19 @@ class _ExplorerPageState extends State { // 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터 final since = DateTime.now().subtract(const Duration(seconds: 2)); final sent = []; + // X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다) + final trashed = []; + final notTrashed = <(String, String)>[]; for (final x in paths) { + if (!permanent && canTrash) { + try { + trashed.add(await trash.moveToTrash(x)); + recycled++; + } on TrashMoveException catch (e) { + notTrashed.add((x, '${e.cause}')); + } + continue; + } try { if (!permanent) { switch (recycle(x)) { @@ -2182,6 +2219,39 @@ class _ExplorerPageState extends State { failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e')); } } + // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 + if (notTrashed.isNotEmpty && mounted) { + final nuke = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + icon: const Icon(Icons.warning_amber_rounded, color: Colors.orangeAccent), + title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)), + const SizedBox(height: 8), + Text(tr('영구 삭제하면 되살릴 수 없습니다.')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + onPressed: () => Navigator.pop(ctx, true), + child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length])), + ), + ], + ), + ); + if (nuke == true) { + for (final (x, _) in notTrashed) { + try { + await FileOps().delete([x]); + } catch (e) { + failed.add((x, '$e')); + } + } + } + } if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) { final nukeLong = await showDialog( context: context, @@ -2248,7 +2318,11 @@ class _ExplorerPageState extends State { ScaffoldMessenger.of(context).showSnackBar(SnackBar( duration: const Duration(seconds: 10), content: Text(text), - action: sent.isEmpty ? null : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), + action: trashed.isNotEmpty + ? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed)) + : sent.isEmpty + ? null + : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), )); } for (final x in _panes) { @@ -2260,6 +2334,45 @@ class _ExplorerPageState extends State { await _refreshAll(paths.map(p.dirname)); } + /// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제) + AppTrash? get _appTrash => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.androidTrashDays > 0 + ? AppTrash([for (final (v, _) in _local) v]) + : null; + + /// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로 + Future _undoTrash(List items) async { + final trash = _appTrash; + if (trash == null) return; + final back = []; + for (final i in items) { + try { + back.add(await trash.restore(i)); + } catch (_) {} + } + await _refreshAll(back.map(p.dirname)); + _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); + } + + /// 시험: [휴지통] 열기 + @visibleForTesting + Future debugOpenTrash() => _openTrash(); + + /// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통 + Future _openTrash() async { + final trash = _appTrash; + if (trash == null) { + if (Platform.isWindows) { + await Process.start('explorer.exe', ['shell:RecycleBinFolder']); + } else { + _snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).')); + } + return; + } + final back = await Navigator.of(context).push>( + MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays))); + if (back != null && back.isNotEmpty && mounted) await _refreshAll(back.map(p.dirname)); + } + /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { final failed = []; diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 20a9a16..a6b086d 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -838,6 +838,20 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!), ), ), + // X-plore 비교 e: Android 앱 휴지통 보관 일 + if (Platform.isAndroid) + SettingTile( + title: Text(tr('휴지통 보관')), + subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')), + trailing: DropdownButton( + value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14, + items: [ + for (final d in const [0, 3, 7, 14, 30, 60]) + DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))), + ], + onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!), + ), + ), // X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙 SwitchListTile( title: Text(tr('X-plore 스타일: 표시 동그라미 늘 보이기')), diff --git a/lib/ui/trash_page.dart b/lib/ui/trash_page.dart new file mode 100644 index 0000000..37dad70 --- /dev/null +++ b/lib/ui/trash_page.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../core/app_trash.dart'; +import '../core/file_ops.dart' show formatSize; +import '../l10n/tr.dart'; +import 'confirm.dart'; +import 'theme.dart'; + +/// X-plore 비교 e: 앱 휴지통 화면 (Android) - 항목 · 원래 자리 · 지운 때, 골라서 되살리기 · 영구 삭제, 모두 비우기. +/// 돌려주는 값: 되살린 경로들 (탐색기가 그 폴더를 새로 읽는다) +class TrashPage extends StatefulWidget { + final AppTrash trash; + + /// 보관 일 (0 = 끔) - 안내 줄에 + final int days; + const TrashPage({super.key, required this.trash, required this.days}); + + @override + State createState() => _TrashPageState(); +} + +class _TrashPageState extends State { + List? _items; + final _picked = {}; + final _restored = []; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final items = await widget.trash.list(); + if (!mounted) return; + setState(() { + _items = items; + _picked.removeWhere((x) => !items.any((i) => i.entryDir == x)); + }); + } + + List get _chosen => [for (final i in _items ?? const []) if (_picked.contains(i.entryDir)) i]; + + void _say(String t) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); + + Future _restore(List items) async { + setState(() => _busy = true); + var n = 0; + final fails = []; + for (final i in items) { + try { + _restored.add(await widget.trash.restore(i)); + n++; + } catch (e) { + fails.add('${i.name}: $e'); + } + } + await _load(); + if (!mounted) return; + setState(() => _busy = false); + _say([ + if (n > 0) trf('{0}개를 원래 자리로 되살렸습니다', [n]), + if (fails.isNotEmpty) trf('되살리지 못함: {0}', [fails.take(3).join(' · ')]), + ].join(' · ')); + } + + Future _purge(List items, {bool all = false}) async { + final ok = await confirmAction( + context, + title: all ? tr('휴지통 비우기') : trf('{0}개를 영구 삭제할까요?', [items.length]), + body: tr('휴지통에서 지운 것은 되살릴 수 없습니다.'), + ok: tr('영구 삭제'), + ); + if (!ok || !mounted) return; + setState(() => _busy = true); + for (final i in items) { + try { + await widget.trash.purge(i); + } catch (_) {} + } + await _load(); + if (mounted) setState(() => _busy = false); + } + + String _date(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final items = _items; + final chosen = _chosen; + final total = (items ?? const []).fold(0, (a, b) => a + b.size); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) Navigator.pop(context, _restored); + }, + child: Scaffold( + appBar: AppBar( + title: Text(items == null ? tr('휴지통') : trf('휴지통 · {0}개 · {1}', [items.length, formatSize(total)])), + actions: [ + if (items != null && items.isNotEmpty) + TextButton.icon( + onPressed: _busy ? null : () => _purge(items, all: true), + icon: const Icon(Icons.delete_forever_outlined, color: JjColors.danger), + label: Text(tr('비우기'), style: const TextStyle(color: JjColors.danger)), + ), + ], + ), + body: Column(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + widget.days > 0 + ? trf('지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).', [widget.days]) + : tr('휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ), + if (_busy) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: items == null + ? const Center(child: CircularProgressIndicator()) + : items.isEmpty + ? Center(child: Text(tr('휴지통이 비어 있습니다'))) + : ListView.builder( + itemCount: items.length, + itemBuilder: (_, k) { + final i = items[k]; + final on = _picked.contains(i.entryDir); + return CheckboxListTile( + value: on, + onChanged: (v) => setState(() => v == true ? _picked.add(i.entryDir) : _picked.remove(i.entryDir)), + secondary: Icon(i.isDir ? Icons.folder : Icons.insert_drive_file_outlined, + color: i.isDir ? Colors.amber : null), + title: Text(i.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${i.original}\n${_date(i.deletedAt)} · ${formatSize(i.size)}', + maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + isThreeLine: true, + ); + }, + ), + ), + if (chosen.isNotEmpty) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row(children: [ + Expanded(child: Text(trf('{0}개 고름', [chosen.length]))), + TextButton( + onPressed: _busy ? null : () => _purge(chosen), + child: Text(tr('영구 삭제'), style: const TextStyle(color: JjColors.danger)), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _busy ? null : () => _restore(chosen), + icon: const Icon(Icons.restore), + label: Text(tr('되살리기')), + ), + ]), + ), + ), + ]), + ), + ); + } +} diff --git a/test/app_trash_test.dart b/test/app_trash_test.dart new file mode 100644 index 0000000..dfed207 --- /dev/null +++ b/test/app_trash_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/app_trash.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 e: Android 앱 휴지통 (저장 장치의 .jj_trash) - Windows 임시 폴더로 시험 +void main() { + late Directory tmp; + late AppTrash trash; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_trash_e_'); + trash = AppTrash([p.join(tmp.path, 'vol')]); + Directory(p.join(tmp.path, 'vol', 'a', 'sub')).createSync(recursive: true); + File(p.join(tmp.path, 'vol', 'a', 'f.txt')).writeAsStringSync('hello'); + File(p.join(tmp.path, 'vol', 'a', 'sub', 'g.txt')).writeAsStringSync('gg'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('파일 · 폴더를 휴지통으로 (같은 저장 장치의 .jj_trash, .nomedia) → 목록 → 되살리기 (자리에 같은 이름이면 "(되살림)")', () async { + final f = p.join(tmp.path, 'vol', 'a', 'f.txt'); + final sub = p.join(tmp.path, 'vol', 'a', 'sub'); + final i1 = await trash.moveToTrash(f, now: DateTime(2026, 10, 1)); + final i2 = await trash.moveToTrash(sub, now: DateTime(2026, 10, 2)); + expect(File(f).existsSync(), isFalse); + expect(Directory(sub).existsSync(), isFalse); + expect(File(p.join(tmp.path, 'vol', trashFolderName, '.nomedia')).existsSync(), isTrue); + expect(trash.isInTrash(i1.stored), isTrue); + final list = await trash.list(); + expect(list.map((x) => x.original), [sub, f], reason: '최근 것부터'); + expect((list.last.size, list.first.isDir, list.first.size), (5, true, 2)); + // 자리에 같은 이름이 새로 생겼으면 그것은 두고 다른 이름으로 + File(f).writeAsStringSync('new'); + final back = await trash.restore(list.last); + expect(p.basename(back), 'f (되살림).txt'); + expect(File(back).readAsStringSync(), 'hello'); + expect(File(f).readAsStringSync(), 'new'); + expect(await trash.restore(i2), sub); + expect(File(p.join(sub, 'g.txt')).readAsStringSync(), 'gg'); + expect(await trash.list(), isEmpty); + }); + + test('보관 일이 지난 것만 지운다 · 영구 삭제 · 저장 장치 밖 · 휴지통 안의 것은 다시 보내지 않음', () async { + final f = p.join(tmp.path, 'vol', 'a', 'f.txt'); + final g = p.join(tmp.path, 'vol', 'a', 'sub', 'g.txt'); + await trash.moveToTrash(f, now: DateTime(2026, 9, 1)); + await trash.moveToTrash(g, now: DateTime(2026, 10, 15)); + expect(await trash.purgeOlderThan(14, now: DateTime(2026, 10, 18)), 1); + final left = await trash.list(); + expect(left.single.original, g); + await trash.purge(left.single); + expect(await trash.list(), isEmpty); + // 어느 저장 장치에도 없는 경로 → 옮기지 못함 (원래 것 그대로) + final out = File(p.join(tmp.path, 'outside.txt'))..writeAsStringSync('x'); + expect(() => trash.moveToTrash(out.path), throwsA(isA())); + expect(out.existsSync(), isTrue); + expect(trash.isInTrash(p.join(tmp.path, 'vol', trashFolderName, 'x')), isTrue); + expect(trash.isTrashRoot(p.join(tmp.path, 'vol', trashFolderName)), isTrue); + }); +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 3da9d30..7bb7e8f 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -142,7 +143,7 @@ void main() { ..explorerOnePaneCopy = 'other'; await open(tester); expect(find.textContaining('복사 · 이동 갈 곳'), findsOneWidget); - expect(find.textContaining(right), findsWidgets, reason: '숨은 창의 폴더'); + expect(find.textContaining('갈 곳: right'), findsOneWidget, reason: '숨은 창의 폴더 이름 (전체 경로는 풍선 도움말)'); await act(tester, () => tester.tap(find.text('doc.txt'))); await settle(tester, () => false, rounds: 15); await act(tester, () => tester.tap(find.text('복사').first)); @@ -252,6 +253,54 @@ void main() { await tester.pump(const Duration(seconds: 1)); }); + testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async { + // 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게) + ExplorerPage.debugAndroidTrash = true; + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; + addTearDown(() { + ExplorerPage.debugAndroidTrash = null; + ExplorerPage.debugVolumes = null; + }); + await open(tester); + final doc = p.join(left, 'doc.txt'); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + expect(find.textContaining('앱 휴지통에 14일'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !File(doc).existsSync() && find.text('되돌리기').evaluate().isNotEmpty); + expect(File(doc).existsSync(), isFalse); + expect(Directory(p.join(tmp.path, '.jj_trash')).existsSync(), isTrue); + // [되돌리기] + await act(tester, () => tester.tap(find.text('되돌리기'))); + await settle(tester, () => File(doc).existsSync()); + expect(File(doc).readAsStringSync(), 'hello'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + // 다시 지우고 [휴지통] 화면에서 되살리기 + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !File(doc).existsSync()); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + // 휴지통 화면을 연다 (닫힐 때까지 기다리는 Future 라 기다리지 않는다) + unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future); + await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byType(CheckboxListTile).first)); + await calm(tester); + await act(tester, () => tester.tap(find.text('되살리기'))); + await settle(tester, () => File(doc).existsSync()); + expect(File(doc).readAsStringSync(), 'hello'); + await tester.pageBack(); + await calm(tester); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 2)); + }); + testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async { c.settings ..copyMethodFile = 'robocopy' @@ -1259,16 +1308,24 @@ void main() { expect(find.text('폴더 열기'), findsOneWidget, reason: '끝난 알림에 [폴더 열기]'); // 패널은 끝난 뒤 잠깐 남는다 - [숨기기] expect(find.text('숨기기'), findsOneWidget); + // 패널 · 작은 표시는 끝나고 1.2초 (시험 시계) 뒤 내려간다. pumpAndSettle (calm) 은 애니메이션이 있는 동안 시계를 그만큼 + // 넘길 수 있어 (PC 가 바쁠 때 흔들림 - 관리자 지시), 정해진 횟수 대신 조건이 될 때까지 시계를 조금씩만 돌린다 + Future until(bool Function() done) async { + for (var i = 0; i < 100 && !done(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 10)); + } + } + await act(tester, () => tester.tap(find.text('숨기기'))); - await calm(tester); + await until(() => c.settings.explorerProgressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty); expect(c.settings.explorerProgressHidden, isTrue, reason: '숨김은 기억'); expect(find.text('숨기기'), findsNothing); expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시'); - // (아래 알림이 잠깐 작은 표시를 덮는다 - 알림을 닫고 누름) + // 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다) + tester.widget(find.byType(TransferProgressChip)).onTap(); + await until(() => !c.settings.explorerProgressHidden); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - await act(tester, () => tester.tap(find.text('복사 · 이동 끝'))); - await calm(tester); expect(c.settings.explorerProgressHidden, isFalse, reason: '누르면 다시 펼침'); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await calm(tester);