diff --git a/assets/l10n/en.json b/assets/l10n/en.json index ae353cc..0552a52 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -321,6 +321,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts", + "WebDAV 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV reads everything inside from the server, which can take a long time. Analyze?", "WebDAV 서버 \"{0}\" 을(를) 지울까요?": "Delete WebDAV server \"{0}\"?", "WebDAV 서버 고치기": "Edit WebDAV server", "WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "The WebDAV server list and its saved passwords are deleted too. The video list, bookmarks and files are kept", @@ -435,6 +436,7 @@ "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} · {1}배 · {2}": "{0} · {1}× · {2}", "{0} · 링크 {1}개 제외": "{0} · {1} links excluded", + "{0} · 파일 {1}개": "{0} · {1} files", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", "{0} 는 이미 끝났습니다": "{0} has already finished", "{0} 라이선스": "{0} license", @@ -510,6 +512,7 @@ "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.", "{0}개 항목을 휴지통으로 보낼까요?": "Move {0} items to the Recycle Bin?", "{0}개 항목을 휴지통으로 보냈습니다.": "Moved {0} items to the Recycle Bin.", + "{0}개는 더 새 파일이라 그대로 둠": "{0} kept (existing file is newer)", "{0}개는 원본에 남음 (건너뜀)": "{0} left in the source (skipped)", "{0}개는 지우지 않았습니다 (취소).": "{0} were not deleted (cancelled).", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).", @@ -924,6 +927,8 @@ "덮어쓰기 (원래 파일은 없어집니다)": "Overwrite (the existing file is replaced)", "덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "Even when overwriting, the old file is replaced only after the new one is finished (if cancelled or failed, the old MKV stays)", "덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "Even when overwriting, the old file is replaced only after the new one is finished. If cancelled or failed, the old MKV stays. To always do the same: Settings > Make MKV > If the MKV already exists", + "덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).": "Overwritten files can't be restored (files that are newer at the destination are kept).", + "덮어쓴 파일은 되돌릴 수 없습니다.": "Overwritten files can't be restored.", "덮어씁니다": "overwrites it", "덴마크어": "Danish", "독일어": "German", @@ -1004,6 +1009,7 @@ "링크 {0} (건너뜀)": "Links {0} (skipped)", "링크 {0}개는 넣지 않음": "{0} links not included", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} links will be skipped (not created): {1}", + "링크 {0}개는 세지 않음": "{0} links not counted", "링크 · junction 은 넣지 않습니다": "Links and junctions are not included", "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", @@ -1270,8 +1276,10 @@ "복사했습니다.": "copied.", "볼 그림이 없습니다.": "No images to show.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", + "분석": "Analyze", "분석 실패: {0} - {1}": "Analysis failed: {0} - {1}", "분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "Re-analyzed {0} videos that had failed", + "분석하지 못했습니다: {0}": "Couldn't analyze: {0}", "붙여넣기": "Paste", "붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.": "Nothing to paste. First hold items with [Copy] · [Move].", "브라우저": "Browser", @@ -1505,6 +1513,7 @@ "아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}", "아직 허용되지 않았습니다": "Not allowed yet", "안내 닫기": "Close tip", + "안에 폴더가 없습니다": "No folders inside", "안쪽 파일을 확인하는 중 …": "Checking files inside …", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", @@ -1893,12 +1902,14 @@ "일본어": "Japanese", "일시정지": "Pause", "읽기 전용 매체입니다": "Read-only media", + "읽는 중 · 폴더 {0}개 · {1}": "Reading · {0} folders · {1}", "읽을 수 없습니다": "Cannot read", "읽을 수 없습니다: {0}": "Cannot read: {0}", "읽을 수 없어 건너뛴 폴더: {0}": "Skipped folders that could not be read: {0}", "읽을 수 없음": "Cannot read", "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "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)", "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 (가로로 긴 그림만)": "Auto (wide images only)", @@ -1986,6 +1997,7 @@ "저장": "Save", "저장 (MKV 반영)": "Save (apply to MKV)", "저장 (MKV 반영) *": "Save (apply to MKV) *", + "저장 공간 분석": "Storage analysis", "저장 공간 정리": "Storage cleanup", "저장 공간 정리: 파일 {0}개 · {1}": "Storage cleanup: {0} files · {1}", "저장 공간이 모자랍니다": "Not enough storage space", @@ -2269,6 +2281,9 @@ "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)", "크메르어": "Khmer", + "큰 파일": "Large files", + "큰 파일이 없습니다": "No large files", + "큰 폴더": "Large folders", "키 있음": "Key saved", "타갈로그어": "Tagalog", "타밀어": "Tamil", @@ -2290,6 +2305,7 @@ "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "Click the JJ_MKVMaker icon in the tray to open it.", "파일": "File", "파일 {0}/{1}": "Files {0}/{1}", + "파일 {0}개": "{0} files", "파일 {0}개 · {1}": "{0} files · {1}", "파일 관리자에서 보기": "Show in file manager", "파일 복사 · 이동 방법": "File copy · move method", @@ -2382,6 +2398,8 @@ "표준": "Standard", "푸는 중": "Extracting", "푸는 중: {0}": "Extracting: {0}", + "풀 수 없는 이름 {0}개": "{0} invalid names", + "풀 수 없는 이름 {0}개 (건너뜀): {1}": "{0} names can't be used on this device (skipped): {1}", "풀기": "Extract", "풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "The extracted size ({0}) is larger than the free space ({1}). Extract anyway?", "풀지 못했습니다: {0}": "Could not extract: {0}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index c3d54c9..d7412a5 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -321,6 +321,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", + "WebDAV 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?", "WebDAV 서버 \"{0}\" 을(를) 지울까요?": "WebDAV サーバー「{0}」を削除しますか?", "WebDAV 서버 고치기": "WebDAV サーバーを編集", "WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "WebDAV サーバー一覧とその保存したパスワードも消えます。動画リスト・お気に入り・ファイルはそのままです", @@ -435,6 +436,7 @@ "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", "{0} · 링크 {1}개 제외": "{0} · リンク {1} 件を除く", + "{0} · 파일 {1}개": "{0} · ファイル {1} 件", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", "{0} 는 이미 끝났습니다": "{0} はすでに終わりました", "{0} 라이선스": "{0} ライセンス", @@ -510,6 +512,7 @@ "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。", "{0}개 항목을 휴지통으로 보낼까요?": "{0} 個の項目をごみ箱に移動しますか?", "{0}개 항목을 휴지통으로 보냈습니다.": "{0} 個の項目をごみ箱に移動しました。", + "{0}개는 더 새 파일이라 그대로 둠": "{0} 件は新しいファイルなのでそのまま", "{0}개는 원본에 남음 (건너뜀)": "{0} 件は元の場所に残っています (スキップ)", "{0}개는 지우지 않았습니다 (취소).": "{0} 個は削除しませんでした (キャンセル)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。", @@ -924,6 +927,8 @@ "덮어쓰기 (원래 파일은 없어집니다)": "上書き (元のファイルは消えます)", "덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "上書きでも作り終えてから置き換えます (途中でキャンセル · 失敗すると以前の MKV はそのまま)", "덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "上書きでも作り終えてから置き換えます。途中でキャンセル · 失敗すると以前の MKV はそのまま残ります。いつも同じにするには: 環境設定 > MKV 作成 > 作る MKV がすでにあるとき", + "덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).": "上書きしたファイルは元に戻せません (受け取る側が新しいファイルはそのまま残します)。", + "덮어쓴 파일은 되돌릴 수 없습니다.": "上書きしたファイルは元に戻せません。", "덮어씁니다": "上書きします", "덴마크어": "デンマーク語", "독일어": "ドイツ語", @@ -1004,6 +1009,7 @@ "링크 {0} (건너뜀)": "リンク {0} (スキップ)", "링크 {0}개는 넣지 않음": "リンク {0} 件は含めず", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}", + "링크 {0}개는 세지 않음": "リンク {0} 件は数えず", "링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", @@ -1270,8 +1276,10 @@ "복사했습니다.": "コピーしました。", "볼 그림이 없습니다.": "表示する画像がありません。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", + "분석": "分析", "분석 실패: {0} - {1}": "分析失敗: {0} - {1}", "분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "分析に失敗していた動画 {0} 件を再分析しました", + "분석하지 못했습니다: {0}": "分析できませんでした: {0}", "붙여넣기": "貼り付け", "붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.": "貼り付ける項目がありません。先に [コピー] · [移動] で保持してください。", "브라우저": "ブラウザー", @@ -1505,6 +1513,7 @@ "아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}", "아직 허용되지 않았습니다": "まだ許可されていません", "안내 닫기": "案内を閉じる", + "안에 폴더가 없습니다": "中にフォルダーがありません", "안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", @@ -1893,12 +1902,14 @@ "일본어": "日本語", "일시정지": "一時停止", "읽기 전용 매체입니다": "読み取り専用メディアです", + "읽는 중 · 폴더 {0}개 · {1}": "読み込み中 · フォルダー {0} 件 · {1}", "읽을 수 없습니다": "読み込めません", "읽을 수 없습니다: {0}": "読み込めません: {0}", "읽을 수 없어 건너뛴 폴더: {0}": "読み込めずスキップしたフォルダー: {0}", "읽을 수 없음": "読み取れません", "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "読めなかったフォルダー {0} 個 (権限・接続の問題) - その中は探せませんでした", + "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "読めなかったフォルダー {0} 件 (サイズが実際より小さいことがあります)", "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 (가로로 긴 그림만)": "自動 (横長の画像のみ)", @@ -1986,6 +1997,7 @@ "저장": "保存", "저장 (MKV 반영)": "保存(MKV に反映)", "저장 (MKV 반영) *": "保存(MKV に反映)*", + "저장 공간 분석": "ストレージ分析", "저장 공간 정리": "ストレージの整理", "저장 공간 정리: 파일 {0}개 · {1}": "ストレージの整理: ファイル {0}個・{1}", "저장 공간이 모자랍니다": "保存容量が足りません", @@ -2269,6 +2281,9 @@ "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", "크메르어": "クメール語", + "큰 파일": "大きいファイル", + "큰 파일이 없습니다": "大きいファイルはありません", + "큰 폴더": "大きいフォルダー", "키 있음": "キーあり", "타갈로그어": "タガログ語", "타밀어": "タミル語", @@ -2290,6 +2305,7 @@ "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "トレイの JJ_MKVMaker アイコンをクリックすると開きます。", "파일": "ファイル", "파일 {0}/{1}": "ファイル {0}/{1}", + "파일 {0}개": "ファイル {0} 件", "파일 {0}개 · {1}": "ファイル {0} 件 · {1}", "파일 관리자에서 보기": "ファイルマネージャーで表示", "파일 복사 · 이동 방법": "ファイルのコピー · 移動方法", @@ -2382,6 +2398,8 @@ "표준": "標準", "푸는 중": "展開中", "푸는 중: {0}": "展開中: {0}", + "풀 수 없는 이름 {0}개": "展開できない名前 {0} 件", + "풀 수 없는 이름 {0}개 (건너뜀): {1}": "展開できない名前 {0} 件 (スキップ): {1}", "풀기": "展開", "풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "展開後のサイズ ({0}) が空き容量 ({1}) より大きいです。それでも展開しますか?", "풀지 못했습니다: {0}": "展開できませんでした: {0}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 25e3497..4640212 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -321,6 +321,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入", + "WebDAV 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV 需要从服务器读取全部内容,可能需要较长时间。要分析吗?", "WebDAV 서버 \"{0}\" 을(를) 지울까요?": "删除 WebDAV 服务器“{0}”吗?", "WebDAV 서버 고치기": "编辑 WebDAV 服务器", "WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "WebDAV 服务器列表及其保存的密码也会被删除。视频列表 · 收藏 · 文件保持不变", @@ -435,6 +436,7 @@ "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", "{0} · 링크 {1}개 제외": "{0} · 已排除 {1} 个链接", + "{0} · 파일 {1}개": "{0} · {1} 个文件", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", "{0} 는 이미 끝났습니다": "{0} 已经完成", "{0} 라이선스": "{0} 许可证", @@ -510,6 +512,7 @@ "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。", "{0}개 항목을 휴지통으로 보낼까요?": "将 {0} 个项目移到回收站吗?", "{0}개 항목을 휴지통으로 보냈습니다.": "已将 {0} 个项目移到回收站。", + "{0}개는 더 새 파일이라 그대로 둠": "{0} 个因现有文件更新而保留", "{0}개는 원본에 남음 (건너뜀)": "{0} 个留在源中(已跳过)", "{0}개는 지우지 않았습니다 (취소).": "{0} 个未删除 (已取消)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。", @@ -924,6 +927,8 @@ "덮어쓰기 (원래 파일은 없어집니다)": "覆盖 (原文件将被替换)", "덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "即使覆盖,也只在制作完成后才替换(中途取消 · 失败时保留原来的 MKV)", "덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "即使覆盖,也只在制作完成后才替换。中途取消 · 失败时原来的 MKV 会保留。若要始终相同:设置 > 制作 MKV > 要制作的 MKV 已存在时", + "덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).": "被覆盖的文件无法恢复 (目标处较新的文件会保留)。", + "덮어쓴 파일은 되돌릴 수 없습니다.": "被覆盖的文件无法恢复。", "덮어씁니다": "覆盖", "덴마크어": "丹麦语", "독일어": "德语", @@ -1004,6 +1009,7 @@ "링크 {0} (건너뜀)": "链接 {0}(跳过)", "링크 {0}개는 넣지 않음": "{0} 个链接未包含", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}", + "링크 {0}개는 세지 않음": "{0} 个链接未计入", "링크 · junction 은 넣지 않습니다": "不包含链接和 junction", "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", @@ -1270,8 +1276,10 @@ "복사했습니다.": "已复制。", "볼 그림이 없습니다.": "没有可显示的图片。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", + "분석": "分析", "분석 실패: {0} - {1}": "分析失败: {0} - {1}", "분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "已重新分析之前分析失败的 {0} 个视频", + "분석하지 못했습니다: {0}": "无法分析: {0}", "붙여넣기": "粘贴", "붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.": "没有可粘贴的项目。请先用 [复制] · [移动] 暂存。", "브라우저": "浏览器", @@ -1505,6 +1513,7 @@ "아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}", "아직 허용되지 않았습니다": "尚未允许", "안내 닫기": "关闭提示", + "안에 폴더가 없습니다": "里面没有文件夹", "안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", @@ -1893,12 +1902,14 @@ "일본어": "日语", "일시정지": "暂停", "읽기 전용 매체입니다": "只读介质", + "읽는 중 · 폴더 {0}개 · {1}": "正在读取 · {0} 个文件夹 · {1}", "읽을 수 없습니다": "无法读取", "읽을 수 없습니다: {0}": "无法读取: {0}", "읽을 수 없어 건너뛴 폴더: {0}": "无法读取而跳过的文件夹: {0}", "읽을 수 없음": "无法读取", "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} 个无法读取的文件夹 (权限 · 连接问题) - 未在其中查找", + "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} 个文件夹无法读取 (大小可能小于实际)", "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 (가로로 긴 그림만)": "自动 (仅横向图片)", @@ -1986,6 +1997,7 @@ "저장": "保存", "저장 (MKV 반영)": "保存(应用到 MKV)", "저장 (MKV 반영) *": "保存(应用到 MKV)*", + "저장 공간 분석": "存储空间分析", "저장 공간 정리": "存储空间清理", "저장 공간 정리: 파일 {0}개 · {1}": "存储空间清理: 文件 {0} 个 · {1}", "저장 공간이 모자랍니다": "存储空间不足", @@ -2269,6 +2281,9 @@ "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", "크메르어": "高棉语", + "큰 파일": "大文件", + "큰 파일이 없습니다": "没有大文件", + "큰 폴더": "大文件夹", "키 있음": "已有密钥", "타갈로그어": "他加禄语", "타밀어": "泰米尔语", @@ -2290,6 +2305,7 @@ "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "点击托盘中的 JJ_MKVMaker 图标即可打开。", "파일": "文件", "파일 {0}/{1}": "文件 {0}/{1}", + "파일 {0}개": "{0} 个文件", "파일 {0}개 · {1}": "{0} 个文件 · {1}", "파일 관리자에서 보기": "在文件管理器中显示", "파일 복사 · 이동 방법": "文件复制 · 移动方式", @@ -2382,6 +2398,8 @@ "표준": "标准", "푸는 중": "正在解压", "푸는 중: {0}": "正在解压: {0}", + "풀 수 없는 이름 {0}개": "{0} 个无法解压的名称", + "풀 수 없는 이름 {0}개 (건너뜀): {1}": "{0} 个名称无法在本设备使用 (跳过): {1}", "풀기": "解压", "풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "解压后大小 ({0}) 大于剩余空间 ({1})。仍要解压吗?", "풀지 못했습니다: {0}": "无法解压: {0}", diff --git a/lib/core/archive_ops.dart b/lib/core/archive_ops.dart index 23cd6b1..416177a 100644 --- a/lib/core/archive_ops.dart +++ b/lib/core/archive_ops.dart @@ -6,7 +6,7 @@ import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; -import 'file_ops.dart' show NameConflict; +import 'file_ops.dart' show NameConflict, nameProblem; /// 86: 압축 종류 (확장자로) enum ArchiveKind { zip, sevenZip, rar } @@ -28,7 +28,11 @@ class ArchiveEntry { /// 7-Zip 에 넘길 원래 이름 (Windows 의 7z 는 '\' 로 보여 준다) final String raw; - const ArchiveEntry(this.name, {this.size = 0, this.isDir = false, this.isLink = false, this.encrypted = false, String? raw}) : raw = raw ?? name; + + /// 압축 안 파일의 바뀐 날 (모르면 null) - 덮어쓰기에서 받는 쪽이 더 새것인지 볼 때 + final DateTime? modified; + const ArchiveEntry(this.name, {this.size = 0, this.isDir = false, this.isLink = false, this.encrypted = false, String? raw, this.modified}) + : raw = raw ?? name; } /// 비밀번호가 필요하거나 틀림 @@ -64,20 +68,26 @@ class ExtractPlan { /// 만들지 않고 건너뛰는 링크 final List links; + /// 이 기기에서 쓸 수 없는 이름 (Windows: CON · NUL · ? * " < > | · 끝 점 · 빈칸 …) - (이름, 까닭), 건너뜀 + final List<(String, String)> badNames; + /// 풀린 크기 합 (바이트) final int totalSize; /// 비밀번호가 걸린 항목이 있음 final bool encrypted; - const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted); + const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted, [this.badNames = const []]); } /// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만 -ExtractPlan planExtract(List entries, {List names = const []}) { +/// 15 지적 · 관리자 판단: 리눅스 · 맥에서 만든 압축의 이름이 Windows 에서 쓸 수 없으면 (78 의 nameProblem) 미리 걸러 건너뛴다 +ExtractPlan planExtract(List entries, {List names = const [], bool? windowsRules}) { + final win = windowsRules ?? Platform.isWindows; bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/')); final files = []; final unsafe = <(String, String)>[]; final links = []; + final badNames = <(String, String)>[]; var size = 0; var enc = false; for (final e in entries) { @@ -92,11 +102,17 @@ ExtractPlan planExtract(List entries, {List names = const continue; } if (e.isDir) continue; + // 이름의 각 부분이 이 기기에서 쓸 수 있는지 (풀다가 실패하거나 7-Zip 이 다른 이름으로 바꾸지 않게) + final why = [for (final s in e.name.split('/')) if (s.isNotEmpty && s != '.') nameProblem(s, windowsRules: win)].nonNulls.firstOrNull; + if (why != null) { + badNames.add((e.name, why)); + continue; + } files.add(e); size += e.size; enc |= e.encrypted; } - return ExtractPlan(files, unsafe, links, size, enc); + return ExtractPlan(files, unsafe, links, size, enc, badNames); } /// 풀 때 [dest] 안의 자리 ([name] 은 [unsafeArchivePath] 를 지난 것) @@ -118,6 +134,25 @@ class ExtractResult { int done = 0; final skippedExisting = []; final renamed = []; + + /// 덮어쓰기인데 받는 쪽이 더 새것이라 그대로 둔 것 (229 와 같은 규칙) + final keptNewer = []; +} + +/// 덮어쓰기에서 건너뛸지: 받는 쪽 파일이 압축 안의 것보다 2초 넘게 새것 +bool _targetNewer(String target, DateTime? modified) { + if (modified == null) return false; + try { + return File(target).lastModifiedSync().isAfter(modified.add(const Duration(seconds: 2))); + } catch (_) { + return false; + } +} + +/// ZIP 의 MS-DOS 날짜 · 시각 (지역 시각) → DateTime (0 이면 null) +DateTime? dosDateTime(int date, int time) { + if (date == 0) return null; + return DateTime(1980 + (date >> 9), (date >> 5) & 0xF, date & 0x1F, time >> 11, (time >> 5) & 0x3F, (time & 0x1F) * 2); } /// 취소 (풀기 · 묶기 도중) @@ -142,7 +177,8 @@ Future> listZip(String path) async { isDir: h.filename.endsWith('/') || h.filename.endsWith('\\'), // 유닉스 파일 종류 (상위 16비트) 가 심볼릭 링크 (0xA000) isLink: (h.externalFileAttributes >> 16) & 0xF000 == 0xA000, - encrypted: h.generalPurposeBitFlag & 0x1 != 0), + encrypted: h.generalPurposeBitFlag & 0x1 != 0, + modified: dosDateTime(h.lastModifiedFileDate, h.lastModifiedFileTime)), ]; } finally { await input.close(); @@ -151,7 +187,11 @@ Future> listZip(String path) async { /// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로 Future extractZipPlan(String path, String dest, ExtractPlan plan, - {NameConflict conflict = NameConflict.rename, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { + {NameConflict conflict = NameConflict.rename, + bool skipNewer = false, + String? password, + void Function(int done, int total)? onProgress, + bool Function()? cancelled}) async { // 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다 if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException(); final input = InputFileStream(path); @@ -160,6 +200,7 @@ Future extractZipPlan(String path, String dest, ExtractPlan plan, // verify: CRC 를 맞춰 보아 비밀번호가 틀리면 (풀린 내용이 다르면) 실패로 final a = ZipDecoder().decodeStream(input, password: password, verify: plan.encrypted); final want = {for (final e in plan.files) e.name}; + final modified = {for (final e in plan.files) e.name: e.modified}; final todo = [for (final f in a.files) if (f.isFile && want.contains(f.name.replaceAll('\\', '/'))) f]; for (final f in todo) { if (cancelled?.call() ?? false) throw const ArchiveCancelled(); @@ -173,6 +214,11 @@ Future extractZipPlan(String path, String dest, ExtractPlan plan, target = _freeName(target); r.renamed.add(f.name); } + // 관리자 판단: 덮어쓰기에도 "받는 쪽이 더 새것이면 건너뛰기" (229) + if (conflict == NameConflict.overwrite && skipNewer && _targetNewer(target, modified[f.name.replaceAll('\\', '/')])) { + r.keptNewer.add(f.name); + continue; + } } await Directory(p.dirname(target)).create(recursive: true); final bytes = f.readBytes(); @@ -270,6 +316,7 @@ List parse7zList(String out) { entries.add(ArchiveEntry(raw.replaceAll('\\', '/'), raw: raw, size: int.tryParse(m['Size'] ?? '') ?? 0, + modified: DateTime.tryParse((m['Modified'] ?? '').replaceFirst(' ', 'T')), isDir: (m['Folder'] ?? '') == '+' || RegExp(r'^[A-Z_]*D').hasMatch(attr), isLink: link, encrypted: (m['Encrypted'] ?? '') == '+')); @@ -326,8 +373,18 @@ Future> list7z(String exe, String path, {String? password}) a /// 7z · RAR 풀기 - [plan] 의 파일만 (목록 파일로 넘김, 링크 · 막은 것은 빠짐). 같은 이름은 [conflict] 대로 Future extract7zPlan(String exe, String path, String dest, ExtractPlan plan, - {NameConflict conflict = NameConflict.rename, String? password, void Function(double v)? onProgress, bool Function()? cancelled}) async { + {NameConflict conflict = NameConflict.rename, + bool skipNewer = false, + String? password, + void Function(double v)? onProgress, + bool Function()? cancelled}) async { final r = ExtractResult(); + // 관리자 판단: 덮어쓰기 (-aoa) 에도 받는 쪽이 더 새 파일은 목록에서 빼서 그대로 둔다 (229) + if (conflict == NameConflict.overwrite && skipNewer) { + final keep = [for (final e in plan.files) if (_targetNewer(extractTarget(dest, e.name), e.modified)) e]; + r.keptNewer.addAll(keep.map((e) => e.name)); + plan = ExtractPlan([for (final e in plan.files) if (!keep.contains(e)) e], plan.unsafe, plan.links, plan.totalSize, plan.encrypted, plan.badNames); + } if (plan.files.isEmpty) return r; final tmp = await Directory.systemTemp.createTemp('jj_7z_'); try { diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index 3b7f5a8..62878a4 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -173,6 +173,12 @@ Future extractArchiveFlow(BuildContext context, AppController c, String [plan.unsafe.length, plan.unsafe.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]), style: const TextStyle(color: JjColors.danger, fontSize: 12)), ], + if (plan.badNames.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(trf('풀 수 없는 이름 {0}개 (건너뜀): {1}', + [plan.badNames.length, plan.badNames.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]), + style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)), + ], if (plan.links.isNotEmpty) ...[ const SizedBox(height: 6), Text(trf('링크 {0}개는 만들지 않고 건너뜁니다: {1}', [plan.links.length, plan.links.take(5).join(', ')]), @@ -201,6 +207,13 @@ Future extractArchiveFlow(BuildContext context, AppController c, String onChanged: (v) => set(() => conflict = v!), ), ]), + // 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠) + if (conflict == NameConflict.overwrite) + Text( + c.settings.copySkipNewer + ? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).') + : tr('덮어쓴 파일은 되돌릴 수 없습니다.'), + style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)), // 관리자 판단: 풀린 크기가 빈 공간보다 크면 풀기 전에 알리고 묻는다 (압축 폭탄 · 공간 부족) if (short) Text(trf('풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?', [formatSize(plan.totalSize), formatSize(f)]), @@ -229,16 +242,23 @@ Future extractArchiveFlow(BuildContext context, AppController c, String final r = await _withProgress(context, trf('압축 푸는 중 · {0}', [vBasename(path)]), (set, cancelled) async { if (kind == ArchiveKind.zip) { return extractZipPlan(local, dest, plan, - conflict: conflict, password: pw, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)); + conflict: conflict, + skipNewer: c.settings.copySkipNewer, + password: pw, + cancelled: cancelled, + onProgress: (d, t) => set(t == 0 ? null : d / t)); } - return extract7zPlan(exe!, local, dest, plan, conflict: conflict, password: pw, cancelled: cancelled, onProgress: set); + return extract7zPlan(exe!, local, dest, plan, + conflict: conflict, skipNewer: c.settings.copySkipNewer, password: pw, cancelled: cancelled, onProgress: set); }); say([ trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]), if (r.skippedExisting.isNotEmpty) trf('같은 이름이라 건너뜀 {0}개', [r.skippedExisting.length]), if (r.renamed.isNotEmpty) trf('이름을 바꿔 풂 {0}개', [r.renamed.length]), + if (r.keptNewer.isNotEmpty) trf('{0}개는 더 새 파일이라 그대로 둠', [r.keptNewer.length]), if (plan.unsafe.isNotEmpty) trf('막은 경로 {0}개', [plan.unsafe.length]), if (plan.links.isNotEmpty) trf('건너뛴 링크 {0}개', [plan.links.length]), + if (plan.badNames.isNotEmpty) trf('풀 수 없는 이름 {0}개', [plan.badNames.length]), ].join(' · ')); return dest; } on ArchivePasswordException catch (e) { diff --git a/test/archive_ops_test.dart b/test/archive_ops_test.dart index 7e8de59..992861c 100644 --- a/test/archive_ops_test.dart +++ b/test/archive_ops_test.dart @@ -37,6 +37,18 @@ void main() { expect(plan.encrypted, isTrue); }); + test('86 보충: Windows 에서 쓸 수 없는 이름 (CON · ? · 끝 점) 은 미리 걸러 건너뛴다 (다른 것은 그대로)', () { + final plan = planExtract([ + const ArchiveEntry('ok/a.txt'), + const ArchiveEntry('con.txt'), + const ArchiveEntry('dir/what?.txt'), + const ArchiveEntry('end./x.txt'), + ], windowsRules: true); + expect(plan.files.map((e) => e.name), ['ok/a.txt']); + expect(plan.badNames.map((x) => x.$1), ['con.txt', 'dir/what?.txt', 'end./x.txt']); + expect(planExtract([const ArchiveEntry('con.txt')], windowsRules: false).files, hasLength(1), reason: 'Android 는 지금처럼'); + }); + group('ZIP', () { test('만들기 (폴더 · 파일, 링크는 넣지 않음) → 목록 → 풀기 (같은 이름은 고른 대로)', () async { final src = Directory(p.join(tmp.path, 'src'))..createSync(); @@ -73,6 +85,23 @@ void main() { expect(File(p.join(dest.path, 'one.txt')).readAsStringSync(), '1'); }); + test('86 보충: 덮어쓰기에도 받는 쪽이 더 새 파일은 그대로 (229 와 같게)', () async { + final a = Archive() + ..addFile(ArchiveFile.string('old.txt', 'from zip')..lastModTime = DateTime(2020, 1, 1).millisecondsSinceEpoch ~/ 1000) + ..addFile(ArchiveFile.string('new.txt', 'from zip')..lastModTime = DateTime(2020, 1, 1).millisecondsSinceEpoch ~/ 1000); + final zip = p.join(tmp.path, 'n.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(a)); + final dest = Directory(p.join(tmp.path, 'd'))..createSync(); + final older = File(p.join(dest.path, 'old.txt'))..writeAsStringSync('mine'); + older.setLastModifiedSync(DateTime(2019, 1, 1)); + File(p.join(dest.path, 'new.txt')).writeAsStringSync('mine'); // 지금 = 더 새것 + final plan = planExtract(await listZip(zip)); + final r = await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.overwrite, skipNewer: true); + expect(r.keptNewer, ['new.txt']); + expect(File(p.join(dest.path, 'new.txt')).readAsStringSync(), 'mine'); + expect(File(p.join(dest.path, 'old.txt')).readAsStringSync(), 'from zip'); + }); + test('zip slip: ../ · 드라이브 경로 · 링크 항목은 대상 밖에 쓰지 않고 알린다', () async { final a = Archive() ..addFile(ArchiveFile.string('ok.txt', 'ok')) @@ -149,6 +178,13 @@ void main() { expect(File(p.join(dest, 'src', 'a_1.txt')).existsSync() || File(p.join(dest, 'src', 'a (2).txt')).existsSync() || Directory(p.join(dest, 'src')).listSync().length > 3, isTrue); + // 86 보충: 덮어쓰기에도 받는 쪽이 더 새 파일은 그대로 (7z) + final mine = File(p.join(dest, 'src', 'a.txt'))..writeAsStringSync('mine'); + mine.setLastModifiedSync(DateTime.now().add(const Duration(days: 1))); + final keep = await extract7zPlan(exe, plain, dest, planExtract(list), conflict: NameConflict.overwrite, skipNewer: true); + expect(keep.keptNewer, ['src/a.txt']); + expect(mine.readAsStringSync(), 'mine'); + await expectLater(list7z(exe, enc), throwsA(isA())); await expectLater(list7z(exe, enc, password: 'nope'), throwsA(isA().having((e) => e.wrong, 'wrong', isTrue))); final encList = await list7z(exe, enc, password: 'pw2'); diff --git a/tool/make_zip.ps1 b/tool/make_zip.ps1 index 3ba0e8d..afea7ab 100644 --- a/tool/make_zip.ps1 +++ b/tool/make_zip.ps1 @@ -34,6 +34,13 @@ if (-not ($list -contains "$name/jj_mkvmaker.exe")) { Remove-Item $Zip -Force throw "zip 맨 위에 $name/jj_mkvmaker.exe 가 없습니다" } +# 86 (관리자 판단): 7-Zip 이 빠진 채 정식 판이 나가지 않게 (CMake 는 없으면 경고만 하고 빼고 빌드한다) +$need = @("$name/Lib/7zip/7z.exe", "$name/Lib/7zip/7z.dll", "$name/Lib/7zip/License.txt") +$missing = $need | Where-Object { -not ($list -contains $_) } +if ($missing) { + Remove-Item $Zip -Force + throw ("7-Zip 이 빠졌습니다 (tool\fetch_7zip.ps1 로 받은 뒤 다시 빌드):`n" + ($missing -join "`n")) +} $item = Get-Item $Zip $hash = (Get-FileHash $Zip -Algorithm SHA256).Hash.ToLower() "{0} {1} bytes {2} files sha256 {3}" -f $item.Name, $item.Length, $list.Count, $hash diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 2c4b265..8d62f50 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -150,6 +150,9 @@ file(GLOB JJ_7ZIP_FILES "${JJ_7ZIP_DIR}/7z.exe" "${JJ_7ZIP_DIR}/7z.dll" "${JJ_7Z if(JJ_7ZIP_FILES) install(FILES ${JJ_7ZIP_FILES} DESTINATION "${CMAKE_INSTALL_PREFIX}/7zip" COMPONENT Runtime) +else() + # 개발 빌드는 그대로 (7z · RAR 풀기만 빠짐). 정식 판은 make_zip.ps1 검사가 막는다 + message(WARNING "7-Zip 이 없습니다 (third_party/7zip/windows) - 7z · RAR 풀기가 빠집니다. tool\\fetch_7zip.ps1 로 받으세요.") endif() install(FILES "${JJ_LICENSES}/LGPL-2.1.txt" DESTINATION "${CMAKE_INSTALL_PREFIX}/7zip" COMPONENT Runtime OPTIONAL)