diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 628e27e..ae353cc 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -32,6 +32,7 @@ "\"{0}\" 안에 만듭니다": "Created inside \"{0}\"", "\"{0}\" 에 맞는 항목이 없습니다": "No items match \"{0}\"", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "These items are not in \"{0}\" but only in the target \"{1}\". Syncing with delete removes them from the target (cannot be undone).", + "\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "\"{0}\" is password-protected.", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "Move \"{0}\" to the \"{1}\" folder?", "\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "Delete \"{0}\"? This cannot be undone.", @@ -329,6 +330,8 @@ "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "Used for WebDAV sources (PC folders sync as soon as they change). Shorter means faster syncing but more server and battery use.", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV can't be added to the MKV list. Copy them to this device first.", "WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV files show only name, size and date", + "WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.": "Can't extract into a WebDAV folder. Extract into a folder on this device.", + "WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.": "WebDAV items can't be zipped. Choose files or folders on this device.", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: the app syncs directly instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files).", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).", "WebM (동영상)": "WebM (video)", @@ -363,7 +366,12 @@ "ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "Double-tapping ZIP · CBZ: comic view → list. To revert: Settings > File explorer > Read ZIP · CBZ as comics", "ZIP · CBZ 를 만화로 보기": "Read ZIP · CBZ as comics", "ZIP 옆 새 폴더 \"{0}\" 로": "To a new folder \"{0}\" next to the ZIP", + "ZIP 으로 묶기": "Compress to ZIP", + "ZIP 으로 묶기 · {0}개": "Compress to ZIP · {0} items", + "ZIP 으로 묶는 중 · {0}": "Compressing · {0}", + "ZIP 을 만들지 못했습니다: {0}": "Couldn't create the ZIP: {0}", "ZIP 이 있는 폴더로": "To the ZIP's folder", + "ZIP 이름": "ZIP name", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].", @@ -476,6 +484,7 @@ "{0}: 저장하지 않았습니다": "{0}: not saved", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", "{0}개": "{0}", + "{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP", "{0}개 · {1}": "{0} · {1}", "{0}개 · {1} …": "{0} items · {1} …", "{0}개 · 주소 {1}개": "{0} · {1} links", @@ -506,6 +515,7 @@ "{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}": "Compressed {0} files: {1}", "{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", @@ -569,13 +579,17 @@ "감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", + "강하게 (0.4)": "Strong (0.4)", + "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", "같은 이름이 이미 있음": "Name already exists", "같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …", + "같은 이름이 있으면": "If the name exists", "같은 이름이 있을 때": "When a file with the same name exists", + "같은 이름이라 건너뜀 {0}개": "{0} skipped (same name)", "같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}", "같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)", "같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "Make a copy in the same folder? (name (2))\n{0}", @@ -591,6 +605,7 @@ "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", "건너뛰기": "Skip", + "건너뛴 링크 {0}개": "{0} links skipped", "건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}", "건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}", "건너뜁니다": "skips it", @@ -658,6 +673,7 @@ "그래도 Chrome 엔진 쓰기…": "Use the Chrome engine anyway…", "그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})", "그래도 쓰기": "Use anyway", + "그래도 풀기": "Extract anyway", "그리는 중 {0}/{1}": "Drawing {0}/{1}", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "To draw, {0} must be downloaded (the model stays on this device, and images and prompts never leave it).", "그리스어": "Greek", @@ -780,6 +796,7 @@ "넣지 못했습니다: {0}": "Could not add: {0}", "네덜란드어": "Dutch", "네트워크 · DNS": "Network · DNS", + "네트워크 경로 (UNC)": "Network path (UNC)", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "Network shares (SMB) aren't supported yet. Use the WebDAV tab", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).", "네팔어": "Nepali", @@ -817,6 +834,7 @@ "다른 이름으로 저장": "Save as", "다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})", "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", + "다른 창 폴더": "Other pane's folder", "다른 창 폴더로": "To the other pane's folder", "다른 창에서 열기": "Open in other pane", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", @@ -969,6 +987,7 @@ "뒤로": "Back", "뒤로 보내고 계속": "Keep running in background", "뒤에서 계속": "Continue in background", + "드라이브 경로": "Drive path", "드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted", "등록": "Register", "등록 중 일부가 실패했습니다.": "Some registrations failed.", @@ -983,6 +1002,9 @@ "로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit", "루마니아어": "Romanian", "링크 {0} (건너뜀)": "Links {0} (skipped)", + "링크 {0}개는 넣지 않음": "{0} links not included", + "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} links will be skipped (not created): {1}", + "링크 · junction 은 넣지 않습니다": "Links and junctions are not included", "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", "마스터 비밀번호 넣기": "Enter master password", @@ -1004,9 +1026,11 @@ "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "Swiping past the last screen goes to the first (off: stops at the end)", "마지막 화면입니다": "This is the last screen", "마지막으로: {0}": "To the last: {0}", + "막은 경로 {0}개": "{0} paths blocked", "만든 그림 · 저장: {0}": "Made images · saved in: {0}", "만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", + "만들 파일: {0}": "File to create: {0}", "만들거나 쓰지 못했습니다": "Could not create or write", "만들기": "Make", "만들다 만 파일 {0}개": "{0} incomplete files", @@ -1079,6 +1103,7 @@ "무엇을 그릴지 적어 주세요": "Write what to draw", "무엇이 바뀌는지 비교하는 중…": "Comparing what will change…", "무한": "Unlimited", + "묶기": "Compress", "문서 미리보기": "Document preview", "문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first", "문자셋 경고": "Charset warning", @@ -1093,6 +1118,7 @@ "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미얀마어": "Burmese", "미지정": "Unspecified", + "바깥 폴더 (../)": "Parent folder (../)", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).", "바꾸기": "Change", "바꾸기 ({0}개)": "Rename ({0})", @@ -1116,6 +1142,7 @@ "바탕 그림 고르기": "Choose a base picture", "바탕 그림 고르기 (그림 → 그림)": "Choose a base picture (image → image)", "바탕 그림 빼기": "Remove base picture", + "밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "Not extracted because the path leads outside ({0}): {1}", "반대로 90° 회전": "Rotate 90° (counterclockwise)", "반전 (고른 것 ↔ 안 고른 것)": "Invert (selected ↔ not selected)", "받기": "Download", @@ -1206,6 +1233,7 @@ "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.", "보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)", "보통": "Normal", + "보통 (0.25, 기본)": "Normal (0.25, default)", "보통: {0}": "Normal: {0}", "복사": "Copy", "복사 (붙여넣기로)": "Copy (to paste)", @@ -1256,8 +1284,11 @@ "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)", + "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", + "비밀번호가 틀렸습니다": "Wrong password", "비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)", + "비밀번호가 필요합니다": "Password required", "비밀번호를 넣으세요": "Enter a password", "비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage", "비밀번호를 잊었습니다": "I forgot the password", @@ -1265,10 +1296,12 @@ "비어 있는 폴더": "Empty folder", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있음": "Empty", + "빈 이름": "Empty name", "빈 폴더 {0}개 지움": "Deleted {0} empty folders", "빈 폴더 그대로": "Leave empty folders", "빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder", "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", + "빠르게": "Fast", "빠른 디코더 (TAESD)": "Fast decoder (TAESD)", "빠른 필터": "Quick filter", "빼고 싶은 것 (선택)": "What to avoid (optional)", @@ -1449,6 +1482,7 @@ "실행해 보지 못함 ({0})": "Could not run it ({0})", "싱크": "Sync", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)", + "쓸 수 없는 글자 (:)": "Invalid character (:)", "쓸 수 없는 이름": "Invalid name", "쓸 수 없는 이름입니다": "This name cannot be used", "아랍어": "Arabic", @@ -1476,9 +1510,15 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)", "암호화 안 됨 (http)": "Not encrypted (http)", + "압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive", "압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", + "압축 푸는 중 · {0}": "Extracting · {0}", + "압축 풀기": "Extract", + "압축 풀기 · {0}": "Extract · {0}", + "압축을 읽지 못했습니다: {0}": "Couldn't read the archive: {0}", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", + "압축이 있는 폴더": "Archive's folder", "앞 항목 (←)": "Previous item (←)", "앞으로": "Forward", "앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.", @@ -1509,6 +1549,7 @@ "약 {0} 남음": "about {0} left", "약 {0}~{1}분": "about {0}–{1} min", "약 {0}분": "about {0} min", + "약하게 (0.15)": "Light (0.15)", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "Both ways (⇄) never deletes source files (--remove-source-files in the options is not used).", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).", "어둡게": "Darker", @@ -1723,6 +1764,7 @@ "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", + "이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "7z and RAR can't be extracted in the app on this device yet. Open it with another app that extracts archives.", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "On this device Low · Normal · High are all the same: {0} (smallest tile because memory is small)", "이 기기용 파일 없음": "No file for this device", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine", @@ -1808,6 +1850,7 @@ "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이름을 바꿉니다": "renames it", + "이름을 바꿔 풂 {0}개": "{0} extracted with new names", "이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})", "이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)", "이미 그리는 중입니다": "Already drawing", @@ -1984,6 +2027,7 @@ "전체 이동": "Move all", "전체 화면": "Full screen", "전체 화면 (F)": "Full screen (F)", + "절대 경로": "Absolute path", "점검": "Check", "정렬 기준": "Sort by", "정렬을 폴더마다 기억": "Remember sort per folder", @@ -2083,6 +2127,7 @@ "지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)", "지금 짝 추가": "Add current pair", "지금 짝을 즐겨찾기에": "Add the current pair to favorites", + "지금 폴더": "Current folder", "지금 폴더 추가": "Add current folder", "지금 폴더를 즐겨찾기에": "Add the current folder to favorites", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)", @@ -2197,6 +2242,8 @@ "취소됨: {0}": "Cancelled: {0}", "취소했습니다": "Cancelled", "취소했습니다.": "Cancelled.", + "취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "Cancelled. No partial ZIP is left.", + "취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "Cancelled. The file being extracted is not left behind (finished files remain).", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).", "칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "Options in the field: {0}\nSaved options: {1}\n\nSave the edited options and run?", @@ -2335,9 +2382,12 @@ "표준": "Standard", "푸는 중": "Extracting", "푸는 중: {0}": "Extracting: {0}", + "풀기": "Extract", + "풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "The extracted size ({0}) is larger than the free space ({1}). Extract anyway?", "풀지 못했습니다: {0}": "Could not extract: {0}", "품질 올리기": "Improve quality", "품질 올리기 (이 장)": "Improve quality (this page)", + "품질 올리기 다듬기 정도": "Improve quality strength", "품질 올리기 단추 보이기": "Show Improve quality button", "품질 올리기: {0}": "Improve quality: {0}", "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "Improve quality needs the AI image model. Download it in Settings > AI image and try again.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index a775dae..c3d54c9 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -32,6 +32,7 @@ "\"{0}\" 안에 만듭니다": "「{0}」の中に作成します", "\"{0}\" 에 맞는 항목이 없습니다": "「{0}」に合う項目はありません", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。", + "\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "「{0}」にはパスワードが設定されています。", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?", "\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。", @@ -329,6 +330,8 @@ "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", "WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV のファイルは名前・サイズ・日付のみ表示します", + "WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.": "WebDAV フォルダーには展開できません。この端末のフォルダーで展開してください。", + "WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.": "WebDAV の項目は ZIP にまとめられません。この端末のファイル・フォルダーを選んでください。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。", "WebM (동영상)": "WebM(動画)", @@ -363,7 +366,12 @@ "ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をダブルタップ: マンガ表示 → 一覧。元に戻す: 環境設定 > ファイルエクスプローラー > ZIP・CBZ をマンガとして表示", "ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をマンガとして表示", "ZIP 옆 새 폴더 \"{0}\" 로": "ZIP の隣の新しいフォルダー「{0}」へ", + "ZIP 으로 묶기": "ZIP にまとめる", + "ZIP 으로 묶기 · {0}개": "ZIP にまとめる · {0} 件", + "ZIP 으로 묶는 중 · {0}": "ZIP にまとめています · {0}", + "ZIP 을 만들지 못했습니다: {0}": "ZIP を作成できませんでした: {0}", "ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ", + "ZIP 이름": "ZIP の名前", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。", @@ -476,6 +484,7 @@ "{0}: 저장하지 않았습니다": "{0}: 保存しませんでした", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", "{0}개": "{0}個", + "{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる", "{0}개 · {1}": "{0}個・{1}", "{0}개 · {1} …": "{0} 件 · {1} …", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", @@ -506,6 +515,7 @@ "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。", "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", + "{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}", "{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", @@ -569,13 +579,17 @@ "감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", + "강하게 (0.4)": "強め (0.4)", + "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", "같은 이름이 이미 있음": "同じ名前が既にあります", "같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …", + "같은 이름이 있으면": "同じ名前がある場合", "같은 이름이 있을 때": "同じ名前があるとき", + "같은 이름이라 건너뜀 {0}개": "同じ名前のためスキップ {0} 件", "같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}", "같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)", "같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "同じフォルダーにコピーを作りますか? (名前 (2))\n{0}", @@ -591,6 +605,7 @@ "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", "건너뛰기": "スキップ", + "건너뛴 링크 {0}개": "スキップしたリンク {0} 件", "건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}", "건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}", "건너뜁니다": "スキップします", @@ -658,6 +673,7 @@ "그래도 Chrome 엔진 쓰기…": "それでも Chrome エンジンを使う…", "그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)", "그래도 쓰기": "それでも使う", + "그래도 풀기": "それでも展開", "그리는 중 {0}/{1}": "描画中 {0}/{1}", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。", "그리스어": "ギリシャ語", @@ -780,6 +796,7 @@ "넣지 못했습니다: {0}": "追加できませんでした: {0}", "네덜란드어": "オランダ語", "네트워크 · DNS": "ネットワーク・DNS", + "네트워크 경로 (UNC)": "ネットワークパス (UNC)", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。", "네팔어": "ネパール語", @@ -817,6 +834,7 @@ "다른 이름으로 저장": "名前を付けて保存", "다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})", "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", + "다른 창 폴더": "もう一方のウィンドウのフォルダー", "다른 창 폴더로": "もう一方の画面のフォルダーへ", "다른 창에서 열기": "もう一方のペインで開く", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", @@ -969,6 +987,7 @@ "뒤로": "戻る", "뒤로 보내고 계속": "裏で続ける", "뒤에서 계속": "バックグラウンドで続ける", + "드라이브 경로": "ドライブのパス", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "등록": "登録", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", @@ -983,6 +1002,9 @@ "로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます", "루마니아어": "ルーマニア語", "링크 {0} (건너뜀)": "リンク {0} (スキップ)", + "링크 {0}개는 넣지 않음": "リンク {0} 件は含めず", + "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}", + "링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", "마스터 비밀번호 넣기": "マスターパスワードを入力", @@ -1004,9 +1026,11 @@ "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "最後の画面からさらにスワイプすると最初の画面へ (オフ: 端で止まる)", "마지막 화면입니다": "最後の画面です", "마지막으로: {0}": "最後へ: {0}", + "막은 경로 {0}개": "ブロックしたパス {0} 件", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", + "만들 파일: {0}": "作成するファイル: {0}", "만들거나 쓰지 못했습니다": "作成または書き込みできませんでした", "만들기": "作成", "만들다 만 파일 {0}개": "未完成のファイル {0} 個", @@ -1079,6 +1103,7 @@ "무엇을 그릴지 적어 주세요": "何を描くか入力してください", "무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…", "무한": "無制限", + "묶기": "まとめる", "문서 미리보기": "文書プレビュー", "문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください", "문자셋 경고": "文字コードの警告", @@ -1093,6 +1118,7 @@ "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미얀마어": "ミャンマー語", "미지정": "未指定", + "바깥 폴더 (../)": "外側のフォルダー (../)", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "変更・オフにするには現在のマスターパスワード (または最上位パスワード) を入力してください。", "바꾸기": "変更", "바꾸기 ({0}개)": "変更 ({0} 件)", @@ -1116,6 +1142,7 @@ "바탕 그림 고르기": "元の画像を選択", "바탕 그림 고르기 (그림 → 그림)": "元の画像を選択 (画像 → 画像)", "바탕 그림 빼기": "元の画像を外す", + "밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "外に出るパスのため展開しません ({0} 件): {1}", "반대로 90° 회전": "反対に 90° 回転", "반전 (고른 것 ↔ 안 고른 것)": "反転 (選択 ↔ 未選択)", "받기": "ダウンロード", @@ -1206,6 +1233,7 @@ "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)", "보통": "普通", + "보통 (0.25, 기본)": "普通 (0.25、既定)", "보통: {0}": "普通: {0}", "복사": "コピー", "복사 (붙여넣기로)": "コピー (貼り付け用)", @@ -1256,8 +1284,11 @@ "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", + "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", + "비밀번호가 틀렸습니다": "パスワードが違います", "비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)", + "비밀번호가 필요합니다": "パスワードが必要です", "비밀번호를 넣으세요": "パスワードを入力してください", "비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました", "비밀번호를 잊었습니다": "パスワードを忘れました", @@ -1265,10 +1296,12 @@ "비어 있는 폴더": "空のフォルダー", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있음": "空", + "빈 이름": "空の名前", "빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除", "빈 폴더 그대로": "空フォルダーはそのまま", "빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す", "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", + "빠르게": "速く", "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", "빠른 필터": "クイックフィルター", "빼고 싶은 것 (선택)": "避けたいもの (任意)", @@ -1449,6 +1482,7 @@ "실행해 보지 못함 ({0})": "実行できませんでした ({0})", "싱크": "同期", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)", + "쓸 수 없는 글자 (:)": "使えない文字 (:)", "쓸 수 없는 이름": "使えない名前", "쓸 수 없는 이름입니다": "使用できない名前です", "아랍어": "アラビア語", @@ -1476,9 +1510,15 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "암호화 안 됨 (http)": "暗号化なし (http)", + "압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", + "압축 푸는 중 · {0}": "展開中 · {0}", + "압축 풀기": "展開", + "압축 풀기 · {0}": "展開 · {0}", + "압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", + "압축이 있는 폴더": "アーカイブのあるフォルダー", "앞 항목 (←)": "前の項目 (←)", "앞으로": "進む", "앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。", @@ -1509,6 +1549,7 @@ "약 {0} 남음": "残り約 {0}", "약 {0}~{1}분": "約 {0}~{1} 分", "약 {0}분": "約 {0} 分", + "약하게 (0.15)": "弱め (0.15)", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", "어둡게": "暗く", @@ -1723,6 +1764,7 @@ "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", + "이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "この端末ではまだアプリ内で 7z・RAR を展開できません。展開できる別のアプリで開いてください。", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "この機器では少なめ · 普通 · 多めはすべて同じです: {0} (メモリが少ないため最小のタイル)", "이 기기용 파일 없음": "このデバイス用のファイルなし", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません", @@ -1808,6 +1850,7 @@ "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이름을 바꿉니다": "名前を変えます", + "이름을 바꿔 풂 {0}개": "名前を変えて展開 {0} 件", "이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})", "이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)", "이미 그리는 중입니다": "すでに描画中です", @@ -1984,6 +2027,7 @@ "전체 이동": "すべて移動", "전체 화면": "全画面", "전체 화면 (F)": "全画面(F)", + "절대 경로": "絶対パス", "점검": "点検", "정렬 기준": "並べ替え", "정렬을 폴더마다 기억": "フォルダーごとに並べ替えを記憶", @@ -2083,6 +2127,7 @@ "지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)", "지금 짝 추가": "今のペアを追加", "지금 짝을 즐겨찾기에": "今のペアをお気に入りに", + "지금 폴더": "現在のフォルダー", "지금 폴더 추가": "今のフォルダーを追加", "지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)", @@ -2197,6 +2242,8 @@ "취소됨: {0}": "取り消し: {0}", "취소했습니다": "キャンセルしました", "취소했습니다.": "キャンセルしました。", + "취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "キャンセルしました。作りかけの ZIP は残りません。", + "취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "キャンセルしました。展開中のファイルは残りません (展開済みのファイルはそのまま)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "欄に表示中のオプション: {0}\n保存済みのオプション: {1}\n\n編集したオプションを保存して実行しますか?", @@ -2335,9 +2382,12 @@ "표준": "標準", "푸는 중": "展開中", "푸는 중: {0}": "展開中: {0}", + "풀기": "展開", + "풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "展開後のサイズ ({0}) が空き容量 ({1}) より大きいです。それでも展開しますか?", "풀지 못했습니다: {0}": "展開できませんでした: {0}", "품질 올리기": "画質を上げる", "품질 올리기 (이 장)": "画質を上げる (このページ)", + "품질 올리기 다듬기 정도": "画質を上げる整えの強さ", "품질 올리기 단추 보이기": "[画質を上げる] ボタンを表示", "품질 올리기: {0}": "画質を上げる: {0}", "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "画質を上げるには AI 画像モデルが必要です。設定 > AI 画像でモデルを入手してから、もう一度押してください。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index ec319a6..25e3497 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -32,6 +32,7 @@ "\"{0}\" 안에 만듭니다": "在“{0}”中创建", "\"{0}\" 에 맞는 항목이 없습니다": "没有与“{0}”匹配的项目", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。", + "\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "“{0}”设有密码。", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?", "\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。", @@ -329,6 +330,8 @@ "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。", "WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV 文件仅显示名称、大小和日期", + "WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.": "无法解压到 WebDAV 文件夹。请解压到本设备的文件夹。", + "WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.": "WebDAV 项无法压缩为 ZIP。请选择本设备上的文件或文件夹。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: 由应用直接同步而非 rsync (比较大小 · 修改时间,遵循 -u · --delete · --remove-source-files)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。", "WebM (동영상)": "WebM(视频)", @@ -363,7 +366,12 @@ "ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "双击 ZIP · CBZ: 漫画阅读 → 列表。恢复: 设置 > 文件浏览器 > 将 ZIP · CBZ 作为漫画阅读", "ZIP · CBZ 를 만화로 보기": "将 ZIP · CBZ 作为漫画阅读", "ZIP 옆 새 폴더 \"{0}\" 로": "到 ZIP 旁的新文件夹“{0}”", + "ZIP 으로 묶기": "压缩为 ZIP", + "ZIP 으로 묶기 · {0}개": "压缩为 ZIP · {0} 项", + "ZIP 으로 묶는 중 · {0}": "正在压缩 · {0}", + "ZIP 을 만들지 못했습니다: {0}": "无法创建 ZIP: {0}", "ZIP 이 있는 폴더로": "到 ZIP 所在文件夹", + "ZIP 이름": "ZIP 名称", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。", @@ -476,6 +484,7 @@ "{0}: 저장하지 않았습니다": "{0}:未保存", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", "{0}개": "{0} 个", + "{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP", "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1} …": "{0} 项 · {1} …", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", @@ -506,6 +515,7 @@ "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。", "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", + "{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}", "{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", @@ -569,13 +579,17 @@ "감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", + "강하게 (0.4)": "强 (0.4)", + "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", "같은 이름이 이미 있음": "同名项已存在", "같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …", + "같은 이름이 있으면": "如果同名", "같은 이름이 있을 때": "存在同名项目时", + "같은 이름이라 건너뜀 {0}개": "因同名跳过 {0} 个", "같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}", "같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)", "같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "要在同一文件夹中创建副本吗?(名称 (2))\n{0}", @@ -591,6 +605,7 @@ "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", "건너뛰기": "跳过", + "건너뛴 링크 {0}개": "跳过的链接 {0} 个", "건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}", "건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}", "건너뜁니다": "跳过", @@ -658,6 +673,7 @@ "그래도 Chrome 엔진 쓰기…": "仍然使用 Chrome 引擎…", "그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)", "그래도 쓰기": "仍然使用", + "그래도 풀기": "仍然解压", "그리는 중 {0}/{1}": "正在绘制 {0}/{1}", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。", "그리스어": "希腊语", @@ -780,6 +796,7 @@ "넣지 못했습니다: {0}": "无法加入: {0}", "네덜란드어": "荷兰语", "네트워크 · DNS": "网络 · DNS", + "네트워크 경로 (UNC)": "网络路径 (UNC)", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。", "네팔어": "尼泊尔语", @@ -817,6 +834,7 @@ "다른 이름으로 저장": "另存为", "다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})", "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", + "다른 창 폴더": "另一窗格的文件夹", "다른 창 폴더로": "到另一窗格的文件夹", "다른 창에서 열기": "在另一窗格中打开", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", @@ -969,6 +987,7 @@ "뒤로": "后退", "뒤로 보내고 계속": "转到后台继续", "뒤에서 계속": "在后台继续", + "드라이브 경로": "驱动器路径", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "등록": "注册", "등록 중 일부가 실패했습니다.": "部分注册失败。", @@ -983,6 +1002,9 @@ "로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加", "루마니아어": "罗马尼亚语", "링크 {0} (건너뜀)": "链接 {0}(跳过)", + "링크 {0}개는 넣지 않음": "{0} 个链接未包含", + "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}", + "링크 · junction 은 넣지 않습니다": "不包含链接和 junction", "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", "마스터 비밀번호 넣기": "输入主密码", @@ -1004,9 +1026,11 @@ "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "在最后一个界面继续滑动会回到第一个界面 (关闭: 停在末尾)", "마지막 화면입니다": "这是最后一个画面", "마지막으로: {0}": "到最后: {0}", + "막은 경로 {0}개": "已阻止 {0} 个路径", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", + "만들 파일: {0}": "要创建的文件: {0}", "만들거나 쓰지 못했습니다": "无法创建或写入", "만들기": "生成", "만들다 만 파일 {0}개": "{0} 个未完成的文件", @@ -1079,6 +1103,7 @@ "무엇을 그릴지 적어 주세요": "请输入要画的内容", "무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…", "무한": "无限制", + "묶기": "压缩", "문서 미리보기": "文档预览", "문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件", "문자셋 경고": "字符集警告", @@ -1093,6 +1118,7 @@ "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미얀마어": "缅甸语", "미지정": "未指定", + "바깥 폴더 (../)": "上级文件夹 (../)", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "如需更改或关闭,请输入当前主密码 (或最高密码)。", "바꾸기": "更改", "바꾸기 ({0}개)": "重命名 ({0} 个)", @@ -1116,6 +1142,7 @@ "바탕 그림 고르기": "选择基础图片", "바탕 그림 고르기 (그림 → 그림)": "选择基础图片 (图片 → 图片)", "바탕 그림 빼기": "移除基础图片", + "밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "因路径指向外部而不解压 ({0} 个): {1}", "반대로 90° 회전": "逆时针旋转 90°", "반전 (고른 것 ↔ 안 고른 것)": "反选(已选 ↔ 未选)", "받기": "下载", @@ -1206,6 +1233,7 @@ "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)", "보통": "普通", + "보통 (0.25, 기본)": "普通 (0.25,默认)", "보통: {0}": "普通:{0}", "복사": "复制", "복사 (붙여넣기로)": "复制 (用于粘贴)", @@ -1256,8 +1284,11 @@ "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", + "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", + "비밀번호가 틀렸습니다": "密码错误", "비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)", + "비밀번호가 필요합니다": "需要密码", "비밀번호를 넣으세요": "请输入密码", "비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储", "비밀번호를 잊었습니다": "忘记密码", @@ -1265,10 +1296,12 @@ "비어 있는 폴더": "空文件夹", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있음": "空", + "빈 이름": "空名称", "빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹", "빈 폴더 그대로": "保留空文件夹", "빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹", "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", + "빠르게": "快速", "빠른 디코더 (TAESD)": "快速解码器 (TAESD)", "빠른 필터": "快速筛选", "빼고 싶은 것 (선택)": "要排除的内容 (可选)", @@ -1449,6 +1482,7 @@ "실행해 보지 못함 ({0})": "无法运行({0})", "싱크": "同步", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)", + "쓸 수 없는 글자 (:)": "无效字符 (:)", "쓸 수 없는 이름": "无效名称", "쓸 수 없는 이름입니다": "不能使用此名称", "아랍어": "阿拉伯语", @@ -1476,9 +1510,15 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "암호화 안 됨 (http)": "未加密 (http)", + "압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”", "압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", + "압축 푸는 중 · {0}": "正在解压 · {0}", + "압축 풀기": "解压", + "압축 풀기 · {0}": "解压 · {0}", + "압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}", + "압축이 있는 폴더": "压缩包所在文件夹", "앞 항목 (←)": "上一项 (←)", "앞으로": "前进", "앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。", @@ -1509,6 +1549,7 @@ "약 {0} 남음": "约剩 {0}", "약 {0}~{1}분": "约 {0}~{1} 分钟", "약 {0}분": "约 {0} 分钟", + "약하게 (0.15)": "弱 (0.15)", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", "어둡게": "调暗", @@ -1723,6 +1764,7 @@ "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", + "이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "本设备暂时无法在应用内解压 7z 和 RAR。请用其他解压应用打开。", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "在此设备上,少 · 普通 · 多 都相同:{0}(内存较小,使用最小的分块)", "이 기기용 파일 없음": "没有此设备的文件", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎", @@ -1808,6 +1850,7 @@ "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이름을 바꿉니다": "重命名", + "이름을 바꿔 풂 {0}개": "{0} 个以新名称解压", "이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})", "이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)", "이미 그리는 중입니다": "已在绘制中", @@ -1984,6 +2027,7 @@ "전체 이동": "全部移动", "전체 화면": "全屏", "전체 화면 (F)": "全屏(F)", + "절대 경로": "绝对路径", "점검": "检查", "정렬 기준": "排序方式", "정렬을 폴더마다 기억": "按文件夹记住排序", @@ -2083,6 +2127,7 @@ "지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)", "지금 짝 추가": "添加当前一对", "지금 짝을 즐겨찾기에": "将当前一对加入收藏", + "지금 폴더": "当前文件夹", "지금 폴더 추가": "添加当前文件夹", "지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)", @@ -2197,6 +2242,8 @@ "취소됨: {0}": "已取消:{0}", "취소했습니다": "已取消", "취소했습니다.": "已取消。", + "취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "已取消。不会留下未完成的 ZIP。", + "취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "已取消。正在解压的文件不会留下 (已解压的文件保留)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "输入框中的选项: {0}\n已保存的选项: {1}\n\n保存修改的选项并执行吗?", @@ -2335,9 +2382,12 @@ "표준": "标准", "푸는 중": "正在解压", "푸는 중: {0}": "正在解压: {0}", + "풀기": "解压", + "풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "解压后大小 ({0}) 大于剩余空间 ({1})。仍要解压吗?", "풀지 못했습니다: {0}": "无法解压: {0}", "품질 올리기": "提升画质", "품질 올리기 (이 장)": "提升画质 (本页)", + "품질 올리기 다듬기 정도": "提升画质的优化强度", "품질 올리기 단추 보이기": "显示提升画质按钮", "품질 올리기: {0}": "提升画质: {0}", "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "提升画质需要 AI 图片模型。请在 设置 > AI 图片 中下载后再试。", diff --git a/assets/licenses/7ZIP_LICENSE.txt b/assets/licenses/7ZIP_LICENSE.txt new file mode 100644 index 0000000..a5a8079 --- /dev/null +++ b/assets/licenses/7ZIP_LICENSE.txt @@ -0,0 +1,146 @@ + 7-Zip + ~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2025 Igor Pavlov. + + The licenses for files are: + + - 7z.dll: + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + - All other files: the "GNU LGPL". + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2025 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2025 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- diff --git a/assets/licenses/THIRD_PARTY_NOTICES.txt b/assets/licenses/THIRD_PARTY_NOTICES.txt index 1bc4788..18258f3 100644 --- a/assets/licenses/THIRD_PARTY_NOTICES.txt +++ b/assets/licenses/THIRD_PARTY_NOTICES.txt @@ -7,7 +7,7 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지 (GPL-2.0.txt · GPL-3.0.txt · LGPL-2.1.txt · PDFIUM_LICENSES.txt · YT-DLP_THIRD_PARTY_LICENSES.txt · RSYNC_COPYING.txt · WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt · STABLE_DIFFUSION_CPP_LICENSES.txt · CREATIVEML_OPENRAIL_M.txt · CREATIVEML_OPENRAIL_PP_M.txt · TAESD_LICENSE.txt · REAL_ESRGAN_LICENSE.txt · -WHISPER_MODEL_LICENSE.txt · NLLB_CC_BY_NC_4.0.txt · TOR_LICENSES.txt) 및 ffmpeg\, tools\, rsync\ 폴더의 *_LICENSE*, *_COPYING* 파일에서 볼 수 있습니다. +WHISPER_MODEL_LICENSE.txt · NLLB_CC_BY_NC_4.0.txt · TOR_LICENSES.txt · 7ZIP_LICENSE.txt) 및 ffmpeg\, tools\, rsync\, 7zip\ 폴더의 *_LICENSE*, *_COPYING*, License.txt 파일에서 볼 수 있습니다. WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도 둡니다. [프로그램에 연결된 라이브러리] @@ -74,6 +74,12 @@ WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도 소스: https://download.samba.org/pub/rsync/src/rsync-3.5.1.tar.gz MSYS2 패키지 (빌드 스크립트 · 함께 쓰는 DLL 의 소스): https://packages.msys2.org/base/rsync 원문: rsync\COPYING.txt (RSYNC_COPYING.txt - GPL-3.0 과 OpenSSL · xxhash 예외) +- 7-Zip 25.01 (7zip\7z.exe · 7z.dll) LGPL-2.1-or-later · BSD-3-Clause · BSD-2-Clause · unRAR 라이선스 제한 + Copyright (C) 1999-2025 Igor Pavlov. https://www.7-zip.org + 7z · RAR 압축을 푸는 데만 씁니다. RAR 을 푸는 코드는 unRAR 라이선스 제한을 따르며 RAR 을 만드는 데 쓰지 않습니다 + (이 앱은 RAR 을 만들지 않습니다). 수정하지 않은 공식 배포 파일 (7z2501-x64.msi) 이며 따로 실행합니다. + 소스: https://www.7-zip.org/download.html (7z2501-src.7z) + 원문: 7zip\License.txt (7ZIP_LICENSE.txt), LGPL-2.1.txt 위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다. diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index 2bb92ab..8884fc2 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -247,6 +247,9 @@ Future saveUpscaled(String fourX, {required String source, required Stri return path; } +/// [품질 올리기] 다듬기 정도 → 그림 → 그림의 바꾸는 정도 +double qualityStrength(String setting) => switch (setting) { 'weak' => 0.15, 'strong' => 0.4, _ => 0.25 }; + /// [품질 올리기] 의 그림 → 그림 크기: 원본 비율 그대로, 긴 쪽 [maxSide] 까지, 64 의 배수 (엔진이 요구), 작은 쪽 256 이상 (int, int) qualitySize(int w, int h, {int maxSide = 1024}) { if (w <= 0 || h <= 0) return (512, 512); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index bed523f..84a479a 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -428,6 +428,9 @@ class AppSettings { /// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임 bool explorerProgressHidden = false; + + /// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억 + int zipLevel = 6; bool explorerShowHidden = false; // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── @@ -600,6 +603,9 @@ class AppSettings { /// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔 bool aiQualityButton = false; + /// [품질 올리기] 다듬기 정도 (그림 → 그림 바꾸는 정도): 'weak' 0.15 · 'normal' 0.25 (처음 값) · 'strong' 0.4 (관리자 지시 - 판단은 설정으로) + String aiQualityStrength = 'normal'; + /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 String aiUpFormat = 'same'; int aiUpJpgQuality = 92; @@ -752,6 +758,7 @@ class AppSettings { 'explorerFolderSize': explorerFolderSize, 'explorerDropAction': explorerDropAction, 'explorerProgressHidden': explorerProgressHidden, + 'zipLevel': zipLevel, 'explorerSortPerFolder': explorerSortPerFolder, 'explorerFolderSorts': explorerFolderSorts, 'explorerShowHidden': explorerShowHidden, @@ -813,6 +820,7 @@ class AppSettings { 'aiUpSecPerMp': aiUpSecPerMp, 'aiUpScale': aiUpScale, 'aiQualityButton': aiQualityButton, + 'aiQualityStrength': aiQualityStrength, 'aiUpFormat': aiUpFormat, 'aiUpJpgQuality': aiUpJpgQuality, 'aiUpDir': aiUpDir, @@ -971,6 +979,7 @@ class AppSettings { ..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort' ..explorerSortPerFolder = j['explorerSortPerFolder'] == true ..explorerProgressHidden = j['explorerProgressHidden'] == true + ..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6 ..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask' ..explorerFolderSorts = { if (j['explorerFolderSorts'] is Map) @@ -1051,6 +1060,7 @@ class AppSettings { // 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로) ..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2 ..aiQualityButton = j['aiQualityButton'] == true + ..aiQualityStrength = const ['weak', 'normal', 'strong'].contains(j['aiQualityStrength']) ? j['aiQualityStrength'] as String : 'normal' ..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same' ..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100) ..aiUpDir = j['aiUpDir'] as String? ?? '' diff --git a/lib/core/archive_ops.dart b/lib/core/archive_ops.dart new file mode 100644 index 0000000..23cd6b1 --- /dev/null +++ b/lib/core/archive_ops.dart @@ -0,0 +1,359 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; +import 'file_ops.dart' show NameConflict; + +/// 86: 압축 종류 (확장자로) +enum ArchiveKind { zip, sevenZip, rar } + +ArchiveKind? archiveKindOf(String path) => switch (p.extension(path).toLowerCase()) { + '.zip' || '.cbz' => ArchiveKind.zip, + '.7z' || '.cb7' => ArchiveKind.sevenZip, + '.rar' || '.cbr' => ArchiveKind.rar, + _ => null, + }; + +/// 압축 안의 항목 하나 (이름은 압축 안 그대로 - 나누는 글자는 '/' 로 맞춤) +class ArchiveEntry { + final String name; + final int size; + final bool isDir; + final bool isLink; + final bool encrypted; + + /// 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; +} + +/// 비밀번호가 필요하거나 틀림 +class ArchivePasswordException implements Exception { + final bool wrong; + const ArchivePasswordException({this.wrong = false}); + @override + String toString() => wrong ? tr('비밀번호가 틀렸습니다') : tr('비밀번호가 필요합니다'); +} + +/// 86 (보안 · 관리자 판단): 풀면 대상 폴더 밖으로 나가는 이름인지 - 까닭 (괜찮으면 null). +/// ../ · 절대 경로 (/ · \) · 드라이브 문자 (C:) · UNC (\\서버) · 이름 안의 ':' (Windows 의 다른 데이터 흐름) 을 막는다 (zip slip) +String? unsafeArchivePath(String name) { + final n = name.replaceAll('\\', '/'); + if (n.startsWith('//')) return tr('네트워크 경로 (UNC)'); + if (n.startsWith('/')) return tr('절대 경로'); + if (RegExp(r'^[A-Za-z]:').hasMatch(n)) return tr('드라이브 경로'); + final parts = n.split('/'); + if (parts.any((s) => s == '..')) return tr('바깥 폴더 (../)'); + if (parts.any((s) => s.contains(':'))) return tr('쓸 수 없는 글자 (:)'); + if (parts.every((s) => s.isEmpty || s == '.')) return tr('빈 이름'); + return null; +} + +/// 풀기 전 검사 결과 +class ExtractPlan { + /// 풀 파일 (폴더는 파일을 풀며 만든다) + final List files; + + /// 막은 것: (이름, 까닭) + final List<(String, String)> unsafe; + + /// 만들지 않고 건너뛰는 링크 + final List links; + + /// 풀린 크기 합 (바이트) + final int totalSize; + + /// 비밀번호가 걸린 항목이 있음 + final bool encrypted; + const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted); +} + +/// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만 +ExtractPlan planExtract(List entries, {List names = const []}) { + 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 = []; + var size = 0; + var enc = false; + for (final e in entries) { + if (!wanted(e.name)) continue; + final bad = unsafeArchivePath(e.name); + if (bad != null) { + unsafe.add((e.name, bad)); + continue; + } + if (e.isLink) { + links.add(e.name); + continue; + } + if (e.isDir) continue; + files.add(e); + size += e.size; + enc |= e.encrypted; + } + return ExtractPlan(files, unsafe, links, size, enc); +} + +/// 풀 때 [dest] 안의 자리 ([name] 은 [unsafeArchivePath] 를 지난 것) +String extractTarget(String dest, String name) => + p.joinAll([dest, ...name.replaceAll('\\', '/').split('/').where((s) => s.isNotEmpty && s != '.')]); + +/// 같은 이름이면 "이름 (2).확장자" … +String _freeName(String path) { + if (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound) return path; + final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); + for (var n = 2;; n++) { + final c = p.join(dir, '$base ($n)$ext'); + if (FileSystemEntity.typeSync(c) == FileSystemEntityType.notFound) return c; + } +} + +/// 푼 결과 +class ExtractResult { + int done = 0; + final skippedExisting = []; + final renamed = []; +} + +/// 취소 (풀기 · 묶기 도중) +class ArchiveCancelled implements Exception { + const ArchiveCancelled(); + @override + String toString() => tr('취소했습니다'); +} + +// ───────── ZIP (archive 패키지, MIT) ───────── + +/// ZIP 목록 - 가운데 목록 (central directory) 을 직접 읽어 비밀번호 · 링크를 안다 +/// (archive 패키지의 항목에는 비밀번호 여부가 없고, 비밀번호 없이 읽으면 암호 글자가 그대로 나온다) +Future> listZip(String path) async { + final input = InputFileStream(path); + try { + final dir = ZipDirectory()..read(input); + return [ + for (final h in dir.fileHeaders) + ArchiveEntry(h.filename.replaceAll('\\', '/'), + size: h.uncompressedSize, + isDir: h.filename.endsWith('/') || h.filename.endsWith('\\'), + // 유닉스 파일 종류 (상위 16비트) 가 심볼릭 링크 (0xA000) + isLink: (h.externalFileAttributes >> 16) & 0xF000 == 0xA000, + encrypted: h.generalPurposeBitFlag & 0x1 != 0), + ]; + } finally { + await input.close(); + } +} + +/// 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 { + // 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다 + if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException(); + final input = InputFileStream(path); + final r = ExtractResult(); + try { + // verify: CRC 를 맞춰 보아 비밀번호가 틀리면 (풀린 내용이 다르면) 실패로 + final a = ZipDecoder().decodeStream(input, password: password, verify: plan.encrypted); + final want = {for (final e in plan.files) e.name}; + 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(); + var target = extractTarget(dest, f.name); + if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { + if (conflict == NameConflict.skip) { + r.skippedExisting.add(f.name); + continue; + } + if (conflict == NameConflict.rename) { + target = _freeName(target); + r.renamed.add(f.name); + } + } + await Directory(p.dirname(target)).create(recursive: true); + final bytes = f.readBytes(); + if (bytes == null) continue; + // 반쯤 쓴 파일이 완성된 이름으로 남지 않게 (.jjpart 에 쓴 뒤 이름 바꾸기) + final part = File('$target.jjpart'); + await part.writeAsBytes(bytes, flush: true); + if (await File(target).exists()) await File(target).delete(); + await part.rename(target); + r.done++; + onProgress?.call(r.done, todo.length); + } + } on ArchivePasswordException { + rethrow; + } catch (e) { + // archive 는 틀린 비밀번호를 ArchiveException · 그냥 Exception ("password error") 으로 알린다 + if (plan.encrypted && (e is ArchiveException || '$e'.toLowerCase().contains('password'))) { + throw const ArchivePasswordException(wrong: true); + } + rethrow; + } finally { + await input.close(); + } + return r; +} + +/// ZIP 만들기: [sources] (파일 · 폴더) 를 [zipPath] 로. 링크 · junction 은 넣지 않는다 (밖을 가리키는 것을 따라가지 않게). +/// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움). +/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수) +Future<(int, int)> createZip(List sources, String zipPath, + {int level = 6, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { + // 넣을 파일 (상대 이름) - 링크는 세기만 + final files = <(String, String)>[]; + var links = 0; + for (final s in sources) { + final base = p.dirname(s); + final t = FileSystemEntity.typeSync(s, followLinks: false); + if (t == FileSystemEntityType.link) { + links++; + } else if (t == FileSystemEntityType.file) { + files.add((s, p.relative(s, from: base))); + } else if (t == FileSystemEntityType.directory) { + await for (final e in Directory(s).list(recursive: true, followLinks: false)) { + if (e is Link || FileSystemEntity.isLinkSync(e.path)) { + links++; + } else if (e is File) { + files.add((e.path, p.relative(e.path, from: base))); + } + } + } + } + final part = '$zipPath.jjpart'; + final enc = ZipFileEncoder()..create(part, level: level); + var done = false; + try { + for (final (i, (path, rel)) in files.indexed) { + if (cancelled?.call() ?? false) throw const ArchiveCancelled(); + await enc.addFile(File(path), rel.replaceAll('\\', '/'), level); + onProgress?.call(i + 1, files.length); + } + await enc.close(); + done = true; + await File(part).rename(zipPath); + } finally { + if (!done) { + try { + await enc.close(); + } catch (_) {} + try { + await File(part).delete(); + } catch (_) {} + } + } + return (files.length, links); +} + +// ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ───────── + +/// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점 +List parse7zList(String out) { + final entries = []; + for (final block in out.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) { + final m = {}; + for (final line in block.split('\n')) { + final i = line.indexOf(' = '); + if (i > 0) m[line.substring(0, i).trim()] = line.substring(i + 3); + } + final raw = m['Path']; + if (raw == null || raw.isEmpty) continue; + final attr = m['Attributes'] ?? ''; + final link = (m['Symbolic Link'] ?? '').isNotEmpty || + (m['Hard Link'] ?? '').isNotEmpty || + RegExp(r'(^|\s)-?l[r-][w-][x-]').hasMatch(attr) || + attr.contains('REPARSE'); + entries.add(ArchiveEntry(raw.replaceAll('\\', '/'), + raw: raw, + size: int.tryParse(m['Size'] ?? '') ?? 0, + isDir: (m['Folder'] ?? '') == '+' || RegExp(r'^[A-Z_]*D').hasMatch(attr), + isLink: link, + encrypted: (m['Encrypted'] ?? '') == '+')); + } + return entries; +} + +/// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단) +Future<(int, String, String)> _run7z(String exe, List args, + {String? password, void Function(String line)? onLine, bool Function()? cancelled}) async { + final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args]); + // 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다) + pr.stdin.writeln(password ?? ''); + await pr.stdin.close(); + final out = StringBuffer(), err = StringBuffer(); + // 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다) + final a = pr.stdout.transform(const Utf8Decoder(allowMalformed: true)).forEach((s) { + out.write(s); + if (onLine != null) { + for (final l in s.split(RegExp(r'[\r\n\b]+'))) { + if (l.trim().isNotEmpty) onLine(l); + } + } + }); + final b = pr.stderr.transform(const Utf8Decoder(allowMalformed: true)).forEach(err.write); + Timer? watch; + if (cancelled != null) { + watch = Timer.periodic(const Duration(milliseconds: 200), (_) { + if (cancelled()) pr.kill(); + }); + } + final code = await pr.exitCode; + watch?.cancel(); + await a.catchError((_) {}); + await b.catchError((_) {}); + if (cancelled?.call() ?? false) throw const ArchiveCancelled(); + return (code, '$out', '$err'); +} + +bool _wrongPassword(String text) { + final t = text.toLowerCase(); + return t.contains('wrong password') || t.contains('can not open encrypted') || t.contains('cannot open encrypted'); +} + +/// 7z · RAR 목록 (머리까지 암호가 걸렸으면 [password] 없이 [ArchivePasswordException]) +Future> list7z(String exe, String path, {String? password}) async { + final (code, out, err) = await _run7z(exe, ['l', '-slt', '-ba', path], password: password); + if (code != 0) { + if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty); + throw ProcessException(exe, const ['l'], err.trim().isEmpty ? out.trim() : err.trim(), code); + } + return parse7zList(out); +} + +/// 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 { + final r = ExtractResult(); + if (plan.files.isEmpty) return r; + final tmp = await Directory.systemTemp.createTemp('jj_7z_'); + try { + final list = File(p.join(tmp.path, 'list.txt')); + await list.writeAsString(plan.files.map((e) => e.raw).join('\n'), encoding: utf8); + final existing = [for (final e in plan.files) if (FileSystemEntity.typeSync(extractTarget(dest, e.name)) != FileSystemEntityType.notFound) e.name]; + final ao = switch (conflict) { NameConflict.overwrite => '-aoa', NameConflict.skip => '-aos', _ => '-aou' }; + final (code, out, err) = await _run7z(exe, ['x', path, '-o$dest', ao, '-bsp1', '-bb0', '@${list.path}'], + password: password, cancelled: cancelled, onLine: (l) { + final m = RegExp(r'(\d+)%').firstMatch(l); + if (m != null) onProgress?.call(int.parse(m[1]!) / 100); + }); + if (code != 0) { + if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty); + throw ProcessException(exe, const ['x'], err.trim().isEmpty ? out.trim() : err.trim(), code); + } + if (conflict == NameConflict.skip) { + r.skippedExisting.addAll(existing); + } else if (conflict == NameConflict.rename) { + r.renamed.addAll(existing); + } + r.done = plan.files.length - (conflict == NameConflict.skip ? existing.length : 0); + return r; + } finally { + try { + await tmp.delete(recursive: true); + } catch (_) {} + } +} diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 090fa79..fd6f78b 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -273,6 +273,21 @@ class _AiSettingsState extends State { '새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')), trailing: Switch(value: s.aiQualityButton, onChanged: (v) => c.updateSettings((x) => x.aiQualityButton = v)), ), + if (s.aiQualityButton) + SettingTile( + leading: const Icon(Icons.tune), + title: Text(tr('품질 올리기 다듬기 정도')), + subtitle: Text(tr('강할수록 많이 다듬지만 원본과 달라질 수 있습니다')), + trailing: DropdownButton( + value: s.aiQualityStrength, + items: [ + DropdownMenuItem(value: 'weak', child: Text(tr('약하게 (0.15)'))), + DropdownMenuItem(value: 'normal', child: Text(tr('보통 (0.25, 기본)'))), + DropdownMenuItem(value: 'strong', child: Text(tr('강하게 (0.4)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.aiQualityStrength = v ?? 'normal'), + ), + ), SettingTile( leading: const Icon(Icons.image_outlined), title: Text(tr('저장 형식')), diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 635262a..a5c28db 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -426,7 +426,7 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) /// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false /// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] - -/// AI 그림 (그림 → 그림, 약하게 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율). +/// AI 그림 (그림 → 그림, 설정의 다듬기 정도 - 처음 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율). /// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내. Future qualityUp(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { if (AiJobs.instance.busy) { @@ -510,7 +510,7 @@ Future qualityUp(BuildContext context, AppController c, {required Strin steps: c.settings.aiSteps, cfg: c.settings.aiCfg, initImage: input, - strength: 0.25, + strength: qualityStrength(c.settings.aiQualityStrength), ), outDir: tmp.path, baseName: 'q', diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart new file mode 100644 index 0000000..3b7f5a8 --- /dev/null +++ b/lib/ui/archive_ui.dart @@ -0,0 +1,330 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_controller.dart'; +import '../app/copy_center.dart' show diskSpace; +import '../core/archive_ops.dart'; +import '../core/file_ops.dart'; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 86: 동봉한 7-Zip (Windows: 실행 파일 옆 7zip\7z.exe). 개발 중에는 third_party 의 것. 없으면 null (Android - 아직 없음) +String? bundledSevenZip() { + if (!Platform.isWindows) return null; + for (final f in [ + p.join(p.dirname(Platform.resolvedExecutable), '7zip', '7z.exe'), + p.join(Directory.current.path, 'third_party', '7zip', 'windows', '7z.exe'), + ]) { + if (File(f).existsSync()) return f; + } + return null; +} + +/// 비밀번호 묻기 (보이지 않게 · 작업 기록에 남기지 않음). 취소하면 null +Future askArchivePassword(BuildContext context, String name, {bool wrong = false}) async { + final ctl = TextEditingController(); + try { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('비밀번호')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('"{0}" 은(는) 비밀번호가 걸려 있습니다.', [name])), + if (wrong) Text(tr('비밀번호가 틀렸습니다'), style: const TextStyle(color: JjColors.danger)), + TextField( + controller: ctl, + autofocus: true, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration(labelText: tr('비밀번호'), isDense: true), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, ctl.text), child: Text(tr('확인'))), + ], + ), + ); + } finally { + ctl.dispose(); + } +} + +/// 진행 창 (취소 단추). [run] 은 진행률 (0~1, 모르면 null) 을 [set] 으로 알리고, 취소는 [cancelled] 로 본다 +Future _withProgress(BuildContext context, String title, Future Function(void Function(double?) set, bool Function() cancelled) run) async { + final v = ValueNotifier(null); + var stop = false; + final nav = Navigator.of(context); + var open = true; + unawaited(showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(title), + content: SizedBox( + width: 380, + child: ValueListenableBuilder( + valueListenable: v, + builder: (_, x, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + LinearProgressIndicator(value: x), + const SizedBox(height: 6), + Text(x == null ? tr('준비하는 중') : '${(x * 100).round()}%', style: const TextStyle(fontSize: 12)), + ]), + ), + ), + actions: [TextButton(onPressed: () => stop = true, child: Text(tr('취소')))], + ), + ).whenComplete(() => open = false)); + try { + return await run((x) => v.value = x, () => stop); + } finally { + if (open) nav.pop(); + } +} + +/// 86: 압축 풀기 (ZIP · 7z · RAR). 목록을 먼저 읽어 검사 (밖으로 나가는 경로 · 링크 · 크기 · 비밀번호) 한 뒤 확인 창 → 풀기. +/// [names] 가 있으면 그 항목만. 푼 폴더를 돌려준다 (취소 · 실패면 null) +Future extractArchiveFlow(BuildContext context, AppController c, String path, {String? otherDir, List names = const []}) async { + final kind = archiveKindOf(path); + if (kind == null) return null; + final messenger = ScaffoldMessenger.maybeOf(context); + void say(String t) => messenger?.showSnackBar(SnackBar(content: Text(t))); + final local = isDav(path) ? await vLocalCopy(path, await c.services.storage.tempDirectory()) : path; + if (!context.mounted) return null; + // 7z · RAR: 동봉한 7-Zip 이 있어야 (Android 는 아직 - 막지 않고 다른 앱으로 안내) + final exe = kind == ArchiveKind.zip ? null : bundledSevenZip(); + if (kind != ArchiveKind.zip && exe == null) { + final open = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('압축 풀기')), + content: Text(tr('이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('다른 앱으로 열기'))), + ], + ), + ); + if (open == true) await c.services.shell.openWith(local, choose: true); + return null; + } + // 목록 (머리까지 암호가 걸린 7z 는 비밀번호부터) + String? password; + List entries; + while (true) { + try { + entries = kind == ArchiveKind.zip ? await listZip(local) : await list7z(exe!, local, password: password); + break; + } on ArchivePasswordException catch (e) { + if (!context.mounted) return null; + password = await askArchivePassword(context, vBasename(path), wrong: e.wrong); + if (password == null) return null; + } catch (e) { + say(trf('압축을 읽지 못했습니다: {0}', [e])); + return null; + } + } + final plan = planExtract(entries, names: names); + if (!context.mounted) return null; + // 풀 곳 (WebDAV 폴더로는 풀지 않음 - 이 기기의 폴더만) + final here = vDirname(path); + final base = vBasename(path).replaceAll(RegExp(r'\.[^.]+$'), ''); + final choices = <(String, String)>[ + if (!isDav(here)) (vJoin(here, base), trf('압축 옆 새 폴더 "{0}"', [base])), + if (!isDav(here)) (here, tr('압축이 있는 폴더')), + if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더')), + ]; + if (choices.isEmpty) { + say(tr('WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.')); + return null; + } + var dest = choices.first.$1; + var conflict = switch (c.settings.copyConflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename }; + Future free(String d) async { + var x = d; + while (!Directory(x).existsSync() && p.dirname(x) != x) { + x = p.dirname(x); + } + return (await diskSpace(x))?.$1; + } + + final frees = {for (final (d, _) in choices) d: await free(d)}; + if (!context.mounted) return null; + final go = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + final f = frees[dest]; + final short = f != null && plan.totalSize > f; + return AlertDialog( + scrollable: true, + title: Text(trf('압축 풀기 · {0}', [vBasename(path)])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('파일 {0}개 · {1}', [plan.files.length, formatSize(plan.totalSize)])), + if (plan.unsafe.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(trf('밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}', + [plan.unsafe.length, plan.unsafe.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]), + style: const TextStyle(color: JjColors.danger, fontSize: 12)), + ], + if (plan.links.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(trf('링크 {0}개는 만들지 않고 건너뜁니다: {1}', [plan.links.length, plan.links.take(5).join(', ')]), + style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)), + ], + if (plan.encrypted) ...[ + const SizedBox(height: 6), + Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ], + const SizedBox(height: 10), + Wrap(spacing: 6, runSpacing: 4, children: [ + for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: dest == d, onSelected: (_) => set(() => dest = d)), + ]), + Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 6), + Row(children: [ + Text(tr('같은 이름이 있으면'), style: const TextStyle(fontSize: 13)), + const SizedBox(width: 8), + DropdownButton( + value: conflict, + items: [ + DropdownMenuItem(value: NameConflict.rename, child: Text(tr('이름 바꾸기'))), + DropdownMenuItem(value: NameConflict.skip, child: Text(tr('건너뛰기'))), + DropdownMenuItem(value: NameConflict.overwrite, child: Text(tr('덮어쓰기'))), + ], + onChanged: (v) => set(() => conflict = v!), + ), + ]), + // 관리자 판단: 풀린 크기가 빈 공간보다 크면 풀기 전에 알리고 묻는다 (압축 폭탄 · 공간 부족) + if (short) + Text(trf('풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?', [formatSize(plan.totalSize), formatSize(f)]), + style: const TextStyle(color: JjColors.danger)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton( + onPressed: plan.files.isEmpty ? null : () => Navigator.pop(ctx, true), + child: Text(short ? tr('그래도 풀기') : tr('풀기')), + ), + ], + ); + }, + ), + ); + if (go != true || !context.mounted) return null; + // 비밀번호가 걸린 파일인데 아직 모르면 묻는다 + if (plan.encrypted && (password == null || password.isEmpty)) { + password = await askArchivePassword(context, vBasename(path)); + if (password == null || !context.mounted) return null; + } + while (true) { + try { + final pw = password; + 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)); + } + return extract7zPlan(exe!, local, dest, plan, conflict: conflict, 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 (plan.unsafe.isNotEmpty) trf('막은 경로 {0}개', [plan.unsafe.length]), + if (plan.links.isNotEmpty) trf('건너뛴 링크 {0}개', [plan.links.length]), + ].join(' · ')); + return dest; + } on ArchivePasswordException catch (e) { + if (!context.mounted) return null; + password = await askArchivePassword(context, vBasename(path), wrong: e.wrong); + if (password == null || !context.mounted) return null; + } on ArchiveCancelled { + say(tr('취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).')); + return null; + } catch (e) { + say(trf('풀지 못했습니다: {0}', [e])); + return null; + } + } +} + +/// 86: ZIP 으로 묶기 - 이름 · 압축 정도 (설정으로 기억) · 넣을 곳. 링크 · junction 은 넣지 않는다. 만든 ZIP 경로 (취소 · 실패면 null) +Future createZipFlow(BuildContext context, AppController c, List sources, {required String dir, String? otherDir}) async { + final messenger = ScaffoldMessenger.maybeOf(context); + void say(String t) => messenger?.showSnackBar(SnackBar(content: Text(t))); + if (sources.isEmpty) return null; + if (sources.any(isDav) || isDav(dir)) { + say(tr('WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.')); + return null; + } + final name = TextEditingController( + text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir)); + var level = c.settings.zipLevel; + var where = dir; + final choices = <(String, String)>[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))]; + String target() { + var n = name.text.trim(); + if (n.isEmpty) n = 'archive'; + if (!n.toLowerCase().endsWith('.zip')) n = '$n.zip'; + var t = p.join(where, n); + for (var i = 2; File(t).existsSync(); i++) { + t = p.join(where, '${p.basenameWithoutExtension(n)} ($i).zip'); + } + return t; + } + + final go = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + title: Text(trf('ZIP 으로 묶기 · {0}개', [sources.length])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField( + controller: name, + decoration: InputDecoration(labelText: tr('ZIP 이름'), suffixText: '.zip', isDense: true), + onChanged: (_) => set(() {}), + ), + const SizedBox(height: 10), + Wrap(spacing: 6, children: [ + for (final (v, label) in [(1, tr('빠르게')), (6, tr('보통')), (9, tr('작게'))]) + ChoiceChip(label: Text(label), selected: level == v, onSelected: (_) => set(() => level = v)), + ]), + const SizedBox(height: 6), + Wrap(spacing: 6, runSpacing: 4, children: [ + for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: where == d, onSelected: (_) => set(() => where = d)), + ]), + Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('묶기'))), + ], + ), + ), + ); + final zip = target(); + name.dispose(); + if (go != true || !context.mounted) return null; + if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level); + if (!context.mounted) return null; + try { + final (n, links) = await _withProgress(context, trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]), + (set, cancelled) => createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t))); + say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · ')); + return zip; + } on ArchiveCancelled { + say(tr('취소했습니다. 만들다 만 ZIP 은 남지 않습니다.')); + } catch (e) { + say(trf('ZIP 을 만들지 못했습니다: {0}', [e])); + } + return null; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 0a36578..f763cd1 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -46,6 +46,8 @@ import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'quick_look.dart'; import 'explorer_drag.dart'; +import 'archive_ui.dart'; +import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; import 'batch_rename_dialog.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; @@ -1226,6 +1228,9 @@ class _ExplorerPageState extends State { await _play([path]); return; } + // 86: 7z · RAR 은 열면 압축 풀기 (ZIP 은 아래의 보기 · ZIP 화면) + final kind = archiveKindOf(path); + if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return _extractArchive(path, pane ?? _pane); // 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때) if (c.settings.components.contains('viewer')) { final ext = extOf(path); @@ -1329,6 +1334,13 @@ class _ExplorerPageState extends State { } /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 + /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 + Future _extractArchive(String path, _Pane pane) async { + final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; + final out = await extractArchiveFlow(context, c, path, otherDir: other); + if (out != null && mounted) await _refreshAll([out, vDirname(out)]); + } + Future _openZip(String path, _Pane? pane) async { try { if (c.settings.zipComic && await openZipComic(context, c, path)) return; @@ -1397,6 +1409,9 @@ class _ExplorerPageState extends State { if (e.isDir) ('open', Icons.folder_open, tr('열기')), // 81: 빠른 보기 (PC 는 Space · TC 는 F3) if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), + // 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두) + if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')), + if (!dav) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), @@ -1486,6 +1501,17 @@ class _ExplorerPageState extends State { if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); case 'quickLook': await _quickLook(pane, e.path); + case 'extract': + await _extractArchive(e.path, pane); + case 'zip': + final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]); + final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; + final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); + if (made != null && mounted) { + await _refreshAll([vDirname(made)]); + pane.focused = made; + pane.changed(); + } case 'folderSize': if (_sizes.busy(e.path)) { _sizes.cancel(e.path); diff --git a/lib/ui/license_texts.dart b/lib/ui/license_texts.dart index d155159..089f028 100644 --- a/lib/ui/license_texts.dart +++ b/lib/ui/license_texts.dart @@ -32,6 +32,9 @@ List<(List, String)> get _licenseAssets => [ (['libmpv (media_kit, LGPL-2.1+)'], 'assets/licenses/LGPL-2.1.txt'), ([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'), (['rsync'], 'assets/licenses/RSYNC_COPYING.txt'), + // 86: 7-Zip 25.01 (7z · RAR 풀기 - RAR 은 풀기만, unRAR 라이선스 제한). LGPL 2.1+ · BSD 3-clause · BSD 2-clause + (['7-Zip 25.01 (7z.exe · 7z.dll)'], 'assets/licenses/7ZIP_LICENSE.txt'), + (['7-Zip 25.01 (GNU LGPL 2.1+)'], 'assets/licenses/LGPL-2.1.txt'), (['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'), // Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs) (['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'), diff --git a/lib/ui/zip_page.dart b/lib/ui/zip_page.dart index 1bdf1e9..4c2b366 100644 --- a/lib/ui/zip_page.dart +++ b/lib/ui/zip_page.dart @@ -6,6 +6,7 @@ import '../core/file_ops.dart' show formatSize; import '../core/reader_sources.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; +import 'archive_ui.dart'; import 'reader_page.dart'; import 'theme.dart'; @@ -62,62 +63,10 @@ class _ZipPageState extends State { bool get _hasImages => _entries?.any((f) => c.settings.imageExts.contains(extOf(f.name))) ?? false; + /// 86: 풀기는 압축 풀기 흐름 (목록 검사 - 밖으로 나가는 경로 · 링크 · 크기 · 비밀번호, 같은 이름 · 진행 · 취소) 으로 Future _extract({required bool all}) async { - final zipDir = vDirname(widget.path); - final base = vBasename(widget.path).replaceAll(RegExp(r'\.[^.]+$'), ''); - final dest = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: Text(all ? tr('모두 풀기') : trf('{0}개 풀기', [_marked.length])), - children: [ - if (widget.otherDir != null) - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, widget.otherDir), - child: ListTile( - leading: const Icon(Icons.vertical_split_outlined), - title: Text(tr('다른 창 폴더로')), - subtitle: Text(vDisplay(widget.otherDir!)), - ), - ), - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, vJoin(zipDir, base)), - child: ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: Text(trf('ZIP 옆 새 폴더 "{0}" 로', [base])), - subtitle: Text(vDisplay(zipDir)), - ), - ), - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, zipDir), - child: ListTile( - leading: const Icon(Icons.folder_outlined), - title: Text(tr('ZIP 이 있는 폴더로')), - subtitle: Text(vDisplay(zipDir)), - ), - ), - ], - ), - ); - if (dest == null || !mounted) return; - setState(() => _progress = (0, 0)); - try { - final temp = await c.services.storage.tempDirectory(); - final n = await extractZip(widget.path, dest, - tempDir: temp, - names: all ? const [] : _marked.toList(), - onProgress: (d, t) { - if (mounted) setState(() => _progress = (d, t)); - }); - if (!mounted) return; - setState(() => _progress = null); - ScaffoldMessenger.maybeOf(context) - ?.showSnackBar(SnackBar(content: Text(trf('{0}개를 풀었습니다: {1}', [n, vDisplay(dest)])))); - Navigator.pop(context, dest); - } catch (e) { - if (!mounted) return; - setState(() => _progress = null); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('풀지 못했습니다: {0}', [e])))); - } + final dest = await extractArchiveFlow(context, c, widget.path, otherDir: widget.otherDir, names: all ? const [] : _marked.toList()); + if (dest != null && mounted) Navigator.pop(context, dest); } @override diff --git a/test/ai_quality_test.dart b/test/ai_quality_test.dart index fb64289..7315aa2 100644 --- a/test/ai_quality_test.dart +++ b/test/ai_quality_test.dart @@ -27,6 +27,11 @@ void main() { expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2); expect(AppSettings().aiQualityButton, isFalse); expect(AppSettings.fromJson((AppSettings()..aiQualityButton = true).toJson()).aiQualityButton, isTrue); + // 다듬기 정도: 약하게 0.15 · 보통 0.25 (처음) · 강하게 0.4, 모르는 값은 보통 + expect(AppSettings().aiQualityStrength, 'normal'); + expect([for (final s in ['weak', 'normal', 'strong']) qualityStrength(s)], [0.15, 0.25, 0.4]); + expect(AppSettings.fromJson({'aiQualityStrength': 'x'}).aiQualityStrength, 'normal'); + expect(AppSettings.fromJson((AppSettings()..aiQualityStrength = 'strong').toJson()).aiQualityStrength, 'strong'); }); test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async { diff --git a/test/archive_ops_test.dart b/test/archive_ops_test.dart new file mode 100644 index 0000000..7e8de59 --- /dev/null +++ b/test/archive_ops_test.dart @@ -0,0 +1,172 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/archive_ops.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show NameConflict; +import 'package:path/path.dart' as p; + +/// 86: 압축 - ZIP 만들기 · 풀기 (archive), 7z · RAR 풀기 (7-Zip). 풀기 전 검사 (zip slip · 링크 · 비밀번호) +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_arch86_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('막는 이름: ../ · 절대 경로 · 드라이브 · UNC · ":" (괜찮은 것은 그대로)', () { + for (final n in ['../evil.txt', 'a/../../x', '/etc/passwd', r'\x.txt', 'C:/Windows/x.dll', r'C:\x', r'\\nas\share\x', '//nas/x', 'a/b:stream']) { + expect(unsafeArchivePath(n), isNotNull, reason: n); + } + for (final n in ['a.txt', 'dir/b.txt', r'dir\c.txt', './d.txt', 'a..b/c..txt']) { + expect(unsafeArchivePath(n), isNull, reason: n); + } + }); + + test('풀기 전 검사: 막은 것 · 링크는 빼고, 크기 합 · 비밀번호 · 고른 이름만', () { + final plan = planExtract([ + const ArchiveEntry('ok/a.txt', size: 10), + const ArchiveEntry('../evil.txt', size: 5), + const ArchiveEntry('link', isLink: true), + const ArchiveEntry('ok', isDir: true), + const ArchiveEntry('ok/b.txt', size: 7, encrypted: true), + const ArchiveEntry('other/c.txt', size: 100), + ], names: ['ok', '../evil.txt', 'link']); + expect(plan.files.map((e) => e.name), ['ok/a.txt', 'ok/b.txt']); + expect(plan.unsafe.single.$1, '../evil.txt'); + expect(plan.links, ['link']); + expect(plan.totalSize, 17); + expect(plan.encrypted, isTrue); + }); + + group('ZIP', () { + test('만들기 (폴더 · 파일, 링크는 넣지 않음) → 목록 → 풀기 (같은 이름은 고른 대로)', () async { + final src = Directory(p.join(tmp.path, 'src'))..createSync(); + File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); + File(p.join(src.path, 'sub', 'b.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('B'); + final one = File(p.join(tmp.path, 'one.txt'))..writeAsStringSync('1'); + var links = 0; + if (Platform.isWindows) { + final r = await Process.run('cmd', ['/c', 'mklink', '/J', p.join(src.path, 'jn'), tmp.path]); + if (r.exitCode == 0) links = 1; + } + final zip = p.join(tmp.path, 'out.zip'); + final (n, skipped) = await createZip([src.path, one.path], zip, level: 9); + expect(n, 3); + expect(skipped, links, reason: 'junction 은 따라가지 않는다 (바깥을 묶지 않게)'); + expect(File('$zip.jjpart').existsSync(), isFalse); + if (links > 0) Link(p.join(src.path, 'jn')).deleteSync(); + + final entries = await listZip(zip); + expect(entries.where((e) => !e.isDir).map((e) => e.name).toSet(), {'src/a.txt', 'src/sub/b.txt', 'one.txt'}); + final dest = Directory(p.join(tmp.path, 'dest'))..createSync(); + File(p.join(dest.path, 'one.txt')).writeAsStringSync('old'); + final plan = planExtract(entries); + final r1 = await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.skip); + expect(r1.skippedExisting, ['one.txt']); + expect(File(p.join(dest.path, 'one.txt')).readAsStringSync(), 'old'); + expect(File(p.join(dest.path, 'src', 'sub', 'b.txt')).readAsStringSync(), 'B'); + final r2 = await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.rename); + expect(r2.renamed, contains('one.txt')); + expect(File(p.join(dest.path, 'one (2).txt')).readAsStringSync(), '1'); + await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.overwrite); + expect(File(p.join(dest.path, 'one.txt')).readAsStringSync(), '1'); + }); + + test('zip slip: ../ · 드라이브 경로 · 링크 항목은 대상 밖에 쓰지 않고 알린다', () async { + final a = Archive() + ..addFile(ArchiveFile.string('ok.txt', 'ok')) + ..addFile(ArchiveFile.string('../evil.txt', 'evil')) + ..addFile(ArchiveFile.string('C:/evil2.txt', 'evil')) + // 유닉스 심볼릭 링크 항목 (파일 종류 0xA000 - 내용은 가리키는 곳) + ..addFile(ArchiveFile.string('lnk', '../../outside')..mode = 0xA1FF); + final zip = p.join(tmp.path, 'bad.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(a)); + final plan = planExtract(await listZip(zip)); + expect(plan.unsafe.map((x) => x.$1).toSet(), {'../evil.txt', 'C:/evil2.txt'}); + expect(plan.links, ['lnk']); + final dest = Directory(p.join(tmp.path, 'd', 'dest'))..createSync(recursive: true); + final r = await extractZipPlan(zip, dest.path, plan); + expect(r.done, 1); + expect(File(p.join(tmp.path, 'd', 'evil.txt')).existsSync(), isFalse); + expect(dest.listSync().map((e) => p.basename(e.path)), ['ok.txt']); + }); + + test('비밀번호: 걸린 ZIP 은 비밀번호 없이는 쓰지 않고 묻는다 · 틀리면 틀림 · 맞으면 풀림', () async { + final src = File(p.join(tmp.path, 's.txt'))..writeAsStringSync('secret text'); + final zip = p.join(tmp.path, 'enc.zip'); + final enc = ZipFileEncoder(password: 'pw1')..create(zip); + await enc.addFile(src, 's.txt'); + await enc.close(); + final plan = planExtract(await listZip(zip)); + expect(plan.encrypted, isTrue); + final dest = p.join(tmp.path, 'out'); + await expectLater(extractZipPlan(zip, dest, plan), throwsA(isA().having((e) => e.wrong, 'wrong', isFalse))); + expect(Directory(dest).existsSync(), isFalse, reason: '암호 글자를 그대로 쓰지 않음'); + await expectLater(extractZipPlan(zip, dest, plan, password: 'nope'), throwsA(isA())); + await extractZipPlan(zip, dest, plan, password: 'pw1'); + expect(File(p.join(dest, 's.txt')).readAsStringSync(), 'secret text'); + }); + + test('만들기 취소: 만들다 만 ZIP 이 남지 않는다', () async { + for (var i = 0; i < 5; i++) { + File(p.join(tmp.path, 'f$i.txt')).writeAsStringSync('$i'); + } + final zip = p.join(tmp.path, 'c.zip'); + var n = 0; + await expectLater( + createZip([for (var i = 0; i < 5; i++) p.join(tmp.path, 'f$i.txt')], zip, cancelled: () => ++n > 2), throwsA(isA())); + expect(File(zip).existsSync(), isFalse); + expect(File('$zip.jjpart').existsSync(), isFalse); + }); + }); + + group('7z (7-Zip 실행 파일)', () { + final exe = p.join(Directory.current.path, 'third_party', '7zip', 'windows', '7z.exe'); + final has = Platform.isWindows && File(exe).existsSync(); + + test('목록 · 풀기 · 비밀번호는 표준 입력으로 (머리까지 암호) · 틀린 비밀번호', () async { + final src = Directory(p.join(tmp.path, 'src'))..createSync(); + File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); + File(p.join(src.path, 'sub', 'b.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('B'); + final plain = p.join(tmp.path, 'p.7z'), enc = p.join(tmp.path, 'e.7z'); + // (시험 파일 만들기에만 명령 줄 비밀번호) + expect((await Process.run(exe, ['a', '-t7z', plain, src.path])).exitCode, 0); + expect((await Process.run(exe, ['a', '-t7z', '-ppw2', '-mhe=on', enc, src.path])).exitCode, 0); + + final list = await list7z(exe, plain); + expect(list.where((e) => !e.isDir).map((e) => e.name).toSet(), {'src/a.txt', 'src/sub/b.txt'}); + final dest = p.join(tmp.path, 'out'); + final r = await extract7zPlan(exe, plain, dest, planExtract(list)); + expect(r.done, 2); + expect(File(p.join(dest, 'src', 'sub', 'b.txt')).readAsStringSync(), 'B'); + // 같은 이름 - 건너뛰기 · 이름 바꾸기 + final again = await extract7zPlan(exe, plain, dest, planExtract(list), conflict: NameConflict.skip); + expect(again.skippedExisting, hasLength(2)); + await extract7zPlan(exe, plain, dest, planExtract(list), conflict: NameConflict.rename); + 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); + + 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'); + final out2 = p.join(tmp.path, 'out2'); + await extract7zPlan(exe, enc, out2, planExtract(encList), password: 'pw2'); + expect(File(p.join(out2, 'src', 'a.txt')).readAsStringSync(), 'A'); + }, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)'); + + test('7-Zip 목록 글 읽기: 링크 (유닉스 · 재분석 지점) · 폴더 · 비밀번호', () { + final l = parse7zList('Path = a\\b.txt\nSize = 3\nAttributes = A\nEncrypted = +\n\n' + 'Path = d\nSize = 0\nAttributes = D\n\n' + 'Path = lnk\nSize = 9\nAttributes = A_ -lrwxrwxrwx\n\n' + 'Path = j\nAttributes = D REPARSE\n'); + expect(l.map((e) => e.name), ['a/b.txt', 'd', 'lnk', 'j']); + expect(l[0].raw, 'a\\b.txt'); + expect(l[0].encrypted, isTrue); + expect(l[1].isDir, isTrue); + expect([l[2].isLink, l[3].isLink], [true, true]); + }); + }); +} diff --git a/test/archive_ui_test.dart b/test/archive_ui_test.dart new file mode 100644 index 0000000..41225ee --- /dev/null +++ b/test/archive_ui_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.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/archive_ui.dart'; +import 'package:path/path.dart' as p; + +/// 86: 압축 풀기 · ZIP 묶기 화면 흐름 - 확인 창에 막은 경로 · 링크, 취소는 아무 일 없음, 묶기는 만든 ZIP +void main() { + late Directory tmp; + late AppController c; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_arch_ui_'); + c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future wait(WidgetTester tester, bool Function() done) async { + for (var i = 0; i < 200 && !done(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + } + + Future Function(Future Function(BuildContext))> host(WidgetTester tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold())); + return (Future Function(BuildContext) f) => f(key.currentContext!); + } + + testWidgets('풀기: 확인 창에 막은 경로 · 건너뛸 링크를 보이고, 풀면 대상 안에만', (tester) async { + final a = Archive() + ..addFile(ArchiveFile.string('ok.txt', 'ok')) + ..addFile(ArchiveFile.string('../evil.txt', 'evil')) + ..addFile(ArchiveFile.string('lnk', '../x')..mode = 0xA1FF); + final zip = p.join(tmp.path, 'bad.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(a)); + final run = await host(tester); + String? out; + var finished = false; + run((ctx) => extractArchiveFlow(ctx, c, zip)).then((v) { + out = v; + finished = true; + }); + await wait(tester, () => find.text('풀기').evaluate().isNotEmpty); + expect(find.textContaining('밖으로 나가는 경로라 풀지 않습니다 (1개)'), findsOneWidget); + expect(find.textContaining('링크 1개는 만들지 않고 건너뜁니다'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '풀기')); + await wait(tester, () => finished); + expect(out, p.join(tmp.path, 'bad')); + expect(File(p.join(tmp.path, 'bad', 'ok.txt')).readAsStringSync(), 'ok'); + expect(File(p.join(tmp.path, 'evil.txt')).existsSync(), isFalse); + }); + + testWidgets('풀기 [취소] 는 아무 일 없음 · 7z 를 풀 수 없는 기기면 다른 앱 안내 (막지 않음)', (tester) async { + final zip = p.join(tmp.path, 'a.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('a.txt', 'a')))); + final run = await host(tester); + var finished = false; + run((ctx) => extractArchiveFlow(ctx, c, zip)).then((_) => finished = true); + await wait(tester, () => find.text('풀기').evaluate().isNotEmpty); + await tester.tap(find.text('취소')); + await wait(tester, () => finished); + expect(Directory(p.join(tmp.path, 'a')).existsSync(), isFalse); + if (bundledSevenZip() == null) { + final sz = File(p.join(tmp.path, 'x.7z'))..writeAsStringSync('7z'); + finished = false; + run((ctx) => extractArchiveFlow(ctx, c, sz.path)).then((_) => finished = true); + await wait(tester, () => find.text('다른 앱으로 열기').evaluate().isNotEmpty); + expect(find.textContaining('다른 앱으로 열어 주세요'), findsOneWidget); + await tester.tap(find.text('닫기')); + await wait(tester, () => finished); + } + }); + + testWidgets('ZIP 으로 묶기: 이름 · 압축 정도 (기억) → 만든 ZIP', (tester) async { + final src = Directory(p.join(tmp.path, 'photos'))..createSync(); + File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); + final run = await host(tester); + String? made; + var finished = false; + run((ctx) => createZipFlow(ctx, c, [src.path], dir: tmp.path)).then((v) { + made = v; + finished = true; + }); + await wait(tester, () => find.text('묶기').evaluate().isNotEmpty); + expect(find.text('만들 파일: photos.zip'), findsOneWidget); + await tester.tap(find.text('작게')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '묶기')); + await wait(tester, () => finished); + expect(made, p.join(tmp.path, 'photos.zip')); + expect(c.settings.zipLevel, 9, reason: '고른 압축 정도를 기억'); + expect(File(made!).lengthSync() > 0, isTrue); + }); +} diff --git a/test/licenses_test.dart b/test/licenses_test.dart index 32abb22..dfb3588 100644 --- a/test/licenses_test.dart +++ b/test/licenses_test.dart @@ -79,6 +79,25 @@ void main() { } }); + test('86: 7-Zip - 라이선스 화면에 원문 (LGPL · BSD · unRAR 제한) · 고지 문서에 판 · 소스 · RAR 은 풀기만 · 설치 폴더에 원문', () async { + registerAppLicenses(); + final entries = await LicenseRegistry.licenses.toList(); + final t = entries.firstWhere((e) => e.packages.any((p) => p.startsWith('7-Zip 25.01 (7z.exe'))).paragraphs.map((p) => p.text).join('\n'); + for (final s in ['Igor Pavlov', 'GNU LGPL', 'unRAR license restriction', 'BSD 3-clause License']) { + expect(t, contains(s), reason: s); + } + final lgpl = entries.firstWhere((e) => e.packages.any((p) => p.startsWith('7-Zip 25.01 (GNU LGPL'))).paragraphs.map((p) => p.text).join('\n'); + expect(lgpl, contains('LESSER GENERAL PUBLIC LICENSE')); + final notices = File('assets/licenses/THIRD_PARTY_NOTICES.txt').readAsStringSync(); + for (final s in ['7-Zip 25.01', '7z2501-src.7z', 'RAR 을 만들지 않습니다', '7ZIP_LICENSE.txt']) { + expect(notices, contains(s), reason: s); + } + final cmake = File('windows/CMakeLists.txt').readAsStringSync(); + expect(cmake, contains('third_party/7zip/windows')); + expect(cmake, contains('License.txt')); + expect(File('tool/fetch_7zip.ps1').readAsStringSync(), contains('e7eb0b7ed5efa4e087b7b17f191797f7af5b7f442d1290c66f3a21777005ef57')); + }); + test('Windows 설치: 실행 파일 옆 · tools · rsync 에 원문을 둔다 (CMake)', () { final cmake = File('windows/CMakeLists.txt').readAsStringSync(); expect(cmake, contains('assets/licenses')); diff --git a/tool/fetch_7zip.ps1 b/tool/fetch_7zip.ps1 new file mode 100644 index 0000000..8eb8bf6 --- /dev/null +++ b/tool/fetch_7zip.ps1 @@ -0,0 +1,37 @@ +# 86: 7-Zip (7z.exe + 7z.dll) 을 third_party\7zip\windows 로 준비한다 (빌드 시 앱의 7zip\ 에 동봉됨). +# 7z · RAR 풀기 (RAR 은 풀기만 - unRAR 라이선스 제한) 와 7z 목록 읽기에 쓴다. ZIP 은 앱 안의 archive 패키지로. +# 공식 배포처 (7-zip.org) 의 MSI 를 받아 SHA-256 을 확인하고, 관리자 설치 (msiexec /a) 로 풀어 필요한 파일만 꺼낸다 +# (설치하지 않음 - 시스템을 바꾸지 않는다). +# 라이선스: GNU LGPL 2.1+ (대부분) · unRAR 라이선스 제한 (RAR 풀기 코드) · BSD 3-clause · BSD 2-clause. +# 원문은 License.txt (assets\licenses\7ZIP_LICENSE.txt 와 같음), 소스: https://www.7-zip.org/download.html +# 받은 곳: https://www.7-zip.org/a/7z2501-x64.msi (7-Zip 25.01 x64, 2025-08-03 판) - 2026-10-17 에 받아 SHA-256 을 고정. +# 공식 MSI 는 전자 서명이 없어 해시로만 고정한다 (관리자 승인). +# 사용법: powershell -File tool\fetch_7zip.ps1 +$ErrorActionPreference = 'Stop' +$version = '25.01' +$msi = '7z2501-x64.msi' +$sha = 'e7eb0b7ed5efa4e087b7b17f191797f7af5b7f442d1290c66f3a21777005ef57' +$url = "https://www.7-zip.org/a/$msi" + +$dest = Join-Path $PSScriptRoot '..\third_party\7zip\windows' +$tmp = Join-Path $env:TEMP 'jj_7zip_fetch' +Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $tmp, $dest | Out-Null +$f = Join-Path $tmp $msi +curl.exe -L -s -f -o $f $url +if ($LASTEXITCODE -ne 0) { throw "$msi 을(를) 받지 못했습니다" } +$got = (Get-FileHash $f -Algorithm SHA256).Hash.ToLower() +if ($got -ne $sha) { throw "$msi 의 SHA256 이 다릅니다 ($got)" } +$x = Join-Path $tmp 'x' +Start-Process msiexec.exe -ArgumentList '/a', "`"$f`"", '/qn', "TARGETDIR=`"$x`"" -Wait +$src = Join-Path $x 'Files\7-Zip' +Get-ChildItem $dest | Remove-Item -Force +foreach ($n in '7z.exe', '7z.dll', 'License.txt') { + if (-not (Test-Path (Join-Path $src $n))) { throw "$n 이(가) MSI 에 없습니다" } + Copy-Item (Join-Path $src $n) $dest -Force +} +$ver = (& (Join-Path $dest '7z.exe') i | Select-Object -First 3 | Where-Object { $_ -match '7-Zip' }) -join '' +if ($ver -notmatch [regex]::Escape($version)) { throw "판 번호가 다릅니다: $ver" } +Write-Host $ver +Remove-Item $tmp -Recurse -Force +Write-Host "완료: $dest" diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 0b41d56..2c4b265 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -143,6 +143,16 @@ if(JJ_TOOL_FILES) DESTINATION "${CMAKE_INSTALL_PREFIX}/tools" COMPONENT Runtime) endif() +# JJ_MKVMaker: 86 7-Zip 동봉 (7z · RAR 풀기). 실행 파일 옆 7zip\ 폴더로 복사 (License.txt 원문 함께). +# 파일은 tool\fetch_7zip.ps1 로 third_party\7zip\windows 에 준비한다 (7-Zip 25.01, SHA-256 고정). +set(JJ_7ZIP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/7zip/windows") +file(GLOB JJ_7ZIP_FILES "${JJ_7ZIP_DIR}/7z.exe" "${JJ_7ZIP_DIR}/7z.dll" "${JJ_7ZIP_DIR}/License.txt") +if(JJ_7ZIP_FILES) + install(FILES ${JJ_7ZIP_FILES} + DESTINATION "${CMAKE_INSTALL_PREFIX}/7zip" COMPONENT Runtime) +endif() +install(FILES "${JJ_LICENSES}/LGPL-2.1.txt" DESTINATION "${CMAKE_INSTALL_PREFIX}/7zip" COMPONENT Runtime OPTIONAL) + # JJ_MKVMaker: rsync 동봉 (내려받기 없이 바로). 실행 파일 옆 rsync\ 폴더로 복사. # 파일은 tool\fetch_rsync.ps1 로 third_party\rsync\windows 에 준비한다. set(JJ_RSYNC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rsync/windows")