WIP 86 압축: ZIP 만들기 · 풀기, 7z · RAR 풀기 (Windows - 7-Zip 25.01 동봉), 풀기 전 검사 + 품질 올리기 다듬기 정도 설정 (검토 대기)

86 (관리자 지시 · 승인):
- ZIP 만들기 (archive 4.3.0, MIT): 길게 누르기 [ZIP 으로 묶기] (표시한 것 모두 · 바깥 것만) - 이름 · 압축 정도
  (빠르게 1 · 보통 6 (처음 값) · 작게 9, 설정 zipLevel 로 기억) · 넣을 곳 (지금 폴더 · 다른 창). 링크 · junction 은 넣지 않음.
  .jjpart 에 쓰고 다 되면 이름 바꾸기 - 취소 · 실패면 만들다 만 ZIP 이 남지 않음. 같은 이름이면 "이름 (2).zip".
- 풀기: 길게 누르기 [압축 풀기] (ZIP · 7z · RAR), 7z · RAR 은 열면 풀기, 보기의 ZIP 화면 [풀기] 도 같은 흐름.
  풀 곳 (압축 옆 새 폴더 · 압축이 있는 폴더 · 다른 창), 같은 이름 (이름 바꾸기 · 건너뛰기 · 덮어쓰기 - 처음은 복사 설정대로), 진행 창 · 취소.
- 7z · RAR: 7-Zip 25.01 (7z.exe · 7z.dll) 을 실행 파일 옆 7zip\ 에 동봉 (tool/fetch_7zip.ps1 - 공식 MSI, SHA-256 고정,
  관리자 설치로 풀기만). RAR 은 풀기만 (unRAR 라이선스) - RAR 만들기 선택지 없음.
  Android 는 아직 (lib7zz.so 빌드는 마감 뒤로) - 막지 않고 "다른 앱으로 열기" 안내.
- 보안 (관리자 판단) - 셋 다 목록을 먼저 읽고 검사:
  · 밖으로 나가는 경로 (../ · 절대 경로 · 드라이브 · UNC · ':') 는 막고 확인 창 · 끝 알림에 (zip slip).
  · 압축 안의 심볼릭 링크 · junction 은 만들지 않고 건너뛰고 알림.
  · 풀린 크기가 대상의 빈 공간보다 크면 풀기 전에 알리고 묻는다 ([그래도 풀기]).
  · 비밀번호: 보이지 않는 칸으로 묻고, 7-Zip 에는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · app.log 에 없음).
    ZIP 은 가운데 목록에서 암호 표시를 읽어, 비밀번호 없이 암호 글자를 그대로 쓰지 않는다. 틀리면 다시 묻는다.
- 라이선스: assets/licenses/7ZIP_LICENSE.txt (원문 - LGPL 2.1+ · BSD 3/2-clause · unRAR 제한), 라이선스 화면 (7-Zip · LGPL),
  THIRD_PARTY_NOTICES (판 · 소스 7z2501-src.7z · RAR 은 만들지 않음), 설치 폴더 7zip\ 에 License.txt · LGPL-2.1.txt.
- 내 판단: 진행은 탐색기 진행 패널이 아니라 진행 창 (취소) - 진행 패널은 복사 · 이동 (TransferJob) 전용이라.
보충 (관리자 지시): [품질 올리기] 다듬기 정도 설정 - 약하게 0.15 / 보통 0.25 (처음 값) / 강하게 0.4. fetch 스크립트에 받은 주소 · 판 · 날짜.
- 시험: archive_ops_test (막는 이름 · 계획 · ZIP 만들기 · 풀기 · 같은 이름 · zip slip · 비밀번호 · 취소, 실제 7-Zip 목록 · 풀기 ·
  표준 입력 비밀번호 · 머리 암호 · 틀린 비밀번호), archive_ui_test (확인 창 · 취소 · 다른 앱 안내 · 묶기), licenses_test (7-Zip),
  ai_quality_test (다듬기 정도).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 14:38:26 +09:00
parent eee38aa2fd
commit 219510fec0
20 changed files with 1402 additions and 58 deletions

View File

@ -32,6 +32,7 @@
"\"{0}\" 안에 만듭니다": "Created inside \"{0}\"", "\"{0}\" 안에 만듭니다": "Created inside \"{0}\"",
"\"{0}\" 에 맞는 항목이 없습니다": "No items match \"{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}\" 에는 없고 대상 \"{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}\" 을(를) \"{1}\" 폴더로 옮길까요?": "Move \"{0}\" to the \"{1}\" folder?",
"\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?", "\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?",
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "Delete \"{0}\"? This cannot be undone.", "\"{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 원본에 씁니다 (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 의 동영상은 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 파일은 이름 · 크기 · 날짜만 봅니다": "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 · --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).", "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)", "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 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > 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 · CBZ 를 만화로 보기": "Read ZIP · CBZ as comics",
"ZIP 옆 새 폴더 \"{0}\" 로": "To a new folder \"{0}\" next to the ZIP", "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 이 있는 폴더로": "To the ZIP's folder",
"ZIP 이름": "ZIP name",
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]", "[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.", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "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].", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].",
@ -476,6 +484,7 @@
"{0}: 저장하지 않았습니다": "{0}: not saved", "{0}: 저장하지 않았습니다": "{0}: not saved",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}",
"{0}개": "{0}", "{0}개": "{0}",
"{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP",
"{0}개 · {1}": "{0} · {1}", "{0}개 · {1}": "{0} · {1}",
"{0}개 · {1} …": "{0} items · {1} …", "{0}개 · {1} …": "{0} items · {1} …",
"{0}개 · 주소 {1}개": "{0} · {1} links", "{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} 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}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (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}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".",
"{0}개를 묶었습니다: {1}": "Compressed {0} files: {1}",
"{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).", "{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).",
"{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})",
@ -569,13 +579,17 @@
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)", "감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language", "감지한 언어: {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}": "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)", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.",
"같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다": "An item with the same name already exists",
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
"같은 이름이 이미 있음": "Name already exists", "같은 이름이 이미 있음": "Name already exists",
"같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …", "같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …",
"같은 이름이 있으면": "If the name exists",
"같은 이름이 있을 때": "When a file with the same name exists", "같은 이름이 있을 때": "When a file with the same name exists",
"같은 이름이라 건너뜀 {0}개": "{0} skipped (same name)",
"같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}", "같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}",
"같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)", "같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)",
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "Make a copy in the same folder? (name (2))\n{0}", "같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "Make a copy in the same folder? (name (2))\n{0}",
@ -591,6 +605,7 @@
"거꾸로": "Reverse", "거꾸로": "Reverse",
"거꾸로 된 범위: {0}": "Reversed range: {0}", "거꾸로 된 범위: {0}": "Reversed range: {0}",
"건너뛰기": "Skip", "건너뛰기": "Skip",
"건너뛴 링크 {0}개": "{0} links skipped",
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}", "건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",
"건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}", "건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}",
"건너뜁니다": "skips it", "건너뜁니다": "skips it",
@ -658,6 +673,7 @@
"그래도 Chrome 엔진 쓰기…": "Use the Chrome engine anyway…", "그래도 Chrome 엔진 쓰기…": "Use the Chrome engine anyway…",
"그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})", "그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})",
"그래도 쓰기": "Use anyway", "그래도 쓰기": "Use anyway",
"그래도 풀기": "Extract anyway",
"그리는 중 {0}/{1}": "Drawing {0}/{1}", "그리는 중 {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).", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "To draw, {0} must be downloaded (the model stays on this device, and images and prompts never leave it).",
"그리스어": "Greek", "그리스어": "Greek",
@ -780,6 +796,7 @@
"넣지 못했습니다: {0}": "Could not add: {0}", "넣지 못했습니다: {0}": "Could not add: {0}",
"네덜란드어": "Dutch", "네덜란드어": "Dutch",
"네트워크 · DNS": "Network · DNS", "네트워크 · DNS": "Network · DNS",
"네트워크 경로 (UNC)": "Network path (UNC)",
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "Network shares (SMB) aren't supported yet. Use the WebDAV tab", "네트워크 공유 (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).", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).",
"네팔어": "Nepali", "네팔어": "Nepali",
@ -817,6 +834,7 @@
"다른 이름으로 저장": "Save as", "다른 이름으로 저장": "Save as",
"다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})", "다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})",
"다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage",
"다른 창 폴더": "Other pane's folder",
"다른 창 폴더로": "To the other pane's folder", "다른 창 폴더로": "To the other pane's folder",
"다른 창에서 열기": "Open in other pane", "다른 창에서 열기": "Open in other pane",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears",
@ -969,6 +987,7 @@
"뒤로": "Back", "뒤로": "Back",
"뒤로 보내고 계속": "Keep running in background", "뒤로 보내고 계속": "Keep running in background",
"뒤에서 계속": "Continue in background", "뒤에서 계속": "Continue in background",
"드라이브 경로": "Drive path",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted", "드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
"등록": "Register", "등록": "Register",
"등록 중 일부가 실패했습니다.": "Some registrations failed.", "등록 중 일부가 실패했습니다.": "Some registrations failed.",
@ -983,6 +1002,9 @@
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit", "로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
"루마니아어": "Romanian", "루마니아어": "Romanian",
"링크 {0} (건너뜀)": "Links {0} (skipped)", "링크 {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 and super passwords",
"마스터 비밀번호": "Master password", "마스터 비밀번호": "Master password",
"마스터 비밀번호 넣기": "Enter master password", "마스터 비밀번호 넣기": "Enter master password",
@ -1004,9 +1026,11 @@
"마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "Swiping past the last screen goes to the first (off: stops at the end)", "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "Swiping past the last screen goes to the first (off: stops at the end)",
"마지막 화면입니다": "This is the last screen", "마지막 화면입니다": "This is the last screen",
"마지막으로: {0}": "To the last: {0}", "마지막으로: {0}": "To the last: {0}",
"막은 경로 {0}개": "{0} paths blocked",
"만든 그림 · 저장: {0}": "Made images · saved in: {0}", "만든 그림 · 저장: {0}": "Made images · saved in: {0}",
"만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 MKV 가 이미 있으면": "If the MKV already exists",
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
"만들 파일: {0}": "File to create: {0}",
"만들거나 쓰지 못했습니다": "Could not create or write", "만들거나 쓰지 못했습니다": "Could not create or write",
"만들기": "Make", "만들기": "Make",
"만들다 만 파일 {0}개": "{0} incomplete files", "만들다 만 파일 {0}개": "{0} incomplete files",
@ -1079,6 +1103,7 @@
"무엇을 그릴지 적어 주세요": "Write what to draw", "무엇을 그릴지 적어 주세요": "Write what to draw",
"무엇이 바뀌는지 비교하는 중…": "Comparing what will change…", "무엇이 바뀌는지 비교하는 중…": "Comparing what will change…",
"무한": "Unlimited", "무한": "Unlimited",
"묶기": "Compress",
"문서 미리보기": "Document preview", "문서 미리보기": "Document preview",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first", "문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first",
"문자셋 경고": "Charset warning", "문자셋 경고": "Charset warning",
@ -1093,6 +1118,7 @@
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
"미얀마어": "Burmese", "미얀마어": "Burmese",
"미지정": "Unspecified", "미지정": "Unspecified",
"바깥 폴더 (../)": "Parent folder (../)",
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).",
"바꾸기": "Change", "바꾸기": "Change",
"바꾸기 ({0}개)": "Rename ({0})", "바꾸기 ({0}개)": "Rename ({0})",
@ -1116,6 +1142,7 @@
"바탕 그림 고르기": "Choose a base picture", "바탕 그림 고르기": "Choose a base picture",
"바탕 그림 고르기 (그림 → 그림)": "Choose a base picture (image → image)", "바탕 그림 고르기 (그림 → 그림)": "Choose a base picture (image → image)",
"바탕 그림 빼기": "Remove base picture", "바탕 그림 빼기": "Remove base picture",
"밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "Not extracted because the path leads outside ({0}): {1}",
"반대로 90° 회전": "Rotate 90° (counterclockwise)", "반대로 90° 회전": "Rotate 90° (counterclockwise)",
"반전 (고른 것 ↔ 안 고른 것)": "Invert (selected ↔ not selected)", "반전 (고른 것 ↔ 안 고른 것)": "Invert (selected ↔ not selected)",
"받기": "Download", "받기": "Download",
@ -1206,6 +1233,7 @@
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
"보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)", "보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)",
"보통": "Normal", "보통": "Normal",
"보통 (0.25, 기본)": "Normal (0.25, default)",
"보통: {0}": "Normal: {0}", "보통: {0}": "Normal: {0}",
"복사": "Copy", "복사": "Copy",
"복사 (붙여넣기로)": "Copy (to paste)", "복사 (붙여넣기로)": "Copy (to paste)",
@ -1256,8 +1284,11 @@
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", "비밀번호 · 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.", "비밀번호 · 자막 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)", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
"비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords",
"비밀번호가 틀렸습니다": "Wrong password",
"비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)", "비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)",
"비밀번호가 필요합니다": "Password required",
"비밀번호를 넣으세요": "Enter a password", "비밀번호를 넣으세요": "Enter a password",
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage", "비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
"비밀번호를 잊었습니다": "I forgot the password", "비밀번호를 잊었습니다": "I forgot the password",
@ -1265,10 +1296,12 @@
"비어 있는 폴더": "Empty folder", "비어 있는 폴더": "Empty folder",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
"비어 있음": "Empty", "비어 있음": "Empty",
"빈 이름": "Empty name",
"빈 폴더 {0}개 지움": "Deleted {0} empty folders", "빈 폴더 {0}개 지움": "Deleted {0} empty folders",
"빈 폴더 그대로": "Leave empty folders", "빈 폴더 그대로": "Leave empty folders",
"빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder", "빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder",
"빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too",
"빠르게": "Fast",
"빠른 디코더 (TAESD)": "Fast decoder (TAESD)", "빠른 디코더 (TAESD)": "Fast decoder (TAESD)",
"빠른 필터": "Quick filter", "빠른 필터": "Quick filter",
"빼고 싶은 것 (선택)": "What to avoid (optional)", "빼고 싶은 것 (선택)": "What to avoid (optional)",
@ -1449,6 +1482,7 @@
"실행해 보지 못함 ({0})": "Could not run it ({0})", "실행해 보지 못함 ({0})": "Could not run it ({0})",
"싱크": "Sync", "싱크": "Sync",
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)",
"쓸 수 없는 글자 (:)": "Invalid character (:)",
"쓸 수 없는 이름": "Invalid name", "쓸 수 없는 이름": "Invalid name",
"쓸 수 없는 이름입니다": "This name cannot be used", "쓸 수 없는 이름입니다": "This name cannot be used",
"아랍어": "Arabic", "아랍어": "Arabic",
@ -1476,9 +1510,15 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", "안쪽에 받는 쪽이 더 새 파일 {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)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
"암호화 안 됨 (http)": "Not encrypted (http)", "암호화 안 됨 (http)": "Not encrypted (http)",
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
"압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 없습니다": "Not in the archive",
"압축 파일 안에 프로그램이 없습니다.": "The program is 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}", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
"압축이 있는 폴더": "Archive's folder",
"앞 항목 (←)": "Previous item (←)", "앞 항목 (←)": "Previous item (←)",
"앞으로": "Forward", "앞으로": "Forward",
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.", "앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
@ -1509,6 +1549,7 @@
"약 {0} 남음": "about {0} left", "약 {0} 남음": "about {0} left",
"약 {0}~{1}분": "about {0}–{1} min", "약 {0}~{1}분": "about {0}–{1} min",
"약 {0}분": "about {0} 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).", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --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).", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).",
"어둡게": "Darker", "어둡게": "Darker",
@ -1723,6 +1764,7 @@
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app",
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "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)", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "On this device Low · Normal · High are all the same: {0} (smallest tile because memory is small)",
"이 기기용 파일 없음": "No file for this device", "이 기기용 파일 없음": "No file for this device",
"이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine",
@ -1808,6 +1850,7 @@
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.",
"이름을 바꿉니다": "renames it", "이름을 바꿉니다": "renames it",
"이름을 바꿔 풂 {0}개": "{0} extracted with new names",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})", "이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})",
"이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)", "이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)",
"이미 그리는 중입니다": "Already drawing", "이미 그리는 중입니다": "Already drawing",
@ -1984,6 +2027,7 @@
"전체 이동": "Move all", "전체 이동": "Move all",
"전체 화면": "Full screen", "전체 화면": "Full screen",
"전체 화면 (F)": "Full screen (F)", "전체 화면 (F)": "Full screen (F)",
"절대 경로": "Absolute path",
"점검": "Check", "점검": "Check",
"정렬 기준": "Sort by", "정렬 기준": "Sort by",
"정렬을 폴더마다 기억": "Remember sort per folder", "정렬을 폴더마다 기억": "Remember sort per folder",
@ -2083,6 +2127,7 @@
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)", "지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
"지금 짝 추가": "Add current pair", "지금 짝 추가": "Add current pair",
"지금 짝을 즐겨찾기에": "Add the current pair to favorites", "지금 짝을 즐겨찾기에": "Add the current pair to favorites",
"지금 폴더": "Current folder",
"지금 폴더 추가": "Add current folder", "지금 폴더 추가": "Add current folder",
"지금 폴더를 즐겨찾기에": "Add the current folder to favorites", "지금 폴더를 즐겨찾기에": "Add the current folder to favorites",
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)",
@ -2197,6 +2242,8 @@
"취소됨: {0}": "Cancelled: {0}", "취소됨: {0}": "Cancelled: {0}",
"취소했습니다": "Cancelled", "취소했습니다": "Cancelled",
"취소했습니다.": "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).", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "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.", "칠한 시간이 없어 동기화하지 않습니다.": "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?", "칸에 보이는 옵션: {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", "표준": "Standard",
"푸는 중": "Extracting", "푸는 중": "Extracting",
"푸는 중: {0}": "Extracting: {0}", "푸는 중: {0}": "Extracting: {0}",
"풀기": "Extract",
"풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "The extracted size ({0}) is larger than the free space ({1}). Extract anyway?",
"풀지 못했습니다: {0}": "Could not extract: {0}", "풀지 못했습니다: {0}": "Could not extract: {0}",
"품질 올리기": "Improve quality", "품질 올리기": "Improve quality",
"품질 올리기 (이 장)": "Improve quality (this page)", "품질 올리기 (이 장)": "Improve quality (this page)",
"품질 올리기 다듬기 정도": "Improve quality strength",
"품질 올리기 단추 보이기": "Show Improve quality button", "품질 올리기 단추 보이기": "Show Improve quality button",
"품질 올리기: {0}": "Improve quality: {0}", "품질 올리기: {0}": "Improve quality: {0}",
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "Improve quality needs the AI image model. Download it in Settings > AI image and try again.", "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "Improve quality needs the AI image model. Download it in Settings > AI image and try again.",

View File

@ -32,6 +32,7 @@
"\"{0}\" 안에 만듭니다": "「{0}」の中に作成します", "\"{0}\" 안에 만듭니다": "「{0}」の中に作成します",
"\"{0}\" 에 맞는 항목이 없습니다": "「{0}」に合う項目はありません", "\"{0}\" 에 맞는 항목이 없습니다": "「{0}」に合う項目はありません",
"\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。",
"\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "「{0}」にはパスワードが設定されています。",
"\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?",
"\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?", "\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?",
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。",
@ -329,6 +330,8 @@
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。",
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV のファイルは名前・サイズ・日付のみ表示します", "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 · --delete · --remove-source-files 를 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。",
"WebM (동영상)": "WebM(動画)", "WebM (동영상)": "WebM(動画)",
@ -363,7 +366,12 @@
"ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をダブルタップ: マンガ表示 → 一覧。元に戻す: 環境設定 > ファイルエクスプローラー > ZIP・CBZ をマンガとして表示", "ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をダブルタップ: マンガ表示 → 一覧。元に戻す: 環境設定 > ファイルエクスプローラー > ZIP・CBZ をマンガとして表示",
"ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をマンガとして表示", "ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をマンガとして表示",
"ZIP 옆 새 폴더 \"{0}\" 로": "ZIP の隣の新しいフォルダー「{0}」へ", "ZIP 옆 새 폴더 \"{0}\" 로": "ZIP の隣の新しいフォルダー「{0}」へ",
"ZIP 으로 묶기": "ZIP にまとめる",
"ZIP 으로 묶기 · {0}개": "ZIP にまとめる · {0} 件",
"ZIP 으로 묶는 중 · {0}": "ZIP にまとめています · {0}",
"ZIP 을 만들지 못했습니다: {0}": "ZIP を作成できませんでした: {0}",
"ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ", "ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ",
"ZIP 이름": "ZIP の名前",
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。",
@ -476,6 +484,7 @@
"{0}: 저장하지 않았습니다": "{0}: 保存しませんでした", "{0}: 저장하지 않았습니다": "{0}: 保存しませんでした",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
"{0}개": "{0}個", "{0}개": "{0}個",
"{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる",
"{0}개 · {1}": "{0}個・{1}", "{0}개 · {1}": "{0}個・{1}",
"{0}개 · {1} …": "{0} 件 · {1} …", "{0}개 · {1} …": "{0} 件 · {1} …",
"{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 · 주소 {1}개": "{0}個・URL {1}個",
@ -506,6 +515,7 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。", "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。",
"{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}",
"{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。", "{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。",
"{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})",
@ -569,13 +579,17 @@
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)", "감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
"강하게 (0.4)": "強め (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。",
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
"같은 이름이 이미 있음": "同じ名前が既にあります", "같은 이름이 이미 있음": "同じ名前が既にあります",
"같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …", "같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …",
"같은 이름이 있으면": "同じ名前がある場合",
"같은 이름이 있을 때": "同じ名前があるとき", "같은 이름이 있을 때": "同じ名前があるとき",
"같은 이름이라 건너뜀 {0}개": "同じ名前のためスキップ {0} 件",
"같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}", "같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}",
"같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)", "같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)",
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "同じフォルダーにコピーを作りますか? (名前 (2))\n{0}", "같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "同じフォルダーにコピーを作りますか? (名前 (2))\n{0}",
@ -591,6 +605,7 @@
"거꾸로": "逆順", "거꾸로": "逆順",
"거꾸로 된 범위: {0}": "逆順の範囲: {0}", "거꾸로 된 범위: {0}": "逆順の範囲: {0}",
"건너뛰기": "スキップ", "건너뛰기": "スキップ",
"건너뛴 링크 {0}개": "スキップしたリンク {0} 件",
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}", "건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",
"건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}", "건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}",
"건너뜁니다": "スキップします", "건너뜁니다": "スキップします",
@ -658,6 +673,7 @@
"그래도 Chrome 엔진 쓰기…": "それでも Chrome エンジンを使う…", "그래도 Chrome 엔진 쓰기…": "それでも Chrome エンジンを使う…",
"그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)", "그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)",
"그래도 쓰기": "それでも使う", "그래도 쓰기": "それでも使う",
"그래도 풀기": "それでも展開",
"그리는 중 {0}/{1}": "描画中 {0}/{1}", "그리는 중 {0}/{1}": "描画中 {0}/{1}",
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。",
"그리스어": "ギリシャ語", "그리스어": "ギリシャ語",
@ -780,6 +796,7 @@
"넣지 못했습니다: {0}": "追加できませんでした: {0}", "넣지 못했습니다: {0}": "追加できませんでした: {0}",
"네덜란드어": "オランダ語", "네덜란드어": "オランダ語",
"네트워크 · DNS": "ネットワーク・DNS", "네트워크 · DNS": "ネットワーク・DNS",
"네트워크 경로 (UNC)": "ネットワークパス (UNC)",
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。",
"네팔어": "ネパール語", "네팔어": "ネパール語",
@ -817,6 +834,7 @@
"다른 이름으로 저장": "名前を付けて保存", "다른 이름으로 저장": "名前を付けて保存",
"다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})", "다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})",
"다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません",
"다른 창 폴더": "もう一方のウィンドウのフォルダー",
"다른 창 폴더로": "もう一方の画面のフォルダーへ", "다른 창 폴더로": "もう一方の画面のフォルダーへ",
"다른 창에서 열기": "もう一方のペインで開く", "다른 창에서 열기": "もう一方のペインで開く",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます",
@ -969,6 +987,7 @@
"뒤로": "戻る", "뒤로": "戻る",
"뒤로 보내고 계속": "裏で続ける", "뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける", "뒤에서 계속": "バックグラウンドで続ける",
"드라이브 경로": "ドライブのパス",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
"등록": "登録", "등록": "登録",
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
@ -983,6 +1002,9 @@
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます", "로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
"루마니아어": "ルーマニア語", "루마니아어": "ルーマニア語",
"링크 {0} (건너뜀)": "リンク {0} (スキップ)", "링크 {0} (건너뜀)": "リンク {0} (スキップ)",
"링크 {0}개는 넣지 않음": "リンク {0} 件は含めず",
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}",
"링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません",
"마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード",
"마스터 비밀번호": "マスターパスワード", "마스터 비밀번호": "マスターパスワード",
"마스터 비밀번호 넣기": "マスターパスワードを入力", "마스터 비밀번호 넣기": "マスターパスワードを入力",
@ -1004,9 +1026,11 @@
"마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "最後の画面からさらにスワイプすると最初の画面へ (オフ: 端で止まる)", "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "最後の画面からさらにスワイプすると最初の画面へ (オフ: 端で止まる)",
"마지막 화면입니다": "最後の画面です", "마지막 화면입니다": "最後の画面です",
"마지막으로: {0}": "最後へ: {0}", "마지막으로: {0}": "最後へ: {0}",
"막은 경로 {0}개": "ブロックしたパス {0} 件",
"만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}",
"만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき",
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
"만들 파일: {0}": "作成するファイル: {0}",
"만들거나 쓰지 못했습니다": "作成または書き込みできませんでした", "만들거나 쓰지 못했습니다": "作成または書き込みできませんでした",
"만들기": "作成", "만들기": "作成",
"만들다 만 파일 {0}개": "未完成のファイル {0} 個", "만들다 만 파일 {0}개": "未完成のファイル {0} 個",
@ -1079,6 +1103,7 @@
"무엇을 그릴지 적어 주세요": "何を描くか入力してください", "무엇을 그릴지 적어 주세요": "何を描くか入力してください",
"무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…", "무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…",
"무한": "無制限", "무한": "無制限",
"묶기": "まとめる",
"문서 미리보기": "文書プレビュー", "문서 미리보기": "文書プレビュー",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください", "문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください",
"문자셋 경고": "文字コードの警告", "문자셋 경고": "文字コードの警告",
@ -1093,6 +1118,7 @@
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
"미얀마어": "ミャンマー語", "미얀마어": "ミャンマー語",
"미지정": "未指定", "미지정": "未指定",
"바깥 폴더 (../)": "外側のフォルダー (../)",
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "変更・オフにするには現在のマスターパスワード (または最上位パスワード) を入力してください。", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "変更・オフにするには現在のマスターパスワード (または最上位パスワード) を入力してください。",
"바꾸기": "変更", "바꾸기": "変更",
"바꾸기 ({0}개)": "変更 ({0} 件)", "바꾸기 ({0}개)": "変更 ({0} 件)",
@ -1116,6 +1142,7 @@
"바탕 그림 고르기": "元の画像を選択", "바탕 그림 고르기": "元の画像を選択",
"바탕 그림 고르기 (그림 → 그림)": "元の画像を選択 (画像 → 画像)", "바탕 그림 고르기 (그림 → 그림)": "元の画像を選択 (画像 → 画像)",
"바탕 그림 빼기": "元の画像を外す", "바탕 그림 빼기": "元の画像を外す",
"밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "外に出るパスのため展開しません ({0} 件): {1}",
"반대로 90° 회전": "反対に 90° 回転", "반대로 90° 회전": "反対に 90° 回転",
"반전 (고른 것 ↔ 안 고른 것)": "反転 (選択 ↔ 未選択)", "반전 (고른 것 ↔ 안 고른 것)": "反転 (選択 ↔ 未選択)",
"받기": "ダウンロード", "받기": "ダウンロード",
@ -1206,6 +1233,7 @@
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
"보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)", "보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)",
"보통": "普通", "보통": "普通",
"보통 (0.25, 기본)": "普通 (0.25、既定)",
"보통: {0}": "普通: {0}", "보통: {0}": "普通: {0}",
"복사": "コピー", "복사": "コピー",
"복사 (붙여넣기로)": "コピー (貼り付け用)", "복사 (붙여넣기로)": "コピー (貼り付け用)",
@ -1256,8 +1284,11 @@
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
"비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき",
"비밀번호가 틀렸습니다": "パスワードが違います",
"비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)", "비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)",
"비밀번호가 필요합니다": "パスワードが必要です",
"비밀번호를 넣으세요": "パスワードを入力してください", "비밀번호를 넣으세요": "パスワードを入力してください",
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました", "비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
"비밀번호를 잊었습니다": "パスワードを忘れました", "비밀번호를 잊었습니다": "パスワードを忘れました",
@ -1265,10 +1296,12 @@
"비어 있는 폴더": "空のフォルダー", "비어 있는 폴더": "空のフォルダー",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
"비어 있음": "空", "비어 있음": "空",
"빈 이름": "空の名前",
"빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除", "빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除",
"빈 폴더 그대로": "空フォルダーはそのまま", "빈 폴더 그대로": "空フォルダーはそのまま",
"빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す", "빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す",
"빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除",
"빠르게": "速く",
"빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)",
"빠른 필터": "クイックフィルター", "빠른 필터": "クイックフィルター",
"빼고 싶은 것 (선택)": "避けたいもの (任意)", "빼고 싶은 것 (선택)": "避けたいもの (任意)",
@ -1449,6 +1482,7 @@
"실행해 보지 못함 ({0})": "実行できませんでした ({0})", "실행해 보지 못함 ({0})": "実行できませんでした ({0})",
"싱크": "同期", "싱크": "同期",
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)",
"쓸 수 없는 글자 (:)": "使えない文字 (:)",
"쓸 수 없는 이름": "使えない名前", "쓸 수 없는 이름": "使えない名前",
"쓸 수 없는 이름입니다": "使用できない名前です", "쓸 수 없는 이름입니다": "使用できない名前です",
"아랍어": "アラビア語", "아랍어": "アラビア語",
@ -1476,9 +1510,15 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호화 안 됨 (http)": "暗号化なし (http)", "암호화 안 됨 (http)": "暗号化なし (http)",
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
"압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 없습니다": "圧縮ファイル内にありません",
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
"압축 푸는 중 · {0}": "展開中 · {0}",
"압축 풀기": "展開",
"압축 풀기 · {0}": "展開 · {0}",
"압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}",
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
"압축이 있는 폴더": "アーカイブのあるフォルダー",
"앞 항목 (←)": "前の項目 (←)", "앞 항목 (←)": "前の項目 (←)",
"앞으로": "進む", "앞으로": "進む",
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。", "앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
@ -1509,6 +1549,7 @@
"약 {0} 남음": "残り約 {0}", "약 {0} 남음": "残り約 {0}",
"약 {0}~{1}분": "約 {0}~{1} 分", "약 {0}~{1}분": "約 {0}~{1} 分",
"약 {0}분": "約 {0} 分", "약 {0}분": "約 {0} 分",
"약하게 (0.15)": "弱め (0.15)",
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
"어둡게": "暗く", "어둡게": "暗く",
@ -1723,6 +1764,7 @@
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください",
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです",
"이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "この端末ではまだアプリ内で 7z・RAR を展開できません。展開できる別のアプリで開いてください。",
"이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "この機器では少なめ · 普通 · 多めはすべて同じです: {0} (メモリが少ないため最小のタイル)", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "この機器では少なめ · 普通 · 多めはすべて同じです: {0} (メモリが少ないため最小のタイル)",
"이 기기용 파일 없음": "このデバイス用のファイルなし", "이 기기용 파일 없음": "このデバイス用のファイルなし",
"이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません",
@ -1808,6 +1850,7 @@
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。",
"이름을 바꿉니다": "名前を変えます", "이름을 바꿉니다": "名前を変えます",
"이름을 바꿔 풂 {0}개": "名前を変えて展開 {0} 件",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})", "이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})",
"이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)", "이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)",
"이미 그리는 중입니다": "すでに描画中です", "이미 그리는 중입니다": "すでに描画中です",
@ -1984,6 +2027,7 @@
"전체 이동": "すべて移動", "전체 이동": "すべて移動",
"전체 화면": "全画面", "전체 화면": "全画面",
"전체 화면 (F)": "全画面(F)", "전체 화면 (F)": "全画面(F)",
"절대 경로": "絶対パス",
"점검": "点検", "점검": "点検",
"정렬 기준": "並べ替え", "정렬 기준": "並べ替え",
"정렬을 폴더마다 기억": "フォルダーごとに並べ替えを記憶", "정렬을 폴더마다 기억": "フォルダーごとに並べ替えを記憶",
@ -2083,6 +2127,7 @@
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)", "지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
"지금 짝 추가": "今のペアを追加", "지금 짝 추가": "今のペアを追加",
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに", "지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
"지금 폴더": "現在のフォルダー",
"지금 폴더 추가": "今のフォルダーを追加", "지금 폴더 추가": "今のフォルダーを追加",
"지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに", "지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに",
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)",
@ -2197,6 +2242,8 @@
"취소됨: {0}": "取り消し: {0}", "취소됨: {0}": "取り消し: {0}",
"취소했습니다": "キャンセルしました", "취소했습니다": "キャンセルしました",
"취소했습니다.": "キャンセルしました。", "취소했습니다.": "キャンセルしました。",
"취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "キャンセルしました。作りかけの ZIP は残りません。",
"취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "キャンセルしました。展開中のファイルは残りません (展開済みのファイルはそのまま)。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。",
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
"칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "欄に表示中のオプション: {0}\n保存済みのオプション: {1}\n\n編集したオプションを保存して実行しますか?", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "欄に表示中のオプション: {0}\n保存済みのオプション: {1}\n\n編集したオプションを保存して実行しますか?",
@ -2335,9 +2382,12 @@
"표준": "標準", "표준": "標準",
"푸는 중": "展開中", "푸는 중": "展開中",
"푸는 중: {0}": "展開中: {0}", "푸는 중: {0}": "展開中: {0}",
"풀기": "展開",
"풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "展開後のサイズ ({0}) が空き容量 ({1}) より大きいです。それでも展開しますか?",
"풀지 못했습니다: {0}": "展開できませんでした: {0}", "풀지 못했습니다: {0}": "展開できませんでした: {0}",
"품질 올리기": "画質を上げる", "품질 올리기": "画質を上げる",
"품질 올리기 (이 장)": "画質を上げる (このページ)", "품질 올리기 (이 장)": "画質を上げる (このページ)",
"품질 올리기 다듬기 정도": "画質を上げる整えの強さ",
"품질 올리기 단추 보이기": "[画質を上げる] ボタンを表示", "품질 올리기 단추 보이기": "[画質を上げる] ボタンを表示",
"품질 올리기: {0}": "画質を上げる: {0}", "품질 올리기: {0}": "画質を上げる: {0}",
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "画質を上げるには AI 画像モデルが必要です。設定 > AI 画像でモデルを入手してから、もう一度押してください。", "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "画質を上げるには AI 画像モデルが必要です。設定 > AI 画像でモデルを入手してから、もう一度押してください。",

View File

@ -32,6 +32,7 @@
"\"{0}\" 안에 만듭니다": "在“{0}”中创建", "\"{0}\" 안에 만듭니다": "在“{0}”中创建",
"\"{0}\" 에 맞는 항목이 없습니다": "没有与“{0}”匹配的项目", "\"{0}\" 에 맞는 항목이 없습니다": "没有与“{0}”匹配的项目",
"\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。",
"\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "“{0}”设有密码。",
"\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?",
"\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?", "\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?",
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。",
@ -329,6 +330,8 @@
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。",
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。",
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV 文件仅显示名称、大小和日期", "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 · --delete · --remove-source-files 를 따름).": "WebDAV: 由应用直接同步而非 rsync (比较大小 · 修改时间,遵循 -u · --delete · --remove-source-files)。",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。",
"WebM (동영상)": "WebM(视频)", "WebM (동영상)": "WebM(视频)",
@ -363,7 +366,12 @@
"ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "双击 ZIP · CBZ: 漫画阅读 → 列表。恢复: 设置 > 文件浏览器 > 将 ZIP · CBZ 作为漫画阅读", "ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "双击 ZIP · CBZ: 漫画阅读 → 列表。恢复: 设置 > 文件浏览器 > 将 ZIP · CBZ 作为漫画阅读",
"ZIP · CBZ 를 만화로 보기": "将 ZIP · CBZ 作为漫画阅读", "ZIP · CBZ 를 만화로 보기": "将 ZIP · CBZ 作为漫画阅读",
"ZIP 옆 새 폴더 \"{0}\" 로": "到 ZIP 旁的新文件夹“{0}”", "ZIP 옆 새 폴더 \"{0}\" 로": "到 ZIP 旁的新文件夹“{0}”",
"ZIP 으로 묶기": "压缩为 ZIP",
"ZIP 으로 묶기 · {0}개": "压缩为 ZIP · {0} 项",
"ZIP 으로 묶는 중 · {0}": "正在压缩 · {0}",
"ZIP 을 만들지 못했습니다: {0}": "无法创建 ZIP: {0}",
"ZIP 이 있는 폴더로": "到 ZIP 所在文件夹", "ZIP 이 있는 폴더로": "到 ZIP 所在文件夹",
"ZIP 이름": "ZIP 名称",
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。",
@ -476,6 +484,7 @@
"{0}: 저장하지 않았습니다": "{0}:未保存", "{0}: 저장하지 않았습니다": "{0}:未保存",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
"{0}개": "{0} 个", "{0}개": "{0} 个",
"{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP",
"{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1}": "{0} 个 · {1}",
"{0}개 · {1} …": "{0} 项 · {1} …", "{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}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。", "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。",
"{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}",
"{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。", "{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。",
"{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})",
@ -569,13 +579,17 @@
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)", "감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
"강하게 (0.4)": "强 (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。",
"같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다": "已存在同名项目",
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
"같은 이름이 이미 있음": "同名项已存在", "같은 이름이 이미 있음": "同名项已存在",
"같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …", "같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …",
"같은 이름이 있으면": "如果同名",
"같은 이름이 있을 때": "存在同名项目时", "같은 이름이 있을 때": "存在同名项目时",
"같은 이름이라 건너뜀 {0}개": "因同名跳过 {0} 个",
"같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}", "같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}",
"같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)", "같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)",
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "要在同一文件夹中创建副本吗?(名称 (2))\n{0}", "같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "要在同一文件夹中创建副本吗?(名称 (2))\n{0}",
@ -591,6 +605,7 @@
"거꾸로": "倒序", "거꾸로": "倒序",
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
"건너뛰기": "跳过", "건너뛰기": "跳过",
"건너뛴 링크 {0}개": "跳过的链接 {0} 个",
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}", "건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",
"건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}", "건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}",
"건너뜁니다": "跳过", "건너뜁니다": "跳过",
@ -658,6 +673,7 @@
"그래도 Chrome 엔진 쓰기…": "仍然使用 Chrome 引擎…", "그래도 Chrome 엔진 쓰기…": "仍然使用 Chrome 引擎…",
"그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)", "그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)",
"그래도 쓰기": "仍然使用", "그래도 쓰기": "仍然使用",
"그래도 풀기": "仍然解压",
"그리는 중 {0}/{1}": "正在绘制 {0}/{1}", "그리는 중 {0}/{1}": "正在绘制 {0}/{1}",
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。",
"그리스어": "希腊语", "그리스어": "希腊语",
@ -780,6 +796,7 @@
"넣지 못했습니다: {0}": "无法加入: {0}", "넣지 못했습니다: {0}": "无法加入: {0}",
"네덜란드어": "荷兰语", "네덜란드어": "荷兰语",
"네트워크 · DNS": "网络 · DNS", "네트워크 · DNS": "网络 · DNS",
"네트워크 경로 (UNC)": "网络路径 (UNC)",
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。",
"네팔어": "尼泊尔语", "네팔어": "尼泊尔语",
@ -817,6 +834,7 @@
"다른 이름으로 저장": "另存为", "다른 이름으로 저장": "另存为",
"다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})", "다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})",
"다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储",
"다른 창 폴더": "另一窗格的文件夹",
"다른 창 폴더로": "到另一窗格的文件夹", "다른 창 폴더로": "到另一窗格的文件夹",
"다른 창에서 열기": "在另一窗格中打开", "다른 창에서 열기": "在另一窗格中打开",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)",
@ -969,6 +987,7 @@
"뒤로": "后退", "뒤로": "后退",
"뒤로 보내고 계속": "转到后台继续", "뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续", "뒤에서 계속": "在后台继续",
"드라이브 경로": "驱动器路径",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
"등록": "注册", "등록": "注册",
"등록 중 일부가 실패했습니다.": "部分注册失败。", "등록 중 일부가 실패했습니다.": "部分注册失败。",
@ -983,6 +1002,9 @@
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加", "로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
"루마니아어": "罗马尼亚语", "루마니아어": "罗马尼亚语",
"링크 {0} (건너뜀)": "链接 {0}(跳过)", "링크 {0} (건너뜀)": "链接 {0}(跳过)",
"링크 {0}개는 넣지 않음": "{0} 个链接未包含",
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}",
"링크 · junction 은 넣지 않습니다": "不包含链接和 junction",
"마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 · 최상 비밀번호": "主密码和最高密码",
"마스터 비밀번호": "主密码", "마스터 비밀번호": "主密码",
"마스터 비밀번호 넣기": "输入主密码", "마스터 비밀번호 넣기": "输入主密码",
@ -1004,9 +1026,11 @@
"마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "在最后一个界面继续滑动会回到第一个界面 (关闭: 停在末尾)", "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "在最后一个界面继续滑动会回到第一个界面 (关闭: 停在末尾)",
"마지막 화면입니다": "这是最后一个画面", "마지막 화면입니다": "这是最后一个画面",
"마지막으로: {0}": "到最后: {0}", "마지막으로: {0}": "到最后: {0}",
"막은 경로 {0}개": "已阻止 {0} 个路径",
"만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}",
"만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时",
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
"만들 파일: {0}": "要创建的文件: {0}",
"만들거나 쓰지 못했습니다": "无法创建或写入", "만들거나 쓰지 못했습니다": "无法创建或写入",
"만들기": "生成", "만들기": "生成",
"만들다 만 파일 {0}개": "{0} 个未完成的文件", "만들다 만 파일 {0}개": "{0} 个未完成的文件",
@ -1079,6 +1103,7 @@
"무엇을 그릴지 적어 주세요": "请输入要画的内容", "무엇을 그릴지 적어 주세요": "请输入要画的内容",
"무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…", "무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…",
"무한": "无限制", "무한": "无限制",
"묶기": "压缩",
"문서 미리보기": "文档预览", "문서 미리보기": "文档预览",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件", "문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件",
"문자셋 경고": "字符集警告", "문자셋 경고": "字符集警告",
@ -1093,6 +1118,7 @@
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
"미얀마어": "缅甸语", "미얀마어": "缅甸语",
"미지정": "未指定", "미지정": "未指定",
"바깥 폴더 (../)": "上级文件夹 (../)",
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "如需更改或关闭,请输入当前主密码 (或最高密码)。", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "如需更改或关闭,请输入当前主密码 (或最高密码)。",
"바꾸기": "更改", "바꾸기": "更改",
"바꾸기 ({0}개)": "重命名 ({0} 个)", "바꾸기 ({0}개)": "重命名 ({0} 个)",
@ -1116,6 +1142,7 @@
"바탕 그림 고르기": "选择基础图片", "바탕 그림 고르기": "选择基础图片",
"바탕 그림 고르기 (그림 → 그림)": "选择基础图片 (图片 → 图片)", "바탕 그림 고르기 (그림 → 그림)": "选择基础图片 (图片 → 图片)",
"바탕 그림 빼기": "移除基础图片", "바탕 그림 빼기": "移除基础图片",
"밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "因路径指向外部而不解压 ({0} 个): {1}",
"반대로 90° 회전": "逆时针旋转 90°", "반대로 90° 회전": "逆时针旋转 90°",
"반전 (고른 것 ↔ 안 고른 것)": "反选(已选 ↔ 未选)", "반전 (고른 것 ↔ 안 고른 것)": "反选(已选 ↔ 未选)",
"받기": "下载", "받기": "下载",
@ -1206,6 +1233,7 @@
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
"보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)", "보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)",
"보통": "普通", "보통": "普通",
"보통 (0.25, 기본)": "普通 (0.25,默认)",
"보통: {0}": "普通:{0}", "보통: {0}": "普通:{0}",
"복사": "复制", "복사": "复制",
"복사 (붙여넣기로)": "复制 (用于粘贴)", "복사 (붙여넣기로)": "复制 (用于粘贴)",
@ -1256,8 +1284,11 @@
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
"비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时",
"비밀번호가 틀렸습니다": "密码错误",
"비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)", "비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)",
"비밀번호가 필요합니다": "需要密码",
"비밀번호를 넣으세요": "请输入密码", "비밀번호를 넣으세요": "请输入密码",
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储", "비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
"비밀번호를 잊었습니다": "忘记密码", "비밀번호를 잊었습니다": "忘记密码",
@ -1265,10 +1296,12 @@
"비어 있는 폴더": "空文件夹", "비어 있는 폴더": "空文件夹",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
"비어 있음": "空", "비어 있음": "空",
"빈 이름": "空名称",
"빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹", "빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹",
"빈 폴더 그대로": "保留空文件夹", "빈 폴더 그대로": "保留空文件夹",
"빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹", "빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹",
"빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除",
"빠르게": "快速",
"빠른 디코더 (TAESD)": "快速解码器 (TAESD)", "빠른 디코더 (TAESD)": "快速解码器 (TAESD)",
"빠른 필터": "快速筛选", "빠른 필터": "快速筛选",
"빼고 싶은 것 (선택)": "要排除的内容 (可选)", "빼고 싶은 것 (선택)": "要排除的内容 (可选)",
@ -1449,6 +1482,7 @@
"실행해 보지 못함 ({0})": "无法运行({0})", "실행해 보지 못함 ({0})": "无法运行({0})",
"싱크": "同步", "싱크": "同步",
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)",
"쓸 수 없는 글자 (:)": "无效字符 (:)",
"쓸 수 없는 이름": "无效名称", "쓸 수 없는 이름": "无效名称",
"쓸 수 없는 이름입니다": "不能使用此名称", "쓸 수 없는 이름입니다": "不能使用此名称",
"아랍어": "阿拉伯语", "아랍어": "阿拉伯语",
@ -1476,9 +1510,15 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호화 안 됨 (http)": "未加密 (http)", "암호화 안 됨 (http)": "未加密 (http)",
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
"압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 없습니다": "压缩文件中没有",
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
"압축 푸는 중 · {0}": "正在解压 · {0}",
"압축 풀기": "解压",
"압축 풀기 · {0}": "解压 · {0}",
"압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}",
"압축을 풀 수 없습니다: {0}": "无法解压: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}",
"압축이 있는 폴더": "压缩包所在文件夹",
"앞 항목 (←)": "上一项 (←)", "앞 항목 (←)": "上一项 (←)",
"앞으로": "前进", "앞으로": "前进",
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。", "앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
@ -1509,6 +1549,7 @@
"약 {0} 남음": "约剩 {0}", "약 {0} 남음": "约剩 {0}",
"약 {0}~{1}분": "约 {0}~{1} 分钟", "약 {0}~{1}분": "约 {0}~{1} 分钟",
"약 {0}분": "约 {0} 分钟", "약 {0}분": "约 {0} 分钟",
"약하게 (0.15)": "弱 (0.15)",
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
"어둡게": "调暗", "어둡게": "调暗",
@ -1723,6 +1764,7 @@
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用",
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用",
"이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "本设备暂时无法在应用内解压 7z 和 RAR。请用其他解压应用打开。",
"이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "在此设备上,少 · 普通 · 多 都相同:{0}(内存较小,使用最小的分块)", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "在此设备上,少 · 普通 · 多 都相同:{0}(内存较小,使用最小的分块)",
"이 기기용 파일 없음": "没有此设备的文件", "이 기기용 파일 없음": "没有此设备的文件",
"이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎",
@ -1808,6 +1850,7 @@
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。",
"이름을 바꿉니다": "重命名", "이름을 바꿉니다": "重命名",
"이름을 바꿔 풂 {0}개": "{0} 个以新名称解压",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})", "이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})",
"이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)", "이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)",
"이미 그리는 중입니다": "已在绘制中", "이미 그리는 중입니다": "已在绘制中",
@ -1984,6 +2027,7 @@
"전체 이동": "全部移动", "전체 이동": "全部移动",
"전체 화면": "全屏", "전체 화면": "全屏",
"전체 화면 (F)": "全屏(F)", "전체 화면 (F)": "全屏(F)",
"절대 경로": "绝对路径",
"점검": "检查", "점검": "检查",
"정렬 기준": "排序方式", "정렬 기준": "排序方式",
"정렬을 폴더마다 기억": "按文件夹记住排序", "정렬을 폴더마다 기억": "按文件夹记住排序",
@ -2083,6 +2127,7 @@
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)", "지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
"지금 짝 추가": "添加当前一对", "지금 짝 추가": "添加当前一对",
"지금 짝을 즐겨찾기에": "将当前一对加入收藏", "지금 짝을 즐겨찾기에": "将当前一对加入收藏",
"지금 폴더": "当前文件夹",
"지금 폴더 추가": "添加当前文件夹", "지금 폴더 추가": "添加当前文件夹",
"지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏", "지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏",
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)",
@ -2197,6 +2242,8 @@
"취소됨: {0}": "已取消:{0}", "취소됨: {0}": "已取消:{0}",
"취소했습니다": "已取消", "취소했습니다": "已取消",
"취소했습니다.": "已取消。", "취소했습니다.": "已取消。",
"취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "已取消。不会留下未完成的 ZIP。",
"취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "已取消。正在解压的文件不会留下 (已解压的文件保留)。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。",
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
"칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "输入框中的选项: {0}\n已保存的选项: {1}\n\n保存修改的选项并执行吗?", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "输入框中的选项: {0}\n已保存的选项: {1}\n\n保存修改的选项并执行吗?",
@ -2335,9 +2382,12 @@
"표준": "标准", "표준": "标准",
"푸는 중": "正在解压", "푸는 중": "正在解压",
"푸는 중: {0}": "正在解压: {0}", "푸는 중: {0}": "正在解压: {0}",
"풀기": "解压",
"풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "解压后大小 ({0}) 大于剩余空间 ({1})。仍要解压吗?",
"풀지 못했습니다: {0}": "无法解压: {0}", "풀지 못했습니다: {0}": "无法解压: {0}",
"품질 올리기": "提升画质", "품질 올리기": "提升画质",
"품질 올리기 (이 장)": "提升画质 (本页)", "품질 올리기 (이 장)": "提升画质 (本页)",
"품질 올리기 다듬기 정도": "提升画质的优化强度",
"품질 올리기 단추 보이기": "显示提升画质按钮", "품질 올리기 단추 보이기": "显示提升画质按钮",
"품질 올리기: {0}": "提升画质: {0}", "품질 올리기: {0}": "提升画质: {0}",
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "提升画质需要 AI 图片模型。请在 设置 > AI 图片 中下载后再试。", "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "提升画质需要 AI 图片模型。请在 设置 > AI 图片 中下载后再试。",

View File

@ -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.
--

View File

@ -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 · (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 · 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 · 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 는 실행 파일 옆에도 둡니다. 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 소스: https://download.samba.org/pub/rsync/src/rsync-3.5.1.tar.gz
MSYS2 패키지 (빌드 스크립트 · 함께 쓰는 DLL 의 소스): https://packages.msys2.org/base/rsync MSYS2 패키지 (빌드 스크립트 · 함께 쓰는 DLL 의 소스): https://packages.msys2.org/base/rsync
원문: rsync\COPYING.txt (RSYNC_COPYING.txt - GPL-3.0 과 OpenSSL · xxhash 예외) 원문: 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 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다. 위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.

View File

@ -247,6 +247,9 @@ Future<String> saveUpscaled(String fourX, {required String source, required Stri
return path; return path;
} }
/// [품질 올리기] 다듬기 정도 → 그림 → 그림의 바꾸는 정도
double qualityStrength(String setting) => switch (setting) { 'weak' => 0.15, 'strong' => 0.4, _ => 0.25 };
/// [품질 올리기] 의 그림 → 그림 크기: 원본 비율 그대로, 긴 쪽 [maxSide] 까지, 64 의 배수 (엔진이 요구), 작은 쪽 256 이상 /// [품질 올리기] 의 그림 → 그림 크기: 원본 비율 그대로, 긴 쪽 [maxSide] 까지, 64 의 배수 (엔진이 요구), 작은 쪽 256 이상
(int, int) qualitySize(int w, int h, {int maxSide = 1024}) { (int, int) qualitySize(int w, int h, {int maxSide = 1024}) {
if (w <= 0 || h <= 0) return (512, 512); if (w <= 0 || h <= 0) return (512, 512);

View File

@ -428,6 +428,9 @@ class AppSettings {
/// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임 /// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임
bool explorerProgressHidden = false; bool explorerProgressHidden = false;
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
int zipLevel = 6;
bool explorerShowHidden = false; bool explorerShowHidden = false;
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
@ -600,6 +603,9 @@ class AppSettings {
/// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔 /// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔
bool aiQualityButton = false; bool aiQualityButton = false;
/// [품질 올리기] 다듬기 정도 (그림 → 그림 바꾸는 정도): 'weak' 0.15 · 'normal' 0.25 (처음 값) · 'strong' 0.4 (관리자 지시 - 판단은 설정으로)
String aiQualityStrength = 'normal';
/// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질
String aiUpFormat = 'same'; String aiUpFormat = 'same';
int aiUpJpgQuality = 92; int aiUpJpgQuality = 92;
@ -752,6 +758,7 @@ class AppSettings {
'explorerFolderSize': explorerFolderSize, 'explorerFolderSize': explorerFolderSize,
'explorerDropAction': explorerDropAction, 'explorerDropAction': explorerDropAction,
'explorerProgressHidden': explorerProgressHidden, 'explorerProgressHidden': explorerProgressHidden,
'zipLevel': zipLevel,
'explorerSortPerFolder': explorerSortPerFolder, 'explorerSortPerFolder': explorerSortPerFolder,
'explorerFolderSorts': explorerFolderSorts, 'explorerFolderSorts': explorerFolderSorts,
'explorerShowHidden': explorerShowHidden, 'explorerShowHidden': explorerShowHidden,
@ -813,6 +820,7 @@ class AppSettings {
'aiUpSecPerMp': aiUpSecPerMp, 'aiUpSecPerMp': aiUpSecPerMp,
'aiUpScale': aiUpScale, 'aiUpScale': aiUpScale,
'aiQualityButton': aiQualityButton, 'aiQualityButton': aiQualityButton,
'aiQualityStrength': aiQualityStrength,
'aiUpFormat': aiUpFormat, 'aiUpFormat': aiUpFormat,
'aiUpJpgQuality': aiUpJpgQuality, 'aiUpJpgQuality': aiUpJpgQuality,
'aiUpDir': aiUpDir, 'aiUpDir': aiUpDir,
@ -971,6 +979,7 @@ class AppSettings {
..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort' ..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort'
..explorerSortPerFolder = j['explorerSortPerFolder'] == true ..explorerSortPerFolder = j['explorerSortPerFolder'] == true
..explorerProgressHidden = j['explorerProgressHidden'] == 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' ..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
..explorerFolderSorts = { ..explorerFolderSorts = {
if (j['explorerFolderSorts'] is Map) if (j['explorerFolderSorts'] is Map)
@ -1051,6 +1060,7 @@ class AppSettings {
// 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로) // 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로)
..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2 ..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2
..aiQualityButton = j['aiQualityButton'] == true ..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' ..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'
..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100) ..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100)
..aiUpDir = j['aiUpDir'] as String? ?? '' ..aiUpDir = j['aiUpDir'] as String? ?? ''

359
lib/core/archive_ops.dart Normal file
View File

@ -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<ArchiveEntry> files;
/// 막은 것: (이름, 까닭)
final List<(String, String)> unsafe;
/// 만들지 않고 건너뛰는 링크
final List<String> links;
/// 풀린 크기 합 (바이트)
final int totalSize;
/// 비밀번호가 걸린 항목이 있음
final bool encrypted;
const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted);
}
/// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만
ExtractPlan planExtract(List<ArchiveEntry> entries, {List<String> names = const []}) {
bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/'));
final files = <ArchiveEntry>[];
final unsafe = <(String, String)>[];
final links = <String>[];
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 = <String>[];
final renamed = <String>[];
}
/// 취소 (풀기 · 묶기 도중)
class ArchiveCancelled implements Exception {
const ArchiveCancelled();
@override
String toString() => tr('취소했습니다');
}
// ───────── ZIP (archive 패키지, MIT) ─────────
/// ZIP 목록 - 가운데 목록 (central directory) 을 직접 읽어 비밀번호 · 링크를 안다
/// (archive 패키지의 항목에는 비밀번호 여부가 없고, 비밀번호 없이 읽으면 암호 글자가 그대로 나온다)
Future<List<ArchiveEntry>> 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<ExtractResult> 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<String> 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<ArchiveEntry> parse7zList(String out) {
final entries = <ArchiveEntry>[];
for (final block in out.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) {
final m = <String, String>{};
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<String> 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<List<ArchiveEntry>> 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<ExtractResult> 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 (_) {}
}
}

View File

@ -273,6 +273,21 @@ class _AiSettingsState extends State<AiSettings> {
'새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')), '새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')),
trailing: Switch(value: s.aiQualityButton, onChanged: (v) => c.updateSettings((x) => x.aiQualityButton = v)), 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<String>(
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( SettingTile(
leading: const Icon(Icons.image_outlined), leading: const Icon(Icons.image_outlined),
title: Text(tr('저장 형식')), title: Text(tr('저장 형식')),

View File

@ -426,7 +426,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false /// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false
/// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] - /// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] -
/// AI 그림 (그림 → 그림, 약하게 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율). /// AI 그림 (그림 → 그림, 설정의 다듬기 정도 - 처음 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율).
/// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내. /// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내.
Future<String?> qualityUp(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { Future<String?> qualityUp(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
if (AiJobs.instance.busy) { if (AiJobs.instance.busy) {
@ -510,7 +510,7 @@ Future<String?> qualityUp(BuildContext context, AppController c, {required Strin
steps: c.settings.aiSteps, steps: c.settings.aiSteps,
cfg: c.settings.aiCfg, cfg: c.settings.aiCfg,
initImage: input, initImage: input,
strength: 0.25, strength: qualityStrength(c.settings.aiQualityStrength),
), ),
outDir: tmp.path, outDir: tmp.path,
baseName: 'q', baseName: 'q',

330
lib/ui/archive_ui.dart Normal file
View File

@ -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<String?> askArchivePassword(BuildContext context, String name, {bool wrong = false}) async {
final ctl = TextEditingController();
try {
return await showDialog<String>(
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<T> _withProgress<T>(BuildContext context, String title, Future<T> Function(void Function(double?) set, bool Function() cancelled) run) async {
final v = ValueNotifier<double?>(null);
var stop = false;
final nav = Navigator.of(context);
var open = true;
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(title),
content: SizedBox(
width: 380,
child: ValueListenableBuilder<double?>(
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<String?> extractArchiveFlow(BuildContext context, AppController c, String path, {String? otherDir, List<String> 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<bool>(
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<ArchiveEntry> 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<int?> 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<bool>(
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<NameConflict>(
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<String?> createZipFlow(BuildContext context, AppController c, List<String> 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<bool>(
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;
}

View File

@ -46,6 +46,8 @@ import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
import 'quick_look.dart'; import 'quick_look.dart';
import 'explorer_drag.dart'; import 'explorer_drag.dart';
import 'archive_ui.dart';
import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf;
import 'batch_rename_dialog.dart'; import 'batch_rename_dialog.dart';
import '../core/folder_size.dart'; import '../core/folder_size.dart';
import '../core/batch_rename.dart'; import '../core/batch_rename.dart';
@ -1226,6 +1228,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _play([path]); await _play([path]);
return; return;
} }
// 86: 7z · RAR 은 열면 압축 풀기 (ZIP 은 아래의 보기 · ZIP 화면)
final kind = archiveKindOf(path);
if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return _extractArchive(path, pane ?? _pane);
// 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때) // 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때)
if (c.settings.components.contains('viewer')) { if (c.settings.components.contains('viewer')) {
final ext = extOf(path); final ext = extOf(path);
@ -1329,6 +1334,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
Future<void> _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<void> _openZip(String path, _Pane? pane) async { Future<void> _openZip(String path, _Pane? pane) async {
try { try {
if (c.settings.zipComic && await openZipComic(context, c, path)) return; if (c.settings.zipComic && await openZipComic(context, c, path)) return;
@ -1397,6 +1409,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')),
// 81: 빠른 보기 (PC 는 Space · TC 는 F3) // 81: 빠른 보기 (PC 는 Space · TC 는 F3)
if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), 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: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
@ -1486,6 +1501,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e);
case 'quickLook': case 'quickLook':
await _quickLook(pane, e.path); 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': case 'folderSize':
if (_sizes.busy(e.path)) { if (_sizes.busy(e.path)) {
_sizes.cancel(e.path); _sizes.cancel(e.path);

View File

@ -32,6 +32,9 @@ List<(List<String>, String)> get _licenseAssets => [
(['libmpv (media_kit, LGPL-2.1+)'], 'assets/licenses/LGPL-2.1.txt'), (['libmpv (media_kit, LGPL-2.1+)'], 'assets/licenses/LGPL-2.1.txt'),
([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'), ([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'),
(['rsync'], 'assets/licenses/RSYNC_COPYING.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'), (['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'),
// Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs) // Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'), (['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'),

View File

@ -6,6 +6,7 @@ import '../core/file_ops.dart' show formatSize;
import '../core/reader_sources.dart'; import '../core/reader_sources.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'archive_ui.dart';
import 'reader_page.dart'; import 'reader_page.dart';
import 'theme.dart'; import 'theme.dart';
@ -62,62 +63,10 @@ class _ZipPageState extends State<ZipPage> {
bool get _hasImages => _entries?.any((f) => c.settings.imageExts.contains(extOf(f.name))) ?? false; bool get _hasImages => _entries?.any((f) => c.settings.imageExts.contains(extOf(f.name))) ?? false;
/// 86: 풀기는 압축 풀기 흐름 (목록 검사 - 밖으로 나가는 경로 · 링크 · 크기 · 비밀번호, 같은 이름 · 진행 · 취소) 으로
Future<void> _extract({required bool all}) async { Future<void> _extract({required bool all}) async {
final zipDir = vDirname(widget.path); final dest = await extractArchiveFlow(context, c, widget.path, otherDir: widget.otherDir, names: all ? const [] : _marked.toList());
final base = vBasename(widget.path).replaceAll(RegExp(r'\.[^.]+$'), ''); if (dest != null && mounted) Navigator.pop(context, dest);
final dest = await showDialog<String>(
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]))));
}
} }
@override @override

View File

@ -27,6 +27,11 @@ void main() {
expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2); expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2);
expect(AppSettings().aiQualityButton, isFalse); expect(AppSettings().aiQualityButton, isFalse);
expect(AppSettings.fromJson((AppSettings()..aiQualityButton = true).toJson()).aiQualityButton, isTrue); 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 { test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async {

172
test/archive_ops_test.dart Normal file
View File

@ -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<ArchivePasswordException>().having((e) => e.wrong, 'wrong', isFalse)));
expect(Directory(dest).existsSync(), isFalse, reason: '암호 글자를 그대로 쓰지 않음');
await expectLater(extractZipPlan(zip, dest, plan, password: 'nope'), throwsA(isA<ArchivePasswordException>()));
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<ArchiveCancelled>()));
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<ArchivePasswordException>()));
await expectLater(list7z(exe, enc, password: 'nope'), throwsA(isA<ArchivePasswordException>().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]);
});
});
}

104
test/archive_ui_test.dart Normal file
View File

@ -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<void> wait(WidgetTester tester, bool Function() done) async {
for (var i = 0; i < 200 && !done(); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
}
}
Future<Future<T?> Function(Future<T?> Function(BuildContext))> host<T>(WidgetTester tester) async {
tester.view.physicalSize = const Size(1000, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final key = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold()));
return (Future<T?> 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<String>(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<String>(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<String>(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);
});
}

View File

@ -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)', () { test('Windows 설치: 실행 파일 옆 · tools · rsync 에 원문을 둔다 (CMake)', () {
final cmake = File('windows/CMakeLists.txt').readAsStringSync(); final cmake = File('windows/CMakeLists.txt').readAsStringSync();
expect(cmake, contains('assets/licenses')); expect(cmake, contains('assets/licenses'));

37
tool/fetch_7zip.ps1 Normal file
View File

@ -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"

View File

@ -143,6 +143,16 @@ if(JJ_TOOL_FILES)
DESTINATION "${CMAKE_INSTALL_PREFIX}/tools" COMPONENT Runtime) DESTINATION "${CMAKE_INSTALL_PREFIX}/tools" COMPONENT Runtime)
endif() 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\ 폴더로 복사. # JJ_MKVMaker: rsync 동봉 (내려받기 없이 바로). 실행 파일 옆 rsync\ 폴더로 복사.
# 파일은 tool\fetch_rsync.ps1 로 third_party\rsync\windows 에 준비한다. # 파일은 tool\fetch_rsync.ps1 로 third_party\rsync\windows 에 준비한다.
set(JJ_RSYNC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rsync/windows") set(JJ_RSYNC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rsync/windows")