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
parent
eee38aa2fd
commit
219510fec0
|
|
@ -32,6 +32,7 @@
|
|||
"\"{0}\" 안에 만듭니다": "Created inside \"{0}\"",
|
||||
"\"{0}\" 에 맞는 항목이 없습니다": "No items match \"{0}\"",
|
||||
"\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "These items are not in \"{0}\" but only in the target \"{1}\". Syncing with delete removes them from the target (cannot be undone).",
|
||||
"\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "\"{0}\" is password-protected.",
|
||||
"\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "Move \"{0}\" to the \"{1}\" folder?",
|
||||
"\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?",
|
||||
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "Delete \"{0}\"? This cannot be undone.",
|
||||
|
|
@ -329,6 +330,8 @@
|
|||
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "Used for WebDAV sources (PC folders sync as soon as they change). Shorter means faster syncing but more server and battery use.",
|
||||
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV can't be added to the MKV list. Copy them to this device first.",
|
||||
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV files show only name, size and date",
|
||||
"WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.": "Can't extract into a WebDAV folder. Extract into a folder on this device.",
|
||||
"WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.": "WebDAV items can't be zipped. Choose files or folders on this device.",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: the app syncs directly instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files).",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).",
|
||||
"WebM (동영상)": "WebM (video)",
|
||||
|
|
@ -363,7 +366,12 @@
|
|||
"ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "Double-tapping ZIP · CBZ: comic view → list. To revert: Settings > File explorer > Read ZIP · CBZ as comics",
|
||||
"ZIP · CBZ 를 만화로 보기": "Read ZIP · CBZ as comics",
|
||||
"ZIP 옆 새 폴더 \"{0}\" 로": "To a new folder \"{0}\" next to the ZIP",
|
||||
"ZIP 으로 묶기": "Compress to ZIP",
|
||||
"ZIP 으로 묶기 · {0}개": "Compress to ZIP · {0} items",
|
||||
"ZIP 으로 묶는 중 · {0}": "Compressing · {0}",
|
||||
"ZIP 을 만들지 못했습니다: {0}": "Couldn't create the ZIP: {0}",
|
||||
"ZIP 이 있는 폴더로": "To the ZIP's folder",
|
||||
"ZIP 이름": "ZIP name",
|
||||
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]",
|
||||
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].",
|
||||
|
|
@ -476,6 +484,7 @@
|
|||
"{0}: 저장하지 않았습니다": "{0}: not saved",
|
||||
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}",
|
||||
"{0}개": "{0}",
|
||||
"{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP",
|
||||
"{0}개 · {1}": "{0} · {1}",
|
||||
"{0}개 · {1} …": "{0} items · {1} …",
|
||||
"{0}개 · 주소 {1}개": "{0} · {1} links",
|
||||
|
|
@ -506,6 +515,7 @@
|
|||
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).",
|
||||
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} didn't fit in the Recycle Bin and were deleted permanently (as chosen when Windows asked).",
|
||||
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".",
|
||||
"{0}개를 묶었습니다: {1}": "Compressed {0} files: {1}",
|
||||
"{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).",
|
||||
"{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}",
|
||||
"{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})",
|
||||
|
|
@ -569,13 +579,17 @@
|
|||
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
|
||||
"강하게 (0.4)": "Strong (0.4)",
|
||||
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original",
|
||||
"같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
|
||||
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.",
|
||||
"같은 이름이 이미 있습니다": "An item with the same name already exists",
|
||||
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
|
||||
"같은 이름이 이미 있음": "Name already exists",
|
||||
"같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …",
|
||||
"같은 이름이 있으면": "If the name exists",
|
||||
"같은 이름이 있을 때": "When a file with the same name exists",
|
||||
"같은 이름이라 건너뜀 {0}개": "{0} skipped (same name)",
|
||||
"같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}",
|
||||
"같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)",
|
||||
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "Make a copy in the same folder? (name (2))\n{0}",
|
||||
|
|
@ -591,6 +605,7 @@
|
|||
"거꾸로": "Reverse",
|
||||
"거꾸로 된 범위: {0}": "Reversed range: {0}",
|
||||
"건너뛰기": "Skip",
|
||||
"건너뛴 링크 {0}개": "{0} links skipped",
|
||||
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",
|
||||
"건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}",
|
||||
"건너뜁니다": "skips it",
|
||||
|
|
@ -658,6 +673,7 @@
|
|||
"그래도 Chrome 엔진 쓰기…": "Use the Chrome engine anyway…",
|
||||
"그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})",
|
||||
"그래도 쓰기": "Use anyway",
|
||||
"그래도 풀기": "Extract anyway",
|
||||
"그리는 중 {0}/{1}": "Drawing {0}/{1}",
|
||||
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "To draw, {0} must be downloaded (the model stays on this device, and images and prompts never leave it).",
|
||||
"그리스어": "Greek",
|
||||
|
|
@ -780,6 +796,7 @@
|
|||
"넣지 못했습니다: {0}": "Could not add: {0}",
|
||||
"네덜란드어": "Dutch",
|
||||
"네트워크 · DNS": "Network · DNS",
|
||||
"네트워크 경로 (UNC)": "Network path (UNC)",
|
||||
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "Network shares (SMB) aren't supported yet. Use the WebDAV tab",
|
||||
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).",
|
||||
"네팔어": "Nepali",
|
||||
|
|
@ -817,6 +834,7 @@
|
|||
"다른 이름으로 저장": "Save as",
|
||||
"다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})",
|
||||
"다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage",
|
||||
"다른 창 폴더": "Other pane's folder",
|
||||
"다른 창 폴더로": "To the other pane's folder",
|
||||
"다른 창에서 열기": "Open in other pane",
|
||||
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears",
|
||||
|
|
@ -969,6 +987,7 @@
|
|||
"뒤로": "Back",
|
||||
"뒤로 보내고 계속": "Keep running in background",
|
||||
"뒤에서 계속": "Continue in background",
|
||||
"드라이브 경로": "Drive path",
|
||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
|
||||
"등록": "Register",
|
||||
"등록 중 일부가 실패했습니다.": "Some registrations failed.",
|
||||
|
|
@ -983,6 +1002,9 @@
|
|||
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
|
||||
"루마니아어": "Romanian",
|
||||
"링크 {0} (건너뜀)": "Links {0} (skipped)",
|
||||
"링크 {0}개는 넣지 않음": "{0} links not included",
|
||||
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} links will be skipped (not created): {1}",
|
||||
"링크 · junction 은 넣지 않습니다": "Links and junctions are not included",
|
||||
"마스터 · 최상 비밀번호": "Master and super passwords",
|
||||
"마스터 비밀번호": "Master password",
|
||||
"마스터 비밀번호 넣기": "Enter master password",
|
||||
|
|
@ -1004,9 +1026,11 @@
|
|||
"마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "Swiping past the last screen goes to the first (off: stops at the end)",
|
||||
"마지막 화면입니다": "This is the last screen",
|
||||
"마지막으로: {0}": "To the last: {0}",
|
||||
"막은 경로 {0}개": "{0} paths blocked",
|
||||
"만든 그림 · 저장: {0}": "Made images · saved in: {0}",
|
||||
"만들 MKV 가 이미 있으면": "If the MKV already exists",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
|
||||
"만들 파일: {0}": "File to create: {0}",
|
||||
"만들거나 쓰지 못했습니다": "Could not create or write",
|
||||
"만들기": "Make",
|
||||
"만들다 만 파일 {0}개": "{0} incomplete files",
|
||||
|
|
@ -1079,6 +1103,7 @@
|
|||
"무엇을 그릴지 적어 주세요": "Write what to draw",
|
||||
"무엇이 바뀌는지 비교하는 중…": "Comparing what will change…",
|
||||
"무한": "Unlimited",
|
||||
"묶기": "Compress",
|
||||
"문서 미리보기": "Document preview",
|
||||
"문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first",
|
||||
"문자셋 경고": "Charset warning",
|
||||
|
|
@ -1093,6 +1118,7 @@
|
|||
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
|
||||
"미얀마어": "Burmese",
|
||||
"미지정": "Unspecified",
|
||||
"바깥 폴더 (../)": "Parent folder (../)",
|
||||
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).",
|
||||
"바꾸기": "Change",
|
||||
"바꾸기 ({0}개)": "Rename ({0})",
|
||||
|
|
@ -1116,6 +1142,7 @@
|
|||
"바탕 그림 고르기": "Choose a base picture",
|
||||
"바탕 그림 고르기 (그림 → 그림)": "Choose a base picture (image → image)",
|
||||
"바탕 그림 빼기": "Remove base picture",
|
||||
"밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "Not extracted because the path leads outside ({0}): {1}",
|
||||
"반대로 90° 회전": "Rotate 90° (counterclockwise)",
|
||||
"반전 (고른 것 ↔ 안 고른 것)": "Invert (selected ↔ not selected)",
|
||||
"받기": "Download",
|
||||
|
|
@ -1206,6 +1233,7 @@
|
|||
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
|
||||
"보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)",
|
||||
"보통": "Normal",
|
||||
"보통 (0.25, 기본)": "Normal (0.25, default)",
|
||||
"보통: {0}": "Normal: {0}",
|
||||
"복사": "Copy",
|
||||
"복사 (붙여넣기로)": "Copy (to paste)",
|
||||
|
|
@ -1256,8 +1284,11 @@
|
|||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)",
|
||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.",
|
||||
"비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
|
||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
|
||||
"비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords",
|
||||
"비밀번호가 틀렸습니다": "Wrong password",
|
||||
"비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)",
|
||||
"비밀번호가 필요합니다": "Password required",
|
||||
"비밀번호를 넣으세요": "Enter a password",
|
||||
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
|
||||
"비밀번호를 잊었습니다": "I forgot the password",
|
||||
|
|
@ -1265,10 +1296,12 @@
|
|||
"비어 있는 폴더": "Empty folder",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
||||
"비어 있음": "Empty",
|
||||
"빈 이름": "Empty name",
|
||||
"빈 폴더 {0}개 지움": "Deleted {0} empty folders",
|
||||
"빈 폴더 그대로": "Leave empty folders",
|
||||
"빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too",
|
||||
"빠르게": "Fast",
|
||||
"빠른 디코더 (TAESD)": "Fast decoder (TAESD)",
|
||||
"빠른 필터": "Quick filter",
|
||||
"빼고 싶은 것 (선택)": "What to avoid (optional)",
|
||||
|
|
@ -1449,6 +1482,7 @@
|
|||
"실행해 보지 못함 ({0})": "Could not run it ({0})",
|
||||
"싱크": "Sync",
|
||||
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)",
|
||||
"쓸 수 없는 글자 (:)": "Invalid character (:)",
|
||||
"쓸 수 없는 이름": "Invalid name",
|
||||
"쓸 수 없는 이름입니다": "This name cannot be used",
|
||||
"아랍어": "Arabic",
|
||||
|
|
@ -1476,9 +1510,15 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||
"암호화 안 됨 (http)": "Not encrypted (http)",
|
||||
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
|
||||
"압축 파일 안에 없습니다": "Not in the archive",
|
||||
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
|
||||
"압축 푸는 중 · {0}": "Extracting · {0}",
|
||||
"압축 풀기": "Extract",
|
||||
"압축 풀기 · {0}": "Extract · {0}",
|
||||
"압축을 읽지 못했습니다: {0}": "Couldn't read the archive: {0}",
|
||||
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
|
||||
"압축이 있는 폴더": "Archive's folder",
|
||||
"앞 항목 (←)": "Previous item (←)",
|
||||
"앞으로": "Forward",
|
||||
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
|
||||
|
|
@ -1509,6 +1549,7 @@
|
|||
"약 {0} 남음": "about {0} left",
|
||||
"약 {0}~{1}분": "about {0}–{1} min",
|
||||
"약 {0}분": "about {0} min",
|
||||
"약하게 (0.15)": "Light (0.15)",
|
||||
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "Both ways (⇄) never deletes source files (--remove-source-files in the options is not used).",
|
||||
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).",
|
||||
"어둡게": "Darker",
|
||||
|
|
@ -1723,6 +1764,7 @@
|
|||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
|
||||
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app",
|
||||
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device",
|
||||
"이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "7z and RAR can't be extracted in the app on this device yet. Open it with another app that extracts archives.",
|
||||
"이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "On this device Low · Normal · High are all the same: {0} (smallest tile because memory is small)",
|
||||
"이 기기용 파일 없음": "No file for this device",
|
||||
"이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine",
|
||||
|
|
@ -1808,6 +1850,7 @@
|
|||
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
|
||||
"이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.",
|
||||
"이름을 바꿉니다": "renames it",
|
||||
"이름을 바꿔 풂 {0}개": "{0} extracted with new names",
|
||||
"이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})",
|
||||
"이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)",
|
||||
"이미 그리는 중입니다": "Already drawing",
|
||||
|
|
@ -1984,6 +2027,7 @@
|
|||
"전체 이동": "Move all",
|
||||
"전체 화면": "Full screen",
|
||||
"전체 화면 (F)": "Full screen (F)",
|
||||
"절대 경로": "Absolute path",
|
||||
"점검": "Check",
|
||||
"정렬 기준": "Sort by",
|
||||
"정렬을 폴더마다 기억": "Remember sort per folder",
|
||||
|
|
@ -2083,6 +2127,7 @@
|
|||
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
|
||||
"지금 짝 추가": "Add current pair",
|
||||
"지금 짝을 즐겨찾기에": "Add the current pair to favorites",
|
||||
"지금 폴더": "Current folder",
|
||||
"지금 폴더 추가": "Add current folder",
|
||||
"지금 폴더를 즐겨찾기에": "Add the current folder to favorites",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)",
|
||||
|
|
@ -2197,6 +2242,8 @@
|
|||
"취소됨: {0}": "Cancelled: {0}",
|
||||
"취소했습니다": "Cancelled",
|
||||
"취소했습니다.": "Cancelled.",
|
||||
"취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "Cancelled. No partial ZIP is left.",
|
||||
"취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "Cancelled. The file being extracted is not left behind (finished files remain).",
|
||||
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).",
|
||||
"칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.",
|
||||
"칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "Options in the field: {0}\nSaved options: {1}\n\nSave the edited options and run?",
|
||||
|
|
@ -2335,9 +2382,12 @@
|
|||
"표준": "Standard",
|
||||
"푸는 중": "Extracting",
|
||||
"푸는 중: {0}": "Extracting: {0}",
|
||||
"풀기": "Extract",
|
||||
"풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "The extracted size ({0}) is larger than the free space ({1}). Extract anyway?",
|
||||
"풀지 못했습니다: {0}": "Could not extract: {0}",
|
||||
"품질 올리기": "Improve quality",
|
||||
"품질 올리기 (이 장)": "Improve quality (this page)",
|
||||
"품질 올리기 다듬기 정도": "Improve quality strength",
|
||||
"품질 올리기 단추 보이기": "Show Improve quality button",
|
||||
"품질 올리기: {0}": "Improve quality: {0}",
|
||||
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "Improve quality needs the AI image model. Download it in Settings > AI image and try again.",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"\"{0}\" 안에 만듭니다": "「{0}」の中に作成します",
|
||||
"\"{0}\" 에 맞는 항목이 없습니다": "「{0}」に合う項目はありません",
|
||||
"\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。",
|
||||
"\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "「{0}」にはパスワードが設定されています。",
|
||||
"\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?",
|
||||
"\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?",
|
||||
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。",
|
||||
|
|
@ -329,6 +330,8 @@
|
|||
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。",
|
||||
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
|
||||
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV のファイルは名前・サイズ・日付のみ表示します",
|
||||
"WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.": "WebDAV フォルダーには展開できません。この端末のフォルダーで展開してください。",
|
||||
"WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.": "WebDAV の項目は ZIP にまとめられません。この端末のファイル・フォルダーを選んでください。",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。",
|
||||
"WebM (동영상)": "WebM(動画)",
|
||||
|
|
@ -363,7 +366,12 @@
|
|||
"ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をダブルタップ: マンガ表示 → 一覧。元に戻す: 環境設定 > ファイルエクスプローラー > ZIP・CBZ をマンガとして表示",
|
||||
"ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をマンガとして表示",
|
||||
"ZIP 옆 새 폴더 \"{0}\" 로": "ZIP の隣の新しいフォルダー「{0}」へ",
|
||||
"ZIP 으로 묶기": "ZIP にまとめる",
|
||||
"ZIP 으로 묶기 · {0}개": "ZIP にまとめる · {0} 件",
|
||||
"ZIP 으로 묶는 중 · {0}": "ZIP にまとめています · {0}",
|
||||
"ZIP 을 만들지 못했습니다: {0}": "ZIP を作成できませんでした: {0}",
|
||||
"ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ",
|
||||
"ZIP 이름": "ZIP の名前",
|
||||
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]",
|
||||
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。",
|
||||
|
|
@ -476,6 +484,7 @@
|
|||
"{0}: 저장하지 않았습니다": "{0}: 保存しませんでした",
|
||||
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
|
||||
"{0}개": "{0}個",
|
||||
"{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる",
|
||||
"{0}개 · {1}": "{0}個・{1}",
|
||||
"{0}개 · {1} …": "{0} 件 · {1} …",
|
||||
"{0}개 · 주소 {1}개": "{0}個・URL {1}個",
|
||||
|
|
@ -506,6 +515,7 @@
|
|||
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。",
|
||||
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。",
|
||||
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。",
|
||||
"{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}",
|
||||
"{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。",
|
||||
"{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}",
|
||||
"{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})",
|
||||
|
|
@ -569,13 +579,17 @@
|
|||
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
|
||||
"강하게 (0.4)": "強め (0.4)",
|
||||
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
|
||||
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
|
||||
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。",
|
||||
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
|
||||
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
|
||||
"같은 이름이 이미 있음": "同じ名前が既にあります",
|
||||
"같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …",
|
||||
"같은 이름이 있으면": "同じ名前がある場合",
|
||||
"같은 이름이 있을 때": "同じ名前があるとき",
|
||||
"같은 이름이라 건너뜀 {0}개": "同じ名前のためスキップ {0} 件",
|
||||
"같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}",
|
||||
"같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)",
|
||||
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "同じフォルダーにコピーを作りますか? (名前 (2))\n{0}",
|
||||
|
|
@ -591,6 +605,7 @@
|
|||
"거꾸로": "逆順",
|
||||
"거꾸로 된 범위: {0}": "逆順の範囲: {0}",
|
||||
"건너뛰기": "スキップ",
|
||||
"건너뛴 링크 {0}개": "スキップしたリンク {0} 件",
|
||||
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",
|
||||
"건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}",
|
||||
"건너뜁니다": "スキップします",
|
||||
|
|
@ -658,6 +673,7 @@
|
|||
"그래도 Chrome 엔진 쓰기…": "それでも Chrome エンジンを使う…",
|
||||
"그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)",
|
||||
"그래도 쓰기": "それでも使う",
|
||||
"그래도 풀기": "それでも展開",
|
||||
"그리는 중 {0}/{1}": "描画中 {0}/{1}",
|
||||
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。",
|
||||
"그리스어": "ギリシャ語",
|
||||
|
|
@ -780,6 +796,7 @@
|
|||
"넣지 못했습니다: {0}": "追加できませんでした: {0}",
|
||||
"네덜란드어": "オランダ語",
|
||||
"네트워크 · DNS": "ネットワーク・DNS",
|
||||
"네트워크 경로 (UNC)": "ネットワークパス (UNC)",
|
||||
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください",
|
||||
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。",
|
||||
"네팔어": "ネパール語",
|
||||
|
|
@ -817,6 +834,7 @@
|
|||
"다른 이름으로 저장": "名前を付けて保存",
|
||||
"다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})",
|
||||
"다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません",
|
||||
"다른 창 폴더": "もう一方のウィンドウのフォルダー",
|
||||
"다른 창 폴더로": "もう一方の画面のフォルダーへ",
|
||||
"다른 창에서 열기": "もう一方のペインで開く",
|
||||
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます",
|
||||
|
|
@ -969,6 +987,7 @@
|
|||
"뒤로": "戻る",
|
||||
"뒤로 보내고 계속": "裏で続ける",
|
||||
"뒤에서 계속": "バックグラウンドで続ける",
|
||||
"드라이브 경로": "ドライブのパス",
|
||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
|
||||
"등록": "登録",
|
||||
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
|
||||
|
|
@ -983,6 +1002,9 @@
|
|||
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
|
||||
"루마니아어": "ルーマニア語",
|
||||
"링크 {0} (건너뜀)": "リンク {0} (スキップ)",
|
||||
"링크 {0}개는 넣지 않음": "リンク {0} 件は含めず",
|
||||
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}",
|
||||
"링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません",
|
||||
"마스터 · 최상 비밀번호": "マスター / 最上位パスワード",
|
||||
"마스터 비밀번호": "マスターパスワード",
|
||||
"마스터 비밀번호 넣기": "マスターパスワードを入力",
|
||||
|
|
@ -1004,9 +1026,11 @@
|
|||
"마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "最後の画面からさらにスワイプすると最初の画面へ (オフ: 端で止まる)",
|
||||
"마지막 화면입니다": "最後の画面です",
|
||||
"마지막으로: {0}": "最後へ: {0}",
|
||||
"막은 경로 {0}개": "ブロックしたパス {0} 件",
|
||||
"만든 그림 · 저장: {0}": "作成した画像・保存先: {0}",
|
||||
"만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
|
||||
"만들 파일: {0}": "作成するファイル: {0}",
|
||||
"만들거나 쓰지 못했습니다": "作成または書き込みできませんでした",
|
||||
"만들기": "作成",
|
||||
"만들다 만 파일 {0}개": "未完成のファイル {0} 個",
|
||||
|
|
@ -1079,6 +1103,7 @@
|
|||
"무엇을 그릴지 적어 주세요": "何を描くか入力してください",
|
||||
"무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…",
|
||||
"무한": "無制限",
|
||||
"묶기": "まとめる",
|
||||
"문서 미리보기": "文書プレビュー",
|
||||
"문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください",
|
||||
"문자셋 경고": "文字コードの警告",
|
||||
|
|
@ -1093,6 +1118,7 @@
|
|||
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
|
||||
"미얀마어": "ミャンマー語",
|
||||
"미지정": "未指定",
|
||||
"바깥 폴더 (../)": "外側のフォルダー (../)",
|
||||
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "変更・オフにするには現在のマスターパスワード (または最上位パスワード) を入力してください。",
|
||||
"바꾸기": "変更",
|
||||
"바꾸기 ({0}개)": "変更 ({0} 件)",
|
||||
|
|
@ -1116,6 +1142,7 @@
|
|||
"바탕 그림 고르기": "元の画像を選択",
|
||||
"바탕 그림 고르기 (그림 → 그림)": "元の画像を選択 (画像 → 画像)",
|
||||
"바탕 그림 빼기": "元の画像を外す",
|
||||
"밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "外に出るパスのため展開しません ({0} 件): {1}",
|
||||
"반대로 90° 회전": "反対に 90° 回転",
|
||||
"반전 (고른 것 ↔ 안 고른 것)": "反転 (選択 ↔ 未選択)",
|
||||
"받기": "ダウンロード",
|
||||
|
|
@ -1206,6 +1233,7 @@
|
|||
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
|
||||
"보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)",
|
||||
"보통": "普通",
|
||||
"보통 (0.25, 기본)": "普通 (0.25、既定)",
|
||||
"보통: {0}": "普通: {0}",
|
||||
"복사": "コピー",
|
||||
"복사 (붙여넣기로)": "コピー (貼り付け用)",
|
||||
|
|
@ -1256,8 +1284,11 @@
|
|||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
|
||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
|
||||
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
|
||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
|
||||
"비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき",
|
||||
"비밀번호가 틀렸습니다": "パスワードが違います",
|
||||
"비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)",
|
||||
"비밀번호가 필요합니다": "パスワードが必要です",
|
||||
"비밀번호를 넣으세요": "パスワードを入力してください",
|
||||
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
|
||||
"비밀번호를 잊었습니다": "パスワードを忘れました",
|
||||
|
|
@ -1265,10 +1296,12 @@
|
|||
"비어 있는 폴더": "空のフォルダー",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
||||
"비어 있음": "空",
|
||||
"빈 이름": "空の名前",
|
||||
"빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除",
|
||||
"빈 폴더 그대로": "空フォルダーはそのまま",
|
||||
"빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除",
|
||||
"빠르게": "速く",
|
||||
"빠른 디코더 (TAESD)": "高速デコーダー (TAESD)",
|
||||
"빠른 필터": "クイックフィルター",
|
||||
"빼고 싶은 것 (선택)": "避けたいもの (任意)",
|
||||
|
|
@ -1449,6 +1482,7 @@
|
|||
"실행해 보지 못함 ({0})": "実行できませんでした ({0})",
|
||||
"싱크": "同期",
|
||||
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)",
|
||||
"쓸 수 없는 글자 (:)": "使えない文字 (:)",
|
||||
"쓸 수 없는 이름": "使えない名前",
|
||||
"쓸 수 없는 이름입니다": "使用できない名前です",
|
||||
"아랍어": "アラビア語",
|
||||
|
|
@ -1476,9 +1510,15 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||
"암호화 안 됨 (http)": "暗号化なし (http)",
|
||||
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
|
||||
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
||||
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
|
||||
"압축 푸는 중 · {0}": "展開中 · {0}",
|
||||
"압축 풀기": "展開",
|
||||
"압축 풀기 · {0}": "展開 · {0}",
|
||||
"압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}",
|
||||
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
|
||||
"압축이 있는 폴더": "アーカイブのあるフォルダー",
|
||||
"앞 항목 (←)": "前の項目 (←)",
|
||||
"앞으로": "進む",
|
||||
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
|
||||
|
|
@ -1509,6 +1549,7 @@
|
|||
"약 {0} 남음": "残り約 {0}",
|
||||
"약 {0}~{1}분": "約 {0}~{1} 分",
|
||||
"약 {0}분": "約 {0} 分",
|
||||
"약하게 (0.15)": "弱め (0.15)",
|
||||
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。",
|
||||
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
|
||||
"어둡게": "暗く",
|
||||
|
|
@ -1723,6 +1764,7 @@
|
|||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
|
||||
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください",
|
||||
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです",
|
||||
"이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "この端末ではまだアプリ内で 7z・RAR を展開できません。展開できる別のアプリで開いてください。",
|
||||
"이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "この機器では少なめ · 普通 · 多めはすべて同じです: {0} (メモリが少ないため最小のタイル)",
|
||||
"이 기기용 파일 없음": "このデバイス用のファイルなし",
|
||||
"이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません",
|
||||
|
|
@ -1808,6 +1850,7 @@
|
|||
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
|
||||
"이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。",
|
||||
"이름을 바꿉니다": "名前を変えます",
|
||||
"이름을 바꿔 풂 {0}개": "名前を変えて展開 {0} 件",
|
||||
"이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})",
|
||||
"이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)",
|
||||
"이미 그리는 중입니다": "すでに描画中です",
|
||||
|
|
@ -1984,6 +2027,7 @@
|
|||
"전체 이동": "すべて移動",
|
||||
"전체 화면": "全画面",
|
||||
"전체 화면 (F)": "全画面(F)",
|
||||
"절대 경로": "絶対パス",
|
||||
"점검": "点検",
|
||||
"정렬 기준": "並べ替え",
|
||||
"정렬을 폴더마다 기억": "フォルダーごとに並べ替えを記憶",
|
||||
|
|
@ -2083,6 +2127,7 @@
|
|||
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
|
||||
"지금 짝 추가": "今のペアを追加",
|
||||
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
|
||||
"지금 폴더": "現在のフォルダー",
|
||||
"지금 폴더 추가": "今のフォルダーを追加",
|
||||
"지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)",
|
||||
|
|
@ -2197,6 +2242,8 @@
|
|||
"취소됨: {0}": "取り消し: {0}",
|
||||
"취소했습니다": "キャンセルしました",
|
||||
"취소했습니다.": "キャンセルしました。",
|
||||
"취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "キャンセルしました。作りかけの ZIP は残りません。",
|
||||
"취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "キャンセルしました。展開中のファイルは残りません (展開済みのファイルはそのまま)。",
|
||||
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。",
|
||||
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
|
||||
"칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "欄に表示中のオプション: {0}\n保存済みのオプション: {1}\n\n編集したオプションを保存して実行しますか?",
|
||||
|
|
@ -2335,9 +2382,12 @@
|
|||
"표준": "標準",
|
||||
"푸는 중": "展開中",
|
||||
"푸는 중: {0}": "展開中: {0}",
|
||||
"풀기": "展開",
|
||||
"풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "展開後のサイズ ({0}) が空き容量 ({1}) より大きいです。それでも展開しますか?",
|
||||
"풀지 못했습니다: {0}": "展開できませんでした: {0}",
|
||||
"품질 올리기": "画質を上げる",
|
||||
"품질 올리기 (이 장)": "画質を上げる (このページ)",
|
||||
"품질 올리기 다듬기 정도": "画質を上げる整えの強さ",
|
||||
"품질 올리기 단추 보이기": "[画質を上げる] ボタンを表示",
|
||||
"품질 올리기: {0}": "画質を上げる: {0}",
|
||||
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "画質を上げるには AI 画像モデルが必要です。設定 > AI 画像でモデルを入手してから、もう一度押してください。",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"\"{0}\" 안에 만듭니다": "在“{0}”中创建",
|
||||
"\"{0}\" 에 맞는 항목이 없습니다": "没有与“{0}”匹配的项目",
|
||||
"\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。",
|
||||
"\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "“{0}”设有密码。",
|
||||
"\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?",
|
||||
"\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?",
|
||||
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。",
|
||||
|
|
@ -329,6 +330,8 @@
|
|||
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。",
|
||||
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。",
|
||||
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV 文件仅显示名称、大小和日期",
|
||||
"WebDAV 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.": "无法解压到 WebDAV 文件夹。请解压到本设备的文件夹。",
|
||||
"WebDAV 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.": "WebDAV 项无法压缩为 ZIP。请选择本设备上的文件或文件夹。",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: 由应用直接同步而非 rsync (比较大小 · 修改时间,遵循 -u · --delete · --remove-source-files)。",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。",
|
||||
"WebM (동영상)": "WebM(视频)",
|
||||
|
|
@ -363,7 +366,12 @@
|
|||
"ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기": "双击 ZIP · CBZ: 漫画阅读 → 列表。恢复: 设置 > 文件浏览器 > 将 ZIP · CBZ 作为漫画阅读",
|
||||
"ZIP · CBZ 를 만화로 보기": "将 ZIP · CBZ 作为漫画阅读",
|
||||
"ZIP 옆 새 폴더 \"{0}\" 로": "到 ZIP 旁的新文件夹“{0}”",
|
||||
"ZIP 으로 묶기": "压缩为 ZIP",
|
||||
"ZIP 으로 묶기 · {0}개": "压缩为 ZIP · {0} 项",
|
||||
"ZIP 으로 묶는 중 · {0}": "正在压缩 · {0}",
|
||||
"ZIP 을 만들지 못했습니다: {0}": "无法创建 ZIP: {0}",
|
||||
"ZIP 이 있는 폴더로": "到 ZIP 所在文件夹",
|
||||
"ZIP 이름": "ZIP 名称",
|
||||
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]",
|
||||
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。",
|
||||
|
|
@ -476,6 +484,7 @@
|
|||
"{0}: 저장하지 않았습니다": "{0}:未保存",
|
||||
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
|
||||
"{0}개": "{0} 个",
|
||||
"{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP",
|
||||
"{0}개 · {1}": "{0} 个 · {1}",
|
||||
"{0}개 · {1} …": "{0} 项 · {1} …",
|
||||
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
|
||||
|
|
@ -506,6 +515,7 @@
|
|||
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。",
|
||||
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。",
|
||||
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。",
|
||||
"{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}",
|
||||
"{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。",
|
||||
"{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}",
|
||||
"{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})",
|
||||
|
|
@ -569,13 +579,17 @@
|
|||
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
|
||||
"강하게 (0.4)": "强 (0.4)",
|
||||
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
|
||||
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
|
||||
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。",
|
||||
"같은 이름이 이미 있습니다": "已存在同名项目",
|
||||
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
|
||||
"같은 이름이 이미 있음": "同名项已存在",
|
||||
"같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …",
|
||||
"같은 이름이 있으면": "如果同名",
|
||||
"같은 이름이 있을 때": "存在同名项目时",
|
||||
"같은 이름이라 건너뜀 {0}개": "因同名跳过 {0} 个",
|
||||
"같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}",
|
||||
"같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)",
|
||||
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "要在同一文件夹中创建副本吗?(名称 (2))\n{0}",
|
||||
|
|
@ -591,6 +605,7 @@
|
|||
"거꾸로": "倒序",
|
||||
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
|
||||
"건너뛰기": "跳过",
|
||||
"건너뛴 링크 {0}개": "跳过的链接 {0} 个",
|
||||
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",
|
||||
"건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}",
|
||||
"건너뜁니다": "跳过",
|
||||
|
|
@ -658,6 +673,7 @@
|
|||
"그래도 Chrome 엔진 쓰기…": "仍然使用 Chrome 引擎…",
|
||||
"그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)",
|
||||
"그래도 쓰기": "仍然使用",
|
||||
"그래도 풀기": "仍然解压",
|
||||
"그리는 중 {0}/{1}": "正在绘制 {0}/{1}",
|
||||
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。",
|
||||
"그리스어": "希腊语",
|
||||
|
|
@ -780,6 +796,7 @@
|
|||
"넣지 못했습니다: {0}": "无法加入: {0}",
|
||||
"네덜란드어": "荷兰语",
|
||||
"네트워크 · DNS": "网络 · DNS",
|
||||
"네트워크 경로 (UNC)": "网络路径 (UNC)",
|
||||
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签",
|
||||
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。",
|
||||
"네팔어": "尼泊尔语",
|
||||
|
|
@ -817,6 +834,7 @@
|
|||
"다른 이름으로 저장": "另存为",
|
||||
"다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})",
|
||||
"다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储",
|
||||
"다른 창 폴더": "另一窗格的文件夹",
|
||||
"다른 창 폴더로": "到另一窗格的文件夹",
|
||||
"다른 창에서 열기": "在另一窗格中打开",
|
||||
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)",
|
||||
|
|
@ -969,6 +987,7 @@
|
|||
"뒤로": "后退",
|
||||
"뒤로 보내고 계속": "转到后台继续",
|
||||
"뒤에서 계속": "在后台继续",
|
||||
"드라이브 경로": "驱动器路径",
|
||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
|
||||
"등록": "注册",
|
||||
"등록 중 일부가 실패했습니다.": "部分注册失败。",
|
||||
|
|
@ -983,6 +1002,9 @@
|
|||
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
|
||||
"루마니아어": "罗马尼亚语",
|
||||
"링크 {0} (건너뜀)": "链接 {0}(跳过)",
|
||||
"링크 {0}개는 넣지 않음": "{0} 个链接未包含",
|
||||
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}",
|
||||
"링크 · junction 은 넣지 않습니다": "不包含链接和 junction",
|
||||
"마스터 · 최상 비밀번호": "主密码和最高密码",
|
||||
"마스터 비밀번호": "主密码",
|
||||
"마스터 비밀번호 넣기": "输入主密码",
|
||||
|
|
@ -1004,9 +1026,11 @@
|
|||
"마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "在最后一个界面继续滑动会回到第一个界面 (关闭: 停在末尾)",
|
||||
"마지막 화면입니다": "这是最后一个画面",
|
||||
"마지막으로: {0}": "到最后: {0}",
|
||||
"막은 경로 {0}개": "已阻止 {0} 个路径",
|
||||
"만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}",
|
||||
"만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
|
||||
"만들 파일: {0}": "要创建的文件: {0}",
|
||||
"만들거나 쓰지 못했습니다": "无法创建或写入",
|
||||
"만들기": "生成",
|
||||
"만들다 만 파일 {0}개": "{0} 个未完成的文件",
|
||||
|
|
@ -1079,6 +1103,7 @@
|
|||
"무엇을 그릴지 적어 주세요": "请输入要画的内容",
|
||||
"무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…",
|
||||
"무한": "无限制",
|
||||
"묶기": "压缩",
|
||||
"문서 미리보기": "文档预览",
|
||||
"문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件",
|
||||
"문자셋 경고": "字符集警告",
|
||||
|
|
@ -1093,6 +1118,7 @@
|
|||
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
|
||||
"미얀마어": "缅甸语",
|
||||
"미지정": "未指定",
|
||||
"바깥 폴더 (../)": "上级文件夹 (../)",
|
||||
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "如需更改或关闭,请输入当前主密码 (或最高密码)。",
|
||||
"바꾸기": "更改",
|
||||
"바꾸기 ({0}개)": "重命名 ({0} 个)",
|
||||
|
|
@ -1116,6 +1142,7 @@
|
|||
"바탕 그림 고르기": "选择基础图片",
|
||||
"바탕 그림 고르기 (그림 → 그림)": "选择基础图片 (图片 → 图片)",
|
||||
"바탕 그림 빼기": "移除基础图片",
|
||||
"밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}": "因路径指向外部而不解压 ({0} 个): {1}",
|
||||
"반대로 90° 회전": "逆时针旋转 90°",
|
||||
"반전 (고른 것 ↔ 안 고른 것)": "反选(已选 ↔ 未选)",
|
||||
"받기": "下载",
|
||||
|
|
@ -1206,6 +1233,7 @@
|
|||
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
|
||||
"보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)",
|
||||
"보통": "普通",
|
||||
"보통 (0.25, 기본)": "普通 (0.25,默认)",
|
||||
"보통: {0}": "普通:{0}",
|
||||
"복사": "复制",
|
||||
"복사 (붙여넣기로)": "复制 (用于粘贴)",
|
||||
|
|
@ -1256,8 +1284,11 @@
|
|||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
|
||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
|
||||
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
|
||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
|
||||
"비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时",
|
||||
"비밀번호가 틀렸습니다": "密码错误",
|
||||
"비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)",
|
||||
"비밀번호가 필요합니다": "需要密码",
|
||||
"비밀번호를 넣으세요": "请输入密码",
|
||||
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
|
||||
"비밀번호를 잊었습니다": "忘记密码",
|
||||
|
|
@ -1265,10 +1296,12 @@
|
|||
"비어 있는 폴더": "空文件夹",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
||||
"비어 있음": "空",
|
||||
"빈 이름": "空名称",
|
||||
"빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹",
|
||||
"빈 폴더 그대로": "保留空文件夹",
|
||||
"빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除",
|
||||
"빠르게": "快速",
|
||||
"빠른 디코더 (TAESD)": "快速解码器 (TAESD)",
|
||||
"빠른 필터": "快速筛选",
|
||||
"빼고 싶은 것 (선택)": "要排除的内容 (可选)",
|
||||
|
|
@ -1449,6 +1482,7 @@
|
|||
"실행해 보지 못함 ({0})": "无法运行({0})",
|
||||
"싱크": "同步",
|
||||
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)",
|
||||
"쓸 수 없는 글자 (:)": "无效字符 (:)",
|
||||
"쓸 수 없는 이름": "无效名称",
|
||||
"쓸 수 없는 이름입니다": "不能使用此名称",
|
||||
"아랍어": "阿拉伯语",
|
||||
|
|
@ -1476,9 +1510,15 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||
"암호화 안 됨 (http)": "未加密 (http)",
|
||||
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
|
||||
"압축 파일 안에 없습니다": "压缩文件中没有",
|
||||
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
|
||||
"압축 푸는 중 · {0}": "正在解压 · {0}",
|
||||
"압축 풀기": "解压",
|
||||
"압축 풀기 · {0}": "解压 · {0}",
|
||||
"압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}",
|
||||
"압축을 풀 수 없습니다: {0}": "无法解压: {0}",
|
||||
"압축이 있는 폴더": "压缩包所在文件夹",
|
||||
"앞 항목 (←)": "上一项 (←)",
|
||||
"앞으로": "前进",
|
||||
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
|
||||
|
|
@ -1509,6 +1549,7 @@
|
|||
"약 {0} 남음": "约剩 {0}",
|
||||
"약 {0}~{1}분": "约 {0}~{1} 分钟",
|
||||
"약 {0}분": "约 {0} 分钟",
|
||||
"약하게 (0.15)": "弱 (0.15)",
|
||||
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。",
|
||||
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
|
||||
"어둡게": "调暗",
|
||||
|
|
@ -1723,6 +1764,7 @@
|
|||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
|
||||
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用",
|
||||
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用",
|
||||
"이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "本设备暂时无法在应用内解压 7z 和 RAR。请用其他解压应用打开。",
|
||||
"이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "在此设备上,少 · 普通 · 多 都相同:{0}(内存较小,使用最小的分块)",
|
||||
"이 기기용 파일 없음": "没有此设备的文件",
|
||||
"이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎",
|
||||
|
|
@ -1808,6 +1850,7 @@
|
|||
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
|
||||
"이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。",
|
||||
"이름을 바꿉니다": "重命名",
|
||||
"이름을 바꿔 풂 {0}개": "{0} 个以新名称解压",
|
||||
"이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})",
|
||||
"이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)",
|
||||
"이미 그리는 중입니다": "已在绘制中",
|
||||
|
|
@ -1984,6 +2027,7 @@
|
|||
"전체 이동": "全部移动",
|
||||
"전체 화면": "全屏",
|
||||
"전체 화면 (F)": "全屏(F)",
|
||||
"절대 경로": "绝对路径",
|
||||
"점검": "检查",
|
||||
"정렬 기준": "排序方式",
|
||||
"정렬을 폴더마다 기억": "按文件夹记住排序",
|
||||
|
|
@ -2083,6 +2127,7 @@
|
|||
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
|
||||
"지금 짝 추가": "添加当前一对",
|
||||
"지금 짝을 즐겨찾기에": "将当前一对加入收藏",
|
||||
"지금 폴더": "当前文件夹",
|
||||
"지금 폴더 추가": "添加当前文件夹",
|
||||
"지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)",
|
||||
|
|
@ -2197,6 +2242,8 @@
|
|||
"취소됨: {0}": "已取消:{0}",
|
||||
"취소했습니다": "已取消",
|
||||
"취소했습니다.": "已取消。",
|
||||
"취소했습니다. 만들다 만 ZIP 은 남지 않습니다.": "已取消。不会留下未完成的 ZIP。",
|
||||
"취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "已取消。正在解压的文件不会留下 (已解压的文件保留)。",
|
||||
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。",
|
||||
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
|
||||
"칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "输入框中的选项: {0}\n已保存的选项: {1}\n\n保存修改的选项并执行吗?",
|
||||
|
|
@ -2335,9 +2382,12 @@
|
|||
"표준": "标准",
|
||||
"푸는 중": "正在解压",
|
||||
"푸는 중: {0}": "正在解压: {0}",
|
||||
"풀기": "解压",
|
||||
"풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?": "解压后大小 ({0}) 大于剩余空间 ({1})。仍要解压吗?",
|
||||
"풀지 못했습니다: {0}": "无法解压: {0}",
|
||||
"품질 올리기": "提升画质",
|
||||
"품질 올리기 (이 장)": "提升画质 (本页)",
|
||||
"품질 올리기 다듬기 정도": "提升画质的优化强度",
|
||||
"품질 올리기 단추 보이기": "显示提升画质按钮",
|
||||
"품질 올리기: {0}": "提升画质: {0}",
|
||||
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "提升画质需要 AI 图片模型。请在 设置 > AI 图片 中下载后再试。",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
--
|
||||
|
|
@ -7,7 +7,7 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
|
|||
(GPL-2.0.txt · GPL-3.0.txt · LGPL-2.1.txt · PDFIUM_LICENSES.txt · YT-DLP_THIRD_PARTY_LICENSES.txt ·
|
||||
RSYNC_COPYING.txt · WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt · STABLE_DIFFUSION_CPP_LICENSES.txt ·
|
||||
CREATIVEML_OPENRAIL_M.txt · CREATIVEML_OPENRAIL_PP_M.txt · TAESD_LICENSE.txt · REAL_ESRGAN_LICENSE.txt ·
|
||||
WHISPER_MODEL_LICENSE.txt · NLLB_CC_BY_NC_4.0.txt · TOR_LICENSES.txt) 및 ffmpeg\, tools\, rsync\ 폴더의 *_LICENSE*, *_COPYING* 파일에서 볼 수 있습니다.
|
||||
WHISPER_MODEL_LICENSE.txt · NLLB_CC_BY_NC_4.0.txt · TOR_LICENSES.txt · 7ZIP_LICENSE.txt) 및 ffmpeg\, tools\, rsync\, 7zip\ 폴더의 *_LICENSE*, *_COPYING*, License.txt 파일에서 볼 수 있습니다.
|
||||
WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도 둡니다.
|
||||
|
||||
[프로그램에 연결된 라이브러리]
|
||||
|
|
@ -74,6 +74,12 @@ WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도
|
|||
소스: https://download.samba.org/pub/rsync/src/rsync-3.5.1.tar.gz
|
||||
MSYS2 패키지 (빌드 스크립트 · 함께 쓰는 DLL 의 소스): https://packages.msys2.org/base/rsync
|
||||
원문: rsync\COPYING.txt (RSYNC_COPYING.txt - GPL-3.0 과 OpenSSL · xxhash 예외)
|
||||
- 7-Zip 25.01 (7zip\7z.exe · 7z.dll) LGPL-2.1-or-later · BSD-3-Clause · BSD-2-Clause · unRAR 라이선스 제한
|
||||
Copyright (C) 1999-2025 Igor Pavlov. https://www.7-zip.org
|
||||
7z · RAR 압축을 푸는 데만 씁니다. RAR 을 푸는 코드는 unRAR 라이선스 제한을 따르며 RAR 을 만드는 데 쓰지 않습니다
|
||||
(이 앱은 RAR 을 만들지 않습니다). 수정하지 않은 공식 배포 파일 (7z2501-x64.msi) 이며 따로 실행합니다.
|
||||
소스: https://www.7-zip.org/download.html (7z2501-src.7z)
|
||||
원문: 7zip\License.txt (7ZIP_LICENSE.txt), LGPL-2.1.txt
|
||||
|
||||
위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@ Future<String> saveUpscaled(String fourX, {required String source, required Stri
|
|||
return path;
|
||||
}
|
||||
|
||||
/// [품질 올리기] 다듬기 정도 → 그림 → 그림의 바꾸는 정도
|
||||
double qualityStrength(String setting) => switch (setting) { 'weak' => 0.15, 'strong' => 0.4, _ => 0.25 };
|
||||
|
||||
/// [품질 올리기] 의 그림 → 그림 크기: 원본 비율 그대로, 긴 쪽 [maxSide] 까지, 64 의 배수 (엔진이 요구), 작은 쪽 256 이상
|
||||
(int, int) qualitySize(int w, int h, {int maxSide = 1024}) {
|
||||
if (w <= 0 || h <= 0) return (512, 512);
|
||||
|
|
|
|||
|
|
@ -428,6 +428,9 @@ class AppSettings {
|
|||
|
||||
/// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임
|
||||
bool explorerProgressHidden = false;
|
||||
|
||||
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
|
||||
int zipLevel = 6;
|
||||
bool explorerShowHidden = false;
|
||||
|
||||
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
|
||||
|
|
@ -600,6 +603,9 @@ class AppSettings {
|
|||
/// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔
|
||||
bool aiQualityButton = false;
|
||||
|
||||
/// [품질 올리기] 다듬기 정도 (그림 → 그림 바꾸는 정도): 'weak' 0.15 · 'normal' 0.25 (처음 값) · 'strong' 0.4 (관리자 지시 - 판단은 설정으로)
|
||||
String aiQualityStrength = 'normal';
|
||||
|
||||
/// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질
|
||||
String aiUpFormat = 'same';
|
||||
int aiUpJpgQuality = 92;
|
||||
|
|
@ -752,6 +758,7 @@ class AppSettings {
|
|||
'explorerFolderSize': explorerFolderSize,
|
||||
'explorerDropAction': explorerDropAction,
|
||||
'explorerProgressHidden': explorerProgressHidden,
|
||||
'zipLevel': zipLevel,
|
||||
'explorerSortPerFolder': explorerSortPerFolder,
|
||||
'explorerFolderSorts': explorerFolderSorts,
|
||||
'explorerShowHidden': explorerShowHidden,
|
||||
|
|
@ -813,6 +820,7 @@ class AppSettings {
|
|||
'aiUpSecPerMp': aiUpSecPerMp,
|
||||
'aiUpScale': aiUpScale,
|
||||
'aiQualityButton': aiQualityButton,
|
||||
'aiQualityStrength': aiQualityStrength,
|
||||
'aiUpFormat': aiUpFormat,
|
||||
'aiUpJpgQuality': aiUpJpgQuality,
|
||||
'aiUpDir': aiUpDir,
|
||||
|
|
@ -971,6 +979,7 @@ class AppSettings {
|
|||
..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort'
|
||||
..explorerSortPerFolder = j['explorerSortPerFolder'] == true
|
||||
..explorerProgressHidden = j['explorerProgressHidden'] == true
|
||||
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
|
||||
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
|
||||
..explorerFolderSorts = {
|
||||
if (j['explorerFolderSorts'] is Map)
|
||||
|
|
@ -1051,6 +1060,7 @@ class AppSettings {
|
|||
// 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로)
|
||||
..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2
|
||||
..aiQualityButton = j['aiQualityButton'] == true
|
||||
..aiQualityStrength = const ['weak', 'normal', 'strong'].contains(j['aiQualityStrength']) ? j['aiQualityStrength'] as String : 'normal'
|
||||
..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'
|
||||
..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100)
|
||||
..aiUpDir = j['aiUpDir'] as String? ?? ''
|
||||
|
|
|
|||
|
|
@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,6 +273,21 @@ class _AiSettingsState extends State<AiSettings> {
|
|||
'새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')),
|
||||
trailing: Switch(value: s.aiQualityButton, onChanged: (v) => c.updateSettings((x) => x.aiQualityButton = v)),
|
||||
),
|
||||
if (s.aiQualityButton)
|
||||
SettingTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: Text(tr('품질 올리기 다듬기 정도')),
|
||||
subtitle: Text(tr('강할수록 많이 다듬지만 원본과 달라질 수 있습니다')),
|
||||
trailing: DropdownButton<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(
|
||||
leading: const Icon(Icons.image_outlined),
|
||||
title: Text(tr('저장 형식')),
|
||||
|
|
|
|||
|
|
@ -426,7 +426,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
|
|||
|
||||
/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false
|
||||
/// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] -
|
||||
/// AI 그림 (그림 → 그림, 약하게 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율).
|
||||
/// AI 그림 (그림 → 그림, 설정의 다듬기 정도 - 처음 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율).
|
||||
/// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내.
|
||||
Future<String?> qualityUp(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
||||
if (AiJobs.instance.busy) {
|
||||
|
|
@ -510,7 +510,7 @@ Future<String?> qualityUp(BuildContext context, AppController c, {required Strin
|
|||
steps: c.settings.aiSteps,
|
||||
cfg: c.settings.aiCfg,
|
||||
initImage: input,
|
||||
strength: 0.25,
|
||||
strength: qualityStrength(c.settings.aiQualityStrength),
|
||||
),
|
||||
outDir: tmp.path,
|
||||
baseName: 'q',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ import 'explorer_bookmarks.dart';
|
|||
import 'explorer_search.dart';
|
||||
import 'quick_look.dart';
|
||||
import 'explorer_drag.dart';
|
||||
import 'archive_ui.dart';
|
||||
import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf;
|
||||
import 'batch_rename_dialog.dart';
|
||||
import '../core/folder_size.dart';
|
||||
import '../core/batch_rename.dart';
|
||||
|
|
@ -1226,6 +1228,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _play([path]);
|
||||
return;
|
||||
}
|
||||
// 86: 7z · RAR 은 열면 압축 풀기 (ZIP 은 아래의 보기 · ZIP 화면)
|
||||
final kind = archiveKindOf(path);
|
||||
if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return _extractArchive(path, pane ?? _pane);
|
||||
// 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때)
|
||||
if (c.settings.components.contains('viewer')) {
|
||||
final ext = extOf(path);
|
||||
|
|
@ -1329,6 +1334,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
try {
|
||||
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('열기')),
|
||||
// 81: 빠른 보기 (PC 는 Space · TC 는 F3)
|
||||
if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')),
|
||||
// 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두)
|
||||
if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')),
|
||||
if (!dav) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')),
|
||||
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
|
||||
if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
|
||||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||||
|
|
@ -1486,6 +1501,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (!_isMarked(pane, e.path)) await _toggleMark(pane, e);
|
||||
case 'quickLook':
|
||||
await _quickLook(pane, e.path);
|
||||
case 'extract':
|
||||
await _extractArchive(e.path, pane);
|
||||
case 'zip':
|
||||
final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]);
|
||||
final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null;
|
||||
final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other);
|
||||
if (made != null && mounted) {
|
||||
await _refreshAll([vDirname(made)]);
|
||||
pane.focused = made;
|
||||
pane.changed();
|
||||
}
|
||||
case 'folderSize':
|
||||
if (_sizes.busy(e.path)) {
|
||||
_sizes.cancel(e.path);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ List<(List<String>, String)> get _licenseAssets => [
|
|||
(['libmpv (media_kit, LGPL-2.1+)'], 'assets/licenses/LGPL-2.1.txt'),
|
||||
([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'),
|
||||
(['rsync'], 'assets/licenses/RSYNC_COPYING.txt'),
|
||||
// 86: 7-Zip 25.01 (7z · RAR 풀기 - RAR 은 풀기만, unRAR 라이선스 제한). LGPL 2.1+ · BSD 3-clause · BSD 2-clause
|
||||
(['7-Zip 25.01 (7z.exe · 7z.dll)'], 'assets/licenses/7ZIP_LICENSE.txt'),
|
||||
(['7-Zip 25.01 (GNU LGPL 2.1+)'], 'assets/licenses/LGPL-2.1.txt'),
|
||||
(['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'),
|
||||
// Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
|
||||
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../core/file_ops.dart' show formatSize;
|
|||
import '../core/reader_sources.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'archive_ui.dart';
|
||||
import 'reader_page.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
|
|
@ -62,62 +63,10 @@ class _ZipPageState extends State<ZipPage> {
|
|||
|
||||
bool get _hasImages => _entries?.any((f) => c.settings.imageExts.contains(extOf(f.name))) ?? false;
|
||||
|
||||
/// 86: 풀기는 압축 풀기 흐름 (목록 검사 - 밖으로 나가는 경로 · 링크 · 크기 · 비밀번호, 같은 이름 · 진행 · 취소) 으로
|
||||
Future<void> _extract({required bool all}) async {
|
||||
final zipDir = vDirname(widget.path);
|
||||
final base = vBasename(widget.path).replaceAll(RegExp(r'\.[^.]+$'), '');
|
||||
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]))));
|
||||
}
|
||||
final dest = await extractArchiveFlow(context, c, widget.path, otherDir: widget.otherDir, names: all ? const [] : _marked.toList());
|
||||
if (dest != null && mounted) Navigator.pop(context, dest);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ void main() {
|
|||
expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2);
|
||||
expect(AppSettings().aiQualityButton, isFalse);
|
||||
expect(AppSettings.fromJson((AppSettings()..aiQualityButton = true).toJson()).aiQualityButton, isTrue);
|
||||
// 다듬기 정도: 약하게 0.15 · 보통 0.25 (처음) · 강하게 0.4, 모르는 값은 보통
|
||||
expect(AppSettings().aiQualityStrength, 'normal');
|
||||
expect([for (final s in ['weak', 'normal', 'strong']) qualityStrength(s)], [0.15, 0.25, 0.4]);
|
||||
expect(AppSettings.fromJson({'aiQualityStrength': 'x'}).aiQualityStrength, 'normal');
|
||||
expect(AppSettings.fromJson((AppSettings()..aiQualityStrength = 'strong').toJson()).aiQualityStrength, 'strong');
|
||||
});
|
||||
|
||||
test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -79,6 +79,25 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
test('86: 7-Zip - 라이선스 화면에 원문 (LGPL · BSD · unRAR 제한) · 고지 문서에 판 · 소스 · RAR 은 풀기만 · 설치 폴더에 원문', () async {
|
||||
registerAppLicenses();
|
||||
final entries = await LicenseRegistry.licenses.toList();
|
||||
final t = entries.firstWhere((e) => e.packages.any((p) => p.startsWith('7-Zip 25.01 (7z.exe'))).paragraphs.map((p) => p.text).join('\n');
|
||||
for (final s in ['Igor Pavlov', 'GNU LGPL', 'unRAR license restriction', 'BSD 3-clause License']) {
|
||||
expect(t, contains(s), reason: s);
|
||||
}
|
||||
final lgpl = entries.firstWhere((e) => e.packages.any((p) => p.startsWith('7-Zip 25.01 (GNU LGPL'))).paragraphs.map((p) => p.text).join('\n');
|
||||
expect(lgpl, contains('LESSER GENERAL PUBLIC LICENSE'));
|
||||
final notices = File('assets/licenses/THIRD_PARTY_NOTICES.txt').readAsStringSync();
|
||||
for (final s in ['7-Zip 25.01', '7z2501-src.7z', 'RAR 을 만들지 않습니다', '7ZIP_LICENSE.txt']) {
|
||||
expect(notices, contains(s), reason: s);
|
||||
}
|
||||
final cmake = File('windows/CMakeLists.txt').readAsStringSync();
|
||||
expect(cmake, contains('third_party/7zip/windows'));
|
||||
expect(cmake, contains('License.txt'));
|
||||
expect(File('tool/fetch_7zip.ps1').readAsStringSync(), contains('e7eb0b7ed5efa4e087b7b17f191797f7af5b7f442d1290c66f3a21777005ef57'));
|
||||
});
|
||||
|
||||
test('Windows 설치: 실행 파일 옆 · tools · rsync 에 원문을 둔다 (CMake)', () {
|
||||
final cmake = File('windows/CMakeLists.txt').readAsStringSync();
|
||||
expect(cmake, contains('assets/licenses'));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -143,6 +143,16 @@ if(JJ_TOOL_FILES)
|
|||
DESTINATION "${CMAKE_INSTALL_PREFIX}/tools" COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
# JJ_MKVMaker: 86 7-Zip 동봉 (7z · RAR 풀기). 실행 파일 옆 7zip\ 폴더로 복사 (License.txt 원문 함께).
|
||||
# 파일은 tool\fetch_7zip.ps1 로 third_party\7zip\windows 에 준비한다 (7-Zip 25.01, SHA-256 고정).
|
||||
set(JJ_7ZIP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/7zip/windows")
|
||||
file(GLOB JJ_7ZIP_FILES "${JJ_7ZIP_DIR}/7z.exe" "${JJ_7ZIP_DIR}/7z.dll" "${JJ_7ZIP_DIR}/License.txt")
|
||||
if(JJ_7ZIP_FILES)
|
||||
install(FILES ${JJ_7ZIP_FILES}
|
||||
DESTINATION "${CMAKE_INSTALL_PREFIX}/7zip" COMPONENT Runtime)
|
||||
endif()
|
||||
install(FILES "${JJ_LICENSES}/LGPL-2.1.txt" DESTINATION "${CMAKE_INSTALL_PREFIX}/7zip" COMPONENT Runtime OPTIONAL)
|
||||
|
||||
# JJ_MKVMaker: rsync 동봉 (내려받기 없이 바로). 실행 파일 옆 rsync\ 폴더로 복사.
|
||||
# 파일은 tool\fetch_rsync.ps1 로 third_party\rsync\windows 에 준비한다.
|
||||
set(JJ_RSYNC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rsync/windows")
|
||||
|
|
|
|||
Loading…
Reference in New Issue