diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 503c831..e9fee0c 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -73,6 +73,7 @@ "1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image", "10초 뒤로 (←)": "Back 10 seconds (←)", "10초 앞으로 (→)": "Forward 10 seconds (→)", + "16진 보기 · {0}": "Hex view · {0}", "1배 (선명하게)": "1× (sharpen)", "1분 안": "under 1 min", "2/2 · 해상도 올리는 중": "2/2 · Upscaling", @@ -368,6 +369,12 @@ "Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "Available on Windows only. On this device, documents open in another app.", "Windows 오류 0x{0}": "Windows error 0x{0}", "Windows 탐색기": "Windows Explorer", + "X-plore 스타일: 옆으로 밀면 창 바꾸기": "X-plore style: swipe sideways to switch panes", + "X-plore 스타일: 표시 동그라미 늘 보이기": "X-plore style: always show the mark circles", + "X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)": "In the X-plore style they appear in one tree like X-plore (the tabs and ★ at the top stay)", + "X-plore 식 (1 · 2 · 3번)": "X-plore way (1st · 2nd · 3rd)", + "X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기": "X-plore way: 1st = the folder, 2nd = all files inside, 3rd = clear", + "X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)": "Like X-plore. When off, folders collapse and expand only with the › arrow (the list doesn't move on a double tap)", "YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)", "YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube is asking for a robot check. In Settings > Downloads > YouTube cookies, choose a browser (Firefox recommended) or a cookies.txt, then download again.", @@ -391,6 +398,7 @@ "ZIP 이 있는 폴더로": "To the ZIP's folder", "ZIP 이름": "ZIP name", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]", + "[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · [D] date · [T] time · extension kept e.g. Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · extension kept 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].", @@ -450,9 +458,11 @@ "{0} (기본): {1}": "{0} (default): {1}", "{0} (동영상 {1})": "{0} (videos: {1})", "{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)", + "{0} / {1} 읽음": "{0} / {1} read", "{0} / {1}장": "{0} / {1} images", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} · {1}배 · {2}": "{0} · {1}× · {2}", + "{0} · 그림 · 동영상 {1}개": "{0} · {1} pictures / videos", "{0} · 링크 {1}개 제외": "{0} · {1} links excluded", "{0} · 파일 {1}개": "{0} · {1} files", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", @@ -508,6 +518,7 @@ "{0}개 · {1}": "{0} · {1}", "{0}개 · {1} …": "{0} items · {1} …", "{0}개 · 주소 {1}개": "{0} · {1} links", + "{0}개 고름": "{0} selected", "{0}개 더 보기": "Show {0} more", "{0}개 맞춤": "{0} synced", "{0}개 보임": "{0} shown", @@ -537,12 +548,15 @@ "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} didn't fit in the Recycle Bin and were deleted permanently (as chosen when Windows asked).", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".", "{0}개를 묶었습니다: {1}": "Compressed {0} files: {1}", + "{0}개를 영구 삭제할까요?": "Delete {0} items permanently?", "{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}개를 옮겼습니다.": "Moved {0} items.", + "{0}개를 원래 자리로 되살렸습니다": "Restored {0} items to their original location", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.", "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", + "{0}개를 휴지통으로 옮기지 못했습니다": "Couldn't move {0} items to the trash", "{0}개에서 멈췄습니다": "Stopped at {0}", "{0}개의 이름을 바꿨습니다.": "Renamed {0} items.", "{0}배": "{0}×", @@ -550,6 +564,7 @@ "{0}분": "{0} min", "{0}분 {1}초": "{0} min {1} s", "{0}시간 {1}분": "{0} h {1} min", + "{0}일": "{0} days", "{0}일 안": "within {0} days", "{0}일 지남": "older than {0} days", "{0}장 모두 해상도 올리기": "Upscale all {0} pages", @@ -603,6 +618,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {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", + "같습니다 ✔": "Match ✔", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}", @@ -647,6 +663,7 @@ "검색 지우기": "Clear search", "검정": "Black", "게스트": "Guest", + "격자로 보기 (그림 · 동영상)": "Grid view (pictures / videos)", "결과 폴더": "Output folder", "결과 폴더 열기": "Open output folder", "결과 폴더 열기 (동영상을 고르세요)": "Open output folder (choose a video)", @@ -736,8 +753,11 @@ "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "{0} of them are newer on the receiving side - the newer files will be overwritten with older ones", "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "Including {0} folders - everything inside them too", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)", + "글 뷰어로 보기": "View as text", "글을 격자에 반영": "Apply text to grid", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", + "글자 작게": "Smaller text", + "글자 크게": "Larger text", "금": "Fri", "기계 번역": "Machine translation", "기기": "device", @@ -764,18 +784,21 @@ "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.", "기타 즐겨찾기": "Other bookmarks", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several", + "길게 누르면 {0}": "long-press: {0}", "길이": "Length", "길이 {0}": "Length {0}", "끄기": "Off", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "When off (default), they are cleared when you leave the Tor screen (another screen, or the app goes to the background). They are always kept separate from the normal browser.", "끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).": "Off is safer, but many sites will not display (from the next time it opens).", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "When off, starts right away with the last settings: source {0}, languages {1}, {2}", + "끄면 선택 모드에서만 보입니다 (다른 스타일은 늘 선택 모드에서만)": "When off, they show only in selection mode (other styles always show them only in selection mode)", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "When off, the web page keeps playing sound while you look at settings, the download list or other screens", "끌어 놓기 기본": "Drag and drop default", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", "끌어서 다른 창으로": "Drag to the other pane", + "끔": "Off", "끝": "End", "끝 (오른쪽 · 아래)": "Edge (right · bottom)", "끝 = 현재": "End = now", @@ -789,10 +812,13 @@ "끝내기 (작업 멈춤)": "Exit (stop tasks)", "끝에서 처음으로 돌기": "Wrap around at the end", "나가기": "Exit", + "나머지 폴더 {0}개": "{0} other folders", "나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn", "나머지는 지웠습니다.": "The rest were deleted.", "나중에": "Later", "날짜": "Date", + "날짜: 바뀐 때": "Date: modified", + "날짜: 찍은 때 (사진, 없으면 바뀐 때)": "Date: taken (photos; otherwise modified)", "남는 것": "What stays", "남은 {0}장": "{0} left", "남은 것 모두 취소 ({0}개)": "Cancel all remaining ({0})", @@ -879,12 +905,14 @@ "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 복사": "Copy to other pane", "다른 창으로 이동": "Move to other pane", + "다른 폴더": "Other folder", "다른 폴더로 이동": "Move to another folder", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "Another program may be using them, or you may lack permission. The rest were deleted.", "다른 프로그램이 쓰고 있습니다": "Another program is using it", "다른 항목과 같은 이름": "Same name as another item", "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "When you go to another screen or the app goes to the background, the Tor screen's pages and connections are cleared right away. Tor itself keeps running for this long so you can use it again immediately.", "다른 화면으로 가면 페이지 소리 멈춤": "Pause page sound when leaving the browser", + "다릅니다 - 파일이 바뀌었거나 다른 파일입니다": "Different - the file has changed or is a different file", "다시 만들기 대기로": "Back to waiting for remake", "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "Queue again to rebuild (after the current batch finishes)", "다시 받기": "Download again", @@ -934,6 +962,7 @@ "단축키 해제": "Unregister hotkey", "닫기": "Close", "닫힘 (실행 안 함)": "Closed (not run)", + "담아 두고 붙여넣기 (기본)": "Hold and paste (default)", "답이 없습니다": "No answer", "대기": "Waiting", "대기 {0}개:": "{0} waiting:", @@ -957,6 +986,7 @@ "대소문자 그대로": "Keep case", "대소문자 무시": "Ignore case", "더 보기": "More", + "더 읽기": "Read more", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", "덮어쓰기": "Overwrite", @@ -1013,6 +1043,8 @@ "되돌릴 수 없습니다.": "This can't be undone.", "되살리고 다시 시작": "Restore and restart", "되살리기": "Restore", + "되살리지 못함: {0}": "Couldn't restore: {0}", + "되살림": "restored", "두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match", "두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)", "두 쪽 나눠 보기": "Split two-page spreads", @@ -1047,6 +1079,7 @@ "링크 {0}개는 넣지 않음": "{0} links not included", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} links will be skipped (not created): {1}", "링크 {0}개는 세지 않음": "{0} links not counted", + "링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} links / junctions (not followed or counted)", "링크 · junction 은 넣지 않습니다": "Links and junctions are not included", "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", @@ -1180,6 +1213,7 @@ "바뀐 날": "Modified", "바뀐 날: 아무 때": "Modified: any time", "바뀐 내용": "Changes", + "바뀐 때: {0}": "Modified: {0}", "바뀐 지 … 일 안": "Modified within … days", "바뀐 지 … 일 지남": "Modified more than … days ago", "바로 끔": "Stop right away", @@ -1293,6 +1327,8 @@ "복사 · 이동 {0}개": "{0} copy · move", "복사 · 이동 {0}개 · {1}%": "Copy · move: {0} · {1}%", "복사 · 이동 {0}개가 아직 끝나지 않았습니다. 끝내면 멈추고, 복사하던 파일은 완성되지 않은 채로 남지 않게 지웁니다.": "{0} copy/move tasks have not finished. Exiting stops them, and the file being copied is removed so no incomplete file is left.", + "복사 · 이동 갈 곳": "Copy / move destination", + "복사 · 이동 갈 곳: {0}": "Copy / move to: {0}", "복사 · 이동 끝": "Copy · move finished", "복사 · 이동 멈춤 - 눌러서 보기": "Copy · move stopped - tap to see", "복사 · 이동 중입니다": "Copying · moving in progress", @@ -1330,13 +1366,16 @@ "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "Could not read the browser cookies. In Settings > Downloads > YouTube cookies, choose \"In-app browser\", or check that the chosen browser is installed (Chrome · Edge must be closed to be read).", "비공개 · 회원 전용 영상입니다.": "This is a private · members-only video.", "비교하지 못했습니다: {0}": "Couldn't compare: {0}", + "비교할 값 붙여넣기 (받은 곳의 해시)": "Paste a value to compare (hash from the source)", "비밀번호": "Password", "비밀번호 (개인 키만 쓰면 비움)": "Password (leave empty if using only a private key)", "비밀번호 (선택)": "Password (optional)", "비밀번호 · 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)", + "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", + "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", "비밀번호가 틀렸습니다": "Wrong password", "비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)", @@ -1347,7 +1386,9 @@ "비어 있는 압축 파일입니다.": "The archive is empty.", "비어 있는 폴더": "Empty folder", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", + "비어 있습니다": "Empty", "비어 있음": "Empty", + "비우기": "Empty", "비우면 지움": "Empty = remove", "빈 이름": "Empty name", "빈 폴더 {0}개 지움": "Deleted {0} empty folders", @@ -1487,6 +1528,8 @@ "숨긴 항목 (. 으로 시작) 보이기": "Show hidden items (starting with .)", "숨긴 항목 숨기기": "Hide hidden items", "숨김": "Hidden", + "숨은 다른 창으로": "To the hidden other pane", + "숨은 다른 창으로: X-plore 처럼 위에 \"갈 곳\" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다": "To the hidden other pane: like X-plore, the destination is always shown at the top and items are copied or moved straight there", "숨은 항목 표시": "Show hidden items", "숨은 항목을 감춥니다.": "Hidden items are hidden.", "숨은 항목을 보입니다.": "Hidden items are shown.", @@ -1581,12 +1624,14 @@ "안쪽에 받는 쪽이 더 새 파일 {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)", + "압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "Inside an archive is read-only (you can only extract)", "압축 옆 새 폴더 \"{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 extract from the archive: {0}", "압축을 읽지 못했습니다: {0}": "Couldn't read the archive: {0}", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", "압축이 있는 폴더": "Archive's folder", @@ -1608,6 +1653,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)", "앱 지우기": "Uninstall app", + "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).", "앱에 들어 있는 것 (기본)": "Built into the app (default)", "앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "Built into the app (Tor {0}, tor {1}). Turning it on starts nothing until the Tor screen is opened.", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "After reinstalling the app, Android Auto Backup (Google backup) restored your previous settings.\n· {0} videos in the list\n· WebDAV servers: {1}", @@ -1661,8 +1707,10 @@ "열 수 없습니다: {0}": "Cannot open: {0}", "열기": "Open", "열기 · 재생": "Open · Play", + "열어 본 때: {0}": "Accessed: {0}", "영구 삭제": "Delete permanently", "영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)", + "영구 삭제하면 되살릴 수 없습니다.": "Permanently deleted items can't be restored.", "영상": "Video", "영상 {0}": "Video {0}", "영상 · 음성 합치는 중": "Merging video · audio", @@ -1838,6 +1886,7 @@ "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "About {0} per image on this device (based on last time)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "About {0} per image, {1} in total on this device (based on last time)", + "이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "7z / RAR contents can't be browsed on this device (open it with another app)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", "이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)": "SMB can't be opened inside the app on this device (on Windows, open the \\\\server\\share path)", @@ -1893,8 +1942,10 @@ "이 폴더에 새 폴더": "New folder in this folder", "이 폴더에 현재 페이지 추가": "Add the current page to this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", + "이 폴더에는 그림 · 동영상이 없습니다": "No pictures or videos in this folder", "이 폴더에만: {0}": "This folder only: {0}", "이 폴더의 동영상 재생": "Play videos in this folder", + "이 폴더의 파일": "Files in this folder", "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "This app is running as administrator. This is not recommended.\n\n· The built-in Chrome engine has its sandbox (the protection that confines web pages) turned off, so web pages run inside an administrator process. A flaw in a page could become administrator access to this PC.\n· The Edge engine is not affected.\n· If you run the app without administrator rights, you can use the Chrome engine without this setting.", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "No app opens this format. Long-press or right-click → [Play in built-in player] to try it.", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "This screen sends the in-app web view's connections through Tor; it is not the official Tor Browser.\n\n· All connections (including name lookups) go out through Tor, and cookies, history and cache are kept separate from the normal browser and cleared when closed (can be kept in Settings).\n· However, protection against browser fingerprinting (screen size, fonts, device information, etc.) is weaker than in the official Tor Browser. Sites may recognize this device.\n· If you need strong anonymity, use the official Tor Browser (https://www.torproject.org).", @@ -1983,6 +2034,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "The original file that could not be read was not deleted; it is kept here:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} unreadable folders (permission or connection problem) - not searched inside", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} folders couldn't be read (sizes may be smaller than actual)", + "읽지 못했습니다: {0}": "Couldn't read: {0}", "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 (가로로 긴 그림만)": "Auto (wide images only)", @@ -2078,6 +2130,8 @@ "저장 실패: {0}": "Save failed: {0}", "저장 안 함": "Don't save", "저장 위치": "Save locations", + "저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "Storage, servers and favorites at the top of the tree", + "저장 장치를 알 수 없습니다": "Unknown storage", "저장 폴더": "Save folder", "저장 형식": "Save format", "저장: {0}": "Save: {0}", @@ -2109,6 +2163,7 @@ "전체 {0}개 중 {1}번째": "Item {1} of {0}", "전체 만들기": "Make all", "전체 목록": "Full list", + "전체 복사": "Copy all", "전체 선택": "Select all", "전체 이동": "Move all", "전체 화면": "Full screen", @@ -2170,6 +2225,7 @@ "준비 중": "Preparing", "준비 중…": "Preparing…", "준비하는 중": "Preparing", + "줄 번호": "Line numbers", "줄 삭제": "Delete line", "중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.": "This could be an interception attack, so the connection was not made. Only if you're sure the server was reinstalled or its key was changed, use [Forget trusted key] in the server settings and connect again to check the new key.", "중국어": "Chinese", @@ -2216,6 +2272,7 @@ "지금 짝을 즐겨찾기에": "Add the current pair to favorites", "지금 폴더": "Current folder", "지금 폴더 추가": "Add current folder", + "지금 폴더로": "Use this folder", "지금 폴더를 즐겨찾기에": "Add the current folder to favorites", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)", "지금 확인": "Check now", @@ -2238,6 +2295,8 @@ "지우지 못했습니다: {0}": "Could not delete: {0}", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "Synced without deleting · confirm whether to delete {0} items only in the target", "지우지 않기 (지우기 끄기)": "Don't delete (turn off delete)", + "지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "Deleted items are kept here for {0} days and then removed automatically (Settings > File explorer > Keep in trash).", + "지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "Deleted items go to the app trash on that storage and are removed after this many days. Off = delete permanently right away", "지울 목록 보고 결정": "Review the list and decide", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})", @@ -2263,6 +2322,7 @@ "차례 기다림": "Waiting in queue", "창": "Panes", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)", + "창 바꿈: {0}": "Switched pane: {0}", "창 배치": "Pane layout", "창 배치 · 버튼 구성": "Pane layout · buttons", "창 배치: 위아래 (누르면 좌우)": "Pane layout: top and bottom (tap for side by side)", @@ -2336,6 +2396,7 @@ "취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "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.", + "칸 그림": "Treemap", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "Options in the field: {0}\nSaved options: {1}\n\nSave the edited options and run?", "컴포넌트": "Components", "컴포넌트 목록을 받을 수 없습니다 ({0})": "Could not get the component list ({0})", @@ -2355,6 +2416,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "With size, date or type conditions, only files are found.", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".", "크기 이상": "Min size", + "크기: {0} ({1} 바이트)": "Size: {0} ({1} bytes)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Sharpens without changing the size and saves new files. The originals and ZIP stay unchanged. It continues after you close the screen; the task notification shows remaining pages and estimated time.", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)", @@ -2368,6 +2430,7 @@ "타일 {0} · 약 {1}GB": "tile {0} · about {1}GB", "타일이 작을수록 느립니다 (예: 16GB 폰에서 1024×768 을 2배로 - 512 약 4분 · 320 약 9분 30초).": "Smaller tiles are slower (e.g. doubling 1024×768 on a 16GB phone: 512 about 4 min · 320 about 9 min 30 s).", "탐색기": "Explorer", + "탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다": "Swiping inside the explorer switches to the other pane. To go to another screen, swipe on the top bar", "탐색기 오른쪽 클릭 메뉴": "Explorer right-click menu", "탐색기에서 동영상을 열 때": "When opening a video from Explorer", "탐색기에서 연 동영상을 재생할 창": "Window to play videos opened from Explorer", @@ -2385,6 +2448,7 @@ "파일 {0}/{1}": "Files {0}/{1}", "파일 {0}개": "{0} files", "파일 {0}개 · {1}": "{0} files · {1}", + "파일 {0}개 · 폴더 {1}개": "{0} files · {1} folders", "파일 관리자에서 보기": "Show in file manager", "파일 복사 · 이동 방법": "File copy · move method", "파일 세는 중…": "Counting files…", @@ -2427,6 +2491,7 @@ "편집 목록에 있음": "In the edit list", "편집 목록에 추가": "Add to the edit list", "편집 목록에 추가 (MKV 만들기 대상)": "Add to the edit list (Make MKV target)", + "펼친 폴더를 다시 누르면 접기": "Tap an open folder again to collapse", "평일 9~18시": "Weekdays 9-18", "포르투갈어": "Portuguese", "포트": "Port", @@ -2443,6 +2508,7 @@ "폴더 복사 · 이동 방법": "Folder copy · move method", "폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)", "폴더 선택": "Choose folder", + "폴더 속성 · {0}": "Folder properties · {0}", "폴더 수정": "Edit folder", "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "Shows the total size of the files inside each folder. Large folders or a NAS can take a while to read. Long-press [Calculate folder size] always works", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "Syncs the folder contents (no same-named folder is created in the target).", @@ -2468,7 +2534,9 @@ "폴더를 자기 안으로 복사할 수 없습니다": "Cannot copy a folder into itself", "폴더를 자기 안으로 옮길 수 없습니다": "Cannot move a folder into itself", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "Cannot {0} a folder into itself: {1}", + "폴더를 표시하면": "When marking a folder", "폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.", + "폴더와 안의 것 전체 (기본)": "The folder and everything inside (default)", "폴란드어": "Polish", "표시 (여러 개 고르기)": "Mark (select several)", "표시 이름": "Display name", @@ -2522,6 +2590,7 @@ "한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)": "Pages through images with two pages side by side one half at a time (starting from the reading direction)", "한 쪽 맞추기": "Fit page", "한 창": "One pane", + "한 창일 때 복사 · 이동": "Copy / move with one pane", "한국어": "Korean", "한글": "Hangul", "할 일": "Task", @@ -2534,6 +2603,7 @@ "해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?": "An upscaling model is needed. Which one should be downloaded?", "해상도를 올리지 못했습니다": "Couldn't upscale", "해상도를 올린 그림 저장 폴더": "Folder for upscaled images", + "해시 · {0}": "Hash · {0}", "해제": "Unset", "허용": "Allow", "허용했으면 다시 확인": "Check again after allowing", @@ -2593,10 +2663,20 @@ "회전 없음": "No rotation", "회차": "Episode", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "Phones and tablets often can't decode AV1/VP9 in hardware, causing stutter or heavy battery use. YouTube's H.264 goes up to 1080p, so for higher qualities size comes first.", + "휴지통": "Trash", + "휴지통 · {0}개 · {1}": "Trash · {0} items · {1}", + "휴지통 보관": "Keep in trash", + "휴지통 비우기": "Empty trash", + "휴지통에서 지운 것은 되살릴 수 없습니다.": "Items deleted from the trash can't be restored.", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)", "휴지통으로": "To Recycle Bin", "휴지통으로 보내기": "Move to Recycle Bin", + "휴지통으로 옮기지 못했습니다: {0} ({1})": "Couldn't move to the trash: {0} ({1})", + "휴지통을 거치지 않고 영구 삭제": "Delete permanently without the trash", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "Delete permanently without the Recycle Bin (Shift+Delete)", + "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off (Settings > File explorer > Keep in trash).", + "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off - deleting removes items permanently right away (Settings > File explorer > Keep in trash).", + "휴지통이 비어 있습니다": "The trash is empty", "휴지통이 없는 곳입니다": "This location has no Recycle Bin", "휴지통이 없습니다": "No Recycle Bin", "히브리어": "Hebrew", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 6698091..9670c1e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -73,6 +73,7 @@ "1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中", "10초 뒤로 (←)": "10秒戻る(←)", "10초 앞으로 (→)": "10秒進む(→)", + "16진 보기 · {0}": "16進表示 · {0}", "1배 (선명하게)": "1倍 (くっきり)", "1분 안": "1 分以内", "2/2 · 해상도 올리는 중": "2/2 · 解像度を上げています", @@ -368,6 +369,12 @@ "Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "Windows でのみ使えます。この端末では文書を他のアプリで開きます。", "Windows 오류 0x{0}": "Windows エラー 0x{0}", "Windows 탐색기": "Windows エクスプローラー", + "X-plore 스타일: 옆으로 밀면 창 바꾸기": "X-plore スタイル: 横にスワイプでペイン切り替え", + "X-plore 스타일: 표시 동그라미 늘 보이기": "X-plore スタイル: 選択の丸を常に表示", + "X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)": "X-plore スタイルで X-plore のように 1 つのツリーに表示します (上部のタブ・★ はそのまま)", + "X-plore 식 (1 · 2 · 3번)": "X-plore 式 (1・2・3 回目)", + "X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기": "X-plore 式: 1 回目 = フォルダー、2 回目 = 中のファイル全部、3 回目 = 解除", + "X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)": "X-plore のように。オフのときは › 矢印でのみ開閉します (2 回押しても一覧は動きません)", "YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)", "YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube がロボット確認を求めています。環境設定 > ダウンロード > YouTube クッキー でブラウザー(Firefox 推奨)または cookies.txt を指定してから、もう一度ダウンロードしてください。", @@ -391,6 +398,7 @@ "ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ", "ZIP 이름": "ZIP の名前", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]", + "[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前・[C] 番号・[D] 日付・[T] 時刻・拡張子はそのまま 例: Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 · 拡張子はそのまま 例: Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。", @@ -450,9 +458,11 @@ "{0} (기본): {1}": "{0}(既定): {1}", "{0} (동영상 {1})": "{0} (動画 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)", + "{0} / {1} 읽음": "{0} / {1} 読み込み済み", "{0} / {1}장": "{0} / {1} 枚", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", + "{0} · 그림 · 동영상 {1}개": "{0} · 画像・動画 {1} 個", "{0} · 링크 {1}개 제외": "{0} · リンク {1} 件を除く", "{0} · 파일 {1}개": "{0} · ファイル {1} 件", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", @@ -508,6 +518,7 @@ "{0}개 · {1}": "{0}個・{1}", "{0}개 · {1} …": "{0} 件 · {1} …", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", + "{0}개 고름": "{0} 件選択", "{0}개 더 보기": "さらに {0} 件表示", "{0}개 맞춤": "{0} 件を同期", "{0}개 보임": "{0} 件表示", @@ -537,12 +548,15 @@ "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}", + "{0}개를 영구 삭제할까요?": "{0} 件を完全に削除しますか?", "{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}개를 옮겼습니다.": "{0} 件を移動しました。", + "{0}개를 원래 자리로 되살렸습니다": "{0} 件を元の場所に戻しました", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。", "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", + "{0}개를 휴지통으로 옮기지 못했습니다": "{0} 件をゴミ箱に移せませんでした", "{0}개에서 멈췄습니다": "{0} 件で停止しました", "{0}개의 이름을 바꿨습니다.": "{0} 件の名前を変更しました。", "{0}배": "{0} 倍", @@ -550,6 +564,7 @@ "{0}분": "{0} 分", "{0}분 {1}초": "{0}分 {1}秒", "{0}시간 {1}분": "{0}時間 {1}分", + "{0}일": "{0} 日", "{0}일 안": "{0} 日以内", "{0}일 지남": "{0} 日以上前", "{0}장 모두 해상도 올리기": "{0} ページすべてを高解像度化", @@ -603,6 +618,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "강하게 (0.4)": "強め (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", + "같습니다 ✔": "一致します ✔", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", @@ -647,6 +663,7 @@ "검색 지우기": "検索をクリア", "검정": "黒", "게스트": "ゲスト", + "격자로 보기 (그림 · 동영상)": "グリッド表示 (画像・動画)", "결과 폴더": "出力フォルダー", "결과 폴더 열기": "出力フォルダーを開く", "결과 폴더 열기 (동영상을 고르세요)": "出力フォルダーを開く(動画を選んでください)", @@ -736,8 +753,11 @@ "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします", "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "うちフォルダー {0} 件 - 中のファイル・フォルダーもすべて一緒に", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)", + "글 뷰어로 보기": "テキストビューアで表示", "글을 격자에 반영": "テキストをグリッドに反映", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", + "글자 작게": "文字を小さく", + "글자 크게": "文字を大きく", "금": "金", "기계 번역": "機械翻訳", "기기": "端末", @@ -764,18 +784,21 @@ "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。", "기타 즐겨찾기": "その他のブックマーク", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択", + "길게 누르면 {0}": "長押しで{0}", "길이": "長さ", "길이 {0}": "長さ {0}", "끄기": "オフ", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "オフ (既定) の場合は Tor 画面を離れるとき (別の画面 · アプリがバックグラウンドへ) に消去します。通常のブラウザーとは常に別です。", "끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).": "オフの方が安全ですが、多くのサイトが表示されません (次に開くときから)。", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "オフにすると前回の設定ですぐ開始: 原語 {0}、言語 {1}、{2}", + "끄면 선택 모드에서만 보입니다 (다른 스타일은 늘 선택 모드에서만)": "オフにすると選択モードのときだけ表示します (他のスタイルは常に選択モードのときだけ)", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "オフにすると、環境設定・ダウンロード一覧・他の画面を見ている間も Web ページの音が流れ続けます", "끌어 놓기 기본": "ドラッグ&ドロップの既定", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", "끌어서 다른 창으로": "ドラッグしてもう一方のウィンドウへ", + "끔": "オフ", "끝": "終了", "끝 (오른쪽 · 아래)": "端 (右 · 下)", "끝 = 현재": "終了 = 現在", @@ -789,10 +812,13 @@ "끝내기 (작업 멈춤)": "終了 (作業を止める)", "끝에서 처음으로 돌기": "最後から最初に戻る", "나가기": "終了する", + "나머지 폴더 {0}개": "その他のフォルダー {0} 個", "나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします", "나머지는 지웠습니다.": "残りは削除しました。", "나중에": "後で", "날짜": "日付", + "날짜: 바뀐 때": "日付: 更新日時", + "날짜: 찍은 때 (사진, 없으면 바뀐 때)": "日付: 撮影日時 (写真、なければ更新日時)", "남는 것": "残るもの", "남은 {0}장": "残り {0} 枚", "남은 것 모두 취소 ({0}개)": "残りをすべて取り消し ({0} 件)", @@ -879,12 +905,14 @@ "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 이동": "もう一方のペインへ移動", + "다른 폴더": "別のフォルダー", "다른 폴더로 이동": "他のフォルダーに移動", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "他のプログラムが使用中か、権限がない可能性があります。残りは削除しました。", "다른 프로그램이 쓰고 있습니다": "他のプログラムが使用中です", "다른 항목과 같은 이름": "他の項目と同じ名前", "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "別の画面に移るかアプリがバックグラウンドに回ると、Tor 画面のページ · 接続はすぐに片付けます。Tor 自体はこの時間だけ動かしておき、戻ったらすぐ使えます。", "다른 화면으로 가면 페이지 소리 멈춤": "他の画面に移るとページの音を止める", + "다릅니다 - 파일이 바뀌었거나 다른 파일입니다": "異なります - ファイルが変更されたか別のファイルです", "다시 만들기 대기로": "作り直し待ちに戻す", "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "再作成待ちに戻す (今のまとめ作業が終わった後)", "다시 받기": "再ダウンロード", @@ -934,6 +962,7 @@ "단축키 해제": "ショートカット解除", "닫기": "閉じる", "닫힘 (실행 안 함)": "閉じた (実行しない)", + "담아 두고 붙여넣기 (기본)": "保持して貼り付け (既定)", "답이 없습니다": "応答がありません", "대기": "待機", "대기 {0}개:": "待機 {0}個:", @@ -957,6 +986,7 @@ "대소문자 그대로": "大文字・小文字はそのまま", "대소문자 무시": "大文字・小文字を区別しない", "더 보기": "その他", + "더 읽기": "続きを読む", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", "덮어쓰기": "上書き", @@ -1013,6 +1043,8 @@ "되돌릴 수 없습니다.": "元に戻せません。", "되살리고 다시 시작": "復元して再起動", "되살리기": "元に戻す", + "되살리지 못함: {0}": "元に戻せませんでした: {0}", + "되살림": "復元", "두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います", "두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)", "두 쪽 나눠 보기": "見開きを分けて表示", @@ -1047,6 +1079,7 @@ "링크 {0}개는 넣지 않음": "リンク {0} 件は含めず", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}", "링크 {0}개는 세지 않음": "リンク {0} 件は数えず", + "링크 · junction {0}개 (따라가지 않고 세지 않음)": "リンク・ジャンクション {0} 個 (たどらず数えません)", "링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", @@ -1180,6 +1213,7 @@ "바뀐 날": "更新日時", "바뀐 날: 아무 때": "更新日: いつでも", "바뀐 내용": "変更内容", + "바뀐 때: {0}": "更新日時: {0}", "바뀐 지 … 일 안": "更新から … 日以内", "바뀐 지 … 일 지남": "更新から … 日以上", "바로 끔": "すぐ終了", @@ -1293,6 +1327,8 @@ "복사 · 이동 {0}개": "コピー・移動 {0} 件", "복사 · 이동 {0}개 · {1}%": "コピー・移動 {0} 件 · {1}%", "복사 · 이동 {0}개가 아직 끝나지 않았습니다. 끝내면 멈추고, 복사하던 파일은 완성되지 않은 채로 남지 않게 지웁니다.": "コピー・移動 {0} 件がまだ終わっていません。終了すると止まり、コピー中のファイルは未完成のまま残らないよう削除します。", + "복사 · 이동 갈 곳": "コピー・移動先", + "복사 · 이동 갈 곳: {0}": "コピー・移動先: {0}", "복사 · 이동 끝": "コピー・移動 完了", "복사 · 이동 멈춤 - 눌러서 보기": "コピー・移動が停止 - タップして表示", "복사 · 이동 중입니다": "コピー・移動中です", @@ -1330,13 +1366,16 @@ "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "ブラウザーのクッキーを読めませんでした。環境設定 > ダウンロード > YouTube クッキー で「アプリ内ブラウザー」を選ぶか、選んだブラウザーがインストールされているか確認してください(Chrome・Edge はブラウザーを閉じないと読めません)。", "비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。", "비교하지 못했습니다: {0}": "比較できませんでした: {0}", + "비교할 값 붙여넣기 (받은 곳의 해시)": "比較する値を貼り付け (入手元のハッシュ)", "비밀번호": "パスワード", "비밀번호 (개인 키만 쓰면 비움)": "パスワード (秘密鍵だけを使う場合は空欄)", "비밀번호 (선택)": "パスワード(任意)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", + "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", + "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", "비밀번호가 틀렸습니다": "パスワードが違います", "비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)", @@ -1347,7 +1386,9 @@ "비어 있는 압축 파일입니다.": "空の圧縮ファイルです。", "비어 있는 폴더": "空のフォルダー", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", + "비어 있습니다": "空です", "비어 있음": "空", + "비우기": "空にする", "비우면 지움": "空なら削除", "빈 이름": "空の名前", "빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除", @@ -1487,6 +1528,8 @@ "숨긴 항목 (. 으로 시작) 보이기": "隠し項目 (. で始まる) を表示", "숨긴 항목 숨기기": "隠し項目を隠す", "숨김": "非表示", + "숨은 다른 창으로": "隠れた別ペインへ", + "숨은 다른 창으로: X-plore 처럼 위에 \"갈 곳\" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다": "隠れた別ペインへ: X-plore のように上部に「移動先」を常に表示し、そのフォルダーへ直接コピー・移動します", "숨은 항목 표시": "隠し項目を表示", "숨은 항목을 감춥니다.": "隠し項目を隠します。", "숨은 항목을 보입니다.": "隠し項目を表示します。", @@ -1581,12 +1624,14 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "암호화 안 됨 (http)": "暗号化なし (http)", + "압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "アーカイブ内は読み取り専用です (取り出しのみできます)", "압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축 푸는 중 · {0}": "展開中 · {0}", "압축 풀기": "展開", "압축 풀기 · {0}": "展開 · {0}", + "압축에서 꺼내지 못했습니다: {0}": "アーカイブから取り出せませんでした: {0}", "압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", "압축이 있는 폴더": "アーカイブのあるフォルダー", @@ -1608,6 +1653,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 지우기": "アプリを削除", + "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", "앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "アプリに内蔵されています (Tor {0}、tor {1})。オンにしても Tor 画面を開くまで何も起動しません。", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "アプリを再インストールしたため、Android 自動バックアップ (Google バックアップ) が以前の設定を復元しました。\n・動画リスト {0} 件\n・WebDAV サーバー: {1}", @@ -1661,8 +1707,10 @@ "열 수 없습니다: {0}": "開けません: {0}", "열기": "開く", "열기 · 재생": "開く・再生", + "열어 본 때: {0}": "アクセス日時: {0}", "영구 삭제": "完全に削除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)", + "영구 삭제하면 되살릴 수 없습니다.": "完全に削除すると元に戻せません。", "영상": "映像", "영상 {0}": "映像 {0}", "영상 · 음성 합치는 중": "映像・音声を結合中", @@ -1838,6 +1886,7 @@ "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", + "이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "この端末では 7z・RAR の中を表示できません (他のアプリで開いてください)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", "이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)": "この端末ではアプリ内で SMB を開けません (Windows では \\\\サーバー\\共有 のパスで開いてください)", @@ -1893,8 +1942,10 @@ "이 폴더에 새 폴더": "このフォルダーに新しいフォルダー", "이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", + "이 폴더에는 그림 · 동영상이 없습니다": "このフォルダーには画像・動画がありません", "이 폴더에만: {0}": "このフォルダーだけ: {0}", "이 폴더의 동영상 재생": "このフォルダーの動画を再生", + "이 폴더의 파일": "このフォルダーのファイル", "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "このアプリは管理者権限で実行されています。おすすめしません。\n\n· 内蔵 Chrome エンジンはサンドボックス (Web ページを閉じ込める保護) がオフのため、Web ページが管理者権限のプロセス内で動きます。ページの脆弱性がそのままこの PC の管理者権限になるおそれがあります。\n· Edge エンジンにはこの影響がありません。\n· 管理者権限なしでアプリを実行すれば、この設定なしで Chrome エンジンを使えます。", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "この形式を開くアプリがありません。長押しまたは右クリック → [内蔵プレーヤーで再生] で試してください。", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "この画面はアプリ内 Web ビューの接続を Tor 経由にするもので、公式の Tor Browser ではありません。\n\n・接続 (名前解決を含む) はすべて Tor 経由になり、Cookie・履歴・キャッシュは通常のブラウザーとは別に保存し、閉じると消去します (環境設定で残すことも可能)。\n・ただし、ブラウザーのフィンガープリント (画面サイズ・フォント・端末情報など) を隠す保護は公式 Tor Browser より弱いです。サイトがこの端末を識別できる場合があります。\n・強い匿名性が必要な場合は公式 Tor Browser (https://www.torproject.org) を使ってください。", @@ -1983,6 +2034,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "読めなかったフォルダー {0} 個 (権限・接続の問題) - その中は探せませんでした", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "読めなかったフォルダー {0} 件 (サイズが実際より小さいことがあります)", + "읽지 못했습니다: {0}": "読み込めませんでした: {0}", "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 (가로로 긴 그림만)": "自動 (横長の画像のみ)", @@ -2078,6 +2130,8 @@ "저장 실패: {0}": "保存失敗: {0}", "저장 안 함": "保存しない", "저장 위치": "保存先", + "저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "ストレージ・サーバー・お気に入りをツリーの最上位に", + "저장 장치를 알 수 없습니다": "ストレージが分かりません", "저장 폴더": "保存フォルダー", "저장 형식": "保存形式", "저장: {0}": "保存: {0}", @@ -2109,6 +2163,7 @@ "전체 {0}개 중 {1}번째": "全 {0} 件中 {1} 件目", "전체 만들기": "すべて作成", "전체 목록": "全体リスト", + "전체 복사": "すべてコピー", "전체 선택": "すべて選択", "전체 이동": "すべて移動", "전체 화면": "全画面", @@ -2170,6 +2225,7 @@ "준비 중": "準備中", "준비 중…": "準備中…", "준비하는 중": "準備中", + "줄 번호": "行番号", "줄 삭제": "行を削除", "중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.": "中間者攻撃の可能性があるため接続しませんでした。サーバーを再インストールした、または鍵を変更したことが確かな場合のみ、サーバー設定の [信頼した鍵を削除] の後に再接続して新しい鍵を確認してください。", "중국어": "中国語", @@ -2216,6 +2272,7 @@ "지금 짝을 즐겨찾기에": "今のペアをお気に入りに", "지금 폴더": "現在のフォルダー", "지금 폴더 추가": "今のフォルダーを追加", + "지금 폴더로": "このフォルダーに", "지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)", "지금 확인": "今すぐ確認", @@ -2238,6 +2295,8 @@ "지우지 못했습니다: {0}": "削除できませんでした: {0}", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "削除せずに同期・対象にだけある {0} 個を削除するか確認が必要", "지우지 않기 (지우기 끄기)": "削除しない (削除をオフ)", + "지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "削除したものはここに {0} 日間置いてから自動で削除します (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", + "지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "削除したものはそのストレージのアプリのゴミ箱に置き、この日数が過ぎると削除します。オフにするとすぐに完全削除", "지울 목록 보고 결정": "削除する一覧を見て決める", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", @@ -2263,6 +2322,7 @@ "차례 기다림": "順番待ち", "창": "ペイン", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)", + "창 바꿈: {0}": "ペインを切り替えました: {0}", "창 배치": "ペインの配置", "창 배치 · 버튼 구성": "ペインの配置 · ボタン構成", "창 배치: 위아래 (누르면 좌우)": "画面配置: 上下 (押すと左右)", @@ -2336,6 +2396,7 @@ "취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "キャンセルしました。展開中のファイルは残りません (展開済みのファイルはそのまま)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", + "칸 그림": "ツリーマップ", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "欄に表示中のオプション: {0}\n保存済みのオプション: {1}\n\n編集したオプションを保存して実行しますか?", "컴포넌트": "コンポーネント", "컴포넌트 목록을 받을 수 없습니다 ({0})": "コンポーネント一覧を取得できません ({0})", @@ -2355,6 +2416,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "サイズ・更新日・種類を指定すると、ファイルだけを探します。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", "크기 이상": "最小サイズ", + "크기: {0} ({1} 바이트)": "サイズ: {0} ({1} バイト)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "サイズはそのままでくっきりさせ、新しいファイルに保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残り枚数と予想時間が表示されます。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", @@ -2368,6 +2430,7 @@ "타일 {0} · 약 {1}GB": "タイル {0} · 約 {1}GB", "타일이 작을수록 느립니다 (예: 16GB 폰에서 1024×768 을 2배로 - 512 약 4분 · 320 약 9분 30초).": "タイルが小さいほど遅くなります (例: 16GB のスマホで 1024×768 を 2 倍に - 512 で約 4 分 · 320 で約 9 分 30 秒)。", "탐색기": "エクスプローラー", + "탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다": "エクスプローラー内でスワイプすると別のペインへ。別の画面へ移るには上部のバーでスワイプします", "탐색기 오른쪽 클릭 메뉴": "エクスプローラーの右クリックメニュー", "탐색기에서 동영상을 열 때": "エクスプローラーで動画を開いたとき", "탐색기에서 연 동영상을 재생할 창": "エクスプローラーで開いた動画を再生するウィンドウ", @@ -2385,6 +2448,7 @@ "파일 {0}/{1}": "ファイル {0}/{1}", "파일 {0}개": "ファイル {0} 件", "파일 {0}개 · {1}": "ファイル {0} 件 · {1}", + "파일 {0}개 · 폴더 {1}개": "ファイル {0} 個・フォルダー {1} 個", "파일 관리자에서 보기": "ファイルマネージャーで表示", "파일 복사 · 이동 방법": "ファイルのコピー · 移動方法", "파일 세는 중…": "ファイルを数えています…", @@ -2427,6 +2491,7 @@ "편집 목록에 있음": "編集リストにあり", "편집 목록에 추가": "編集リストに追加", "편집 목록에 추가 (MKV 만들기 대상)": "編集リストに追加(MKV 作成の対象)", + "펼친 폴더를 다시 누르면 접기": "開いたフォルダーをもう一度押すと閉じる", "평일 9~18시": "平日 9~18 時", "포르투갈어": "ポルトガル語", "포트": "ポート", @@ -2443,6 +2508,7 @@ "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", "폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)", "폴더 선택": "フォルダーを選択", + "폴더 속성 · {0}": "フォルダーのプロパティ · {0}", "폴더 수정": "フォルダーを編集", "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "フォルダー内のファイルサイズの合計を一覧に表示します。大きなフォルダー・NAS は読み込みに時間がかかることがあります。長押しの [フォルダーサイズを計算] はいつでも使えます", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "フォルダーの中身を合わせます (対象に同じ名前のフォルダーは作りません)。", @@ -2468,7 +2534,9 @@ "폴더를 자기 안으로 복사할 수 없습니다": "フォルダーを自分自身の中にコピーできません", "폴더를 자기 안으로 옮길 수 없습니다": "フォルダーを自分自身の中に移動できません", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "フォルダーを自分自身の中へ{0}ことはできません: {1}", + "폴더를 표시하면": "フォルダーにチェックしたとき", "폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。", + "폴더와 안의 것 전체 (기본)": "フォルダーと中身すべて (既定)", "폴란드어": "ポーランド語", "표시 (여러 개 고르기)": "マーク (複数選択)", "표시 이름": "表示名", @@ -2522,6 +2590,7 @@ "한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)": "1 枚に 2 ページが並んだ画像を半分ずつめくって表示します (読む方向のページから)", "한 쪽 맞추기": "1 ページに合わせる", "한 창": "1 ペイン", + "한 창일 때 복사 · 이동": "1 ペイン時のコピー・移動", "한국어": "韓国語", "한글": "ハングル", "할 일": "内容", @@ -2534,6 +2603,7 @@ "해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?": "解像度アップのモデルが必要です。どれをダウンロードしますか?", "해상도를 올리지 못했습니다": "高解像度化できませんでした", "해상도를 올린 그림 저장 폴더": "高解像度化した画像の保存フォルダー", + "해시 · {0}": "ハッシュ · {0}", "해제": "解除", "허용": "許可", "허용했으면 다시 확인": "許可したらもう一度確認", @@ -2593,10 +2663,20 @@ "회전 없음": "回転なし", "회차": "話数", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "スマホ・タブレットは AV1・VP9 をハードウェアで再生できず、途切れたりバッテリーを多く使うことがあります。YouTube の H.264 は 1080p までなので、それより高い画質を選ぶとサイズが優先です。", + "휴지통": "ゴミ箱", + "휴지통 · {0}개 · {1}": "ゴミ箱 · {0} 件 · {1}", + "휴지통 보관": "ゴミ箱の保管", + "휴지통 비우기": "ゴミ箱を空にする", + "휴지통에서 지운 것은 되살릴 수 없습니다.": "ゴミ箱から削除したものは元に戻せません。", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)", "휴지통으로": "ごみ箱へ", "휴지통으로 보내기": "ごみ箱に移動", + "휴지통으로 옮기지 못했습니다: {0} ({1})": "ゴミ箱に移せませんでした: {0} ({1})", + "휴지통을 거치지 않고 영구 삭제": "ゴミ箱を使わずに完全に削除", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)", + "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", + "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです - 削除するとすぐに完全削除されます (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", + "휴지통이 비어 있습니다": "ゴミ箱は空です", "휴지통이 없는 곳입니다": "ごみ箱がない場所です", "휴지통이 없습니다": "ごみ箱がありません", "히브리어": "ヘブライ語", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 341d4a6..bd96ad9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -73,6 +73,7 @@ "1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化", "10초 뒤로 (←)": "后退 10 秒(←)", "10초 앞으로 (→)": "前进 10 秒(→)", + "16진 보기 · {0}": "十六进制查看 · {0}", "1배 (선명하게)": "1倍 (锐化)", "1분 안": "1 分钟以内", "2/2 · 해상도 올리는 중": "2/2 · 正在提高分辨率", @@ -368,6 +369,12 @@ "Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "仅限 Windows。在本设备上会用其他应用打开文档。", "Windows 오류 0x{0}": "Windows 错误 0x{0}", "Windows 탐색기": "Windows 资源管理器", + "X-plore 스타일: 옆으로 밀면 창 바꾸기": "X-plore 样式: 左右滑动切换窗格", + "X-plore 스타일: 표시 동그라미 늘 보이기": "X-plore 样式: 始终显示标记圆圈", + "X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)": "在 X-plore 样式中像 X-plore 一样显示在一棵树中 (顶部的标签 · ★ 保持不变)", + "X-plore 식 (1 · 2 · 3번)": "X-plore 方式 (第 1 · 2 · 3 次)", + "X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기": "X-plore 方式: 第 1 次 = 文件夹,第 2 次 = 其中所有文件,第 3 次 = 取消", + "X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)": "像 X-plore 一样。关闭时仅用 › 箭头折叠 · 展开 (双击时列表不会移动)", "YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)", "YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube 要求进行机器人验证。请在 设置 > 下载 > YouTube Cookie 中指定浏览器(推荐 Firefox)或 cookies.txt 后重新下载。", @@ -391,6 +398,7 @@ "ZIP 이 있는 폴더로": "到 ZIP 所在文件夹", "ZIP 이름": "ZIP 名称", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]", + "[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名 · [C] 编号 · [D] 日期 · [T] 时间 · 扩展名不变 例: Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名称 · [C] 序号 · 保留扩展名 例如: Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。", @@ -450,9 +458,11 @@ "{0} (기본): {1}": "{0}(默认): {1}", "{0} (동영상 {1})": "{0}(视频 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)", + "{0} / {1} 읽음": "已读取 {0} / {1}", "{0} / {1}장": "{0} / {1} 张", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", + "{0} · 그림 · 동영상 {1}개": "{0} · {1} 个图片 / 视频", "{0} · 링크 {1}개 제외": "{0} · 已排除 {1} 个链接", "{0} · 파일 {1}개": "{0} · {1} 个文件", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", @@ -508,6 +518,7 @@ "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1} …": "{0} 项 · {1} …", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", + "{0}개 고름": "已选 {0} 个", "{0}개 더 보기": "再显示 {0} 个", "{0}개 맞춤": "已同步 {0} 个", "{0}개 보임": "显示 {0} 项", @@ -537,12 +548,15 @@ "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}", + "{0}개를 영구 삭제할까요?": "要永久删除 {0} 项吗?", "{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}개를 옮겼습니다.": "已移动 {0} 项。", + "{0}개를 원래 자리로 되살렸습니다": "已将 {0} 项还原到原位置", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。", "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", + "{0}개를 휴지통으로 옮기지 못했습니다": "无法将 {0} 项移到回收站", "{0}개에서 멈췄습니다": "已在 {0} 个处暂停", "{0}개의 이름을 바꿨습니다.": "已重命名 {0} 项。", "{0}배": "{0} 倍", @@ -550,6 +564,7 @@ "{0}분": "{0} 分钟", "{0}분 {1}초": "{0} 分 {1} 秒", "{0}시간 {1}분": "{0} 小时 {1} 分", + "{0}일": "{0} 天", "{0}일 안": "{0} 天内", "{0}일 지남": "超过 {0} 天", "{0}장 모두 해상도 올리기": "提高全部 {0} 页的分辨率", @@ -603,6 +618,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "강하게 (0.4)": "强 (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", + "같습니다 ✔": "相同 ✔", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", @@ -647,6 +663,7 @@ "검색 지우기": "清除搜索", "검정": "黑色", "게스트": "访客", + "격자로 보기 (그림 · 동영상)": "网格查看 (图片 / 视频)", "결과 폴더": "输出文件夹", "결과 폴더 열기": "打开输出文件夹", "결과 폴더 열기 (동영상을 고르세요)": "打开输出文件夹(请选择视频)", @@ -736,8 +753,11 @@ "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件", "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "其中 {0} 个文件夹 - 其中的文件和文件夹也一并处理", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)", + "글 뷰어로 보기": "以文本查看", "글을 격자에 반영": "将文本应用到网格", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", + "글자 작게": "缩小文字", + "글자 크게": "放大文字", "금": "五", "기계 번역": "机器翻译", "기기": "设备", @@ -764,18 +784,21 @@ "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。", "기타 즐겨찾기": "其他书签", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选", + "길게 누르면 {0}": "长按: {0}", "길이": "时长", "길이 {0}": "时长 {0}", "끄기": "关闭", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "关闭时(默认)在离开 Tor 页面时(切换到其他页面或应用转到后台)清除。始终与普通浏览器分开。", "끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).": "关闭更安全,但很多网站将无法显示 (从下次打开起)。", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "关闭后将直接使用上次的设置开始: 原语言 {0},语言 {1},{2}", + "끄면 선택 모드에서만 보입니다 (다른 스타일은 늘 선택 모드에서만)": "关闭时仅在选择模式下显示 (其他样式始终仅在选择模式下显示)", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "关闭后,在查看设置、下载列表或其他界面时网页声音会继续播放", "끌어 놓기 기본": "拖放默认操作", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", "끌어서 다른 창으로": "拖到另一个窗格", + "끔": "关闭", "끝": "结束", "끝 (오른쪽 · 아래)": "边缘 (右 · 下)", "끝 = 현재": "结束 = 当前", @@ -789,10 +812,13 @@ "끝내기 (작업 멈춤)": "退出 (停止任务)", "끝에서 처음으로 돌기": "到末尾后回到开头", "나가기": "退出", + "나머지 폴더 {0}개": "其余 {0} 个文件夹", "나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载", "나머지는 지웠습니다.": "其余已删除。", "나중에": "以后再说", "날짜": "日期", + "날짜: 바뀐 때": "日期: 修改时间", + "날짜: 찍은 때 (사진, 없으면 바뀐 때)": "日期: 拍摄时间 (照片; 否则为修改时间)", "남는 것": "保留的内容", "남은 {0}장": "剩余 {0} 张", "남은 것 모두 취소 ({0}개)": "取消全部剩余项({0} 个)", @@ -879,12 +905,14 @@ "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 복사": "复制到另一窗格", "다른 창으로 이동": "移动到另一窗格", + "다른 폴더": "其他文件夹", "다른 폴더로 이동": "移动到其他文件夹", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "可能有其他程序正在使用或没有权限。其余已删除。", "다른 프로그램이 쓰고 있습니다": "其他程序正在使用", "다른 항목과 같은 이름": "与其他项同名", "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "切换到其他页面或应用转到后台时,会立即清理 Tor 页面的网页和连接。Tor 本身会在这段时间内保持运行,返回后可立即使用。", "다른 화면으로 가면 페이지 소리 멈춤": "离开浏览器时暂停网页声音", + "다릅니다 - 파일이 바뀌었거나 다른 파일입니다": "不同 - 文件已更改或是另一个文件", "다시 만들기 대기로": "改回等待重新制作", "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "重新排队制作 (当前批次结束后)", "다시 받기": "重新下载", @@ -934,6 +962,7 @@ "단축키 해제": "注销快捷键", "닫기": "关闭", "닫힘 (실행 안 함)": "已关闭 (未执行)", + "담아 두고 붙여넣기 (기본)": "暂存后粘贴 (默认)", "답이 없습니다": "没有应答", "대기": "等待", "대기 {0}개:": "等待 {0} 个:", @@ -957,6 +986,7 @@ "대소문자 그대로": "保持大小写", "대소문자 무시": "忽略大小写", "더 보기": "更多", + "더 읽기": "继续读取", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", "덮어쓰기": "覆盖", @@ -1013,6 +1043,8 @@ "되돌릴 수 없습니다.": "无法撤销。", "되살리고 다시 시작": "恢复并重新启动", "되살리기": "恢复", + "되살리지 못함: {0}": "无法还原: {0}", + "되살림": "已还原", "두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致", "두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)", "두 쪽 나눠 보기": "拆分双页", @@ -1047,6 +1079,7 @@ "링크 {0}개는 넣지 않음": "{0} 个链接未包含", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}", "링크 {0}개는 세지 않음": "{0} 个链接未计入", + "링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} 个链接 / 联接 (不跟随、不计入)", "링크 · junction 은 넣지 않습니다": "不包含链接和 junction", "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", @@ -1180,6 +1213,7 @@ "바뀐 날": "修改时间", "바뀐 날: 아무 때": "修改时间: 任意", "바뀐 내용": "更改内容", + "바뀐 때: {0}": "修改时间: {0}", "바뀐 지 … 일 안": "… 天内修改", "바뀐 지 … 일 지남": "超过 … 天未修改", "바로 끔": "立即关闭", @@ -1293,6 +1327,8 @@ "복사 · 이동 {0}개": "复制 · 移动 {0} 个", "복사 · 이동 {0}개 · {1}%": "复制 · 移动 {0} 个 · {1}%", "복사 · 이동 {0}개가 아직 끝나지 않았습니다. 끝내면 멈추고, 복사하던 파일은 완성되지 않은 채로 남지 않게 지웁니다.": "{0} 个复制 · 移动尚未完成。退出会停止它们,并删除正在复制的文件,以免留下不完整的文件。", + "복사 · 이동 갈 곳": "复制 · 移动目标", + "복사 · 이동 갈 곳: {0}": "复制 · 移动到: {0}", "복사 · 이동 끝": "复制 · 移动 已完成", "복사 · 이동 멈춤 - 눌러서 보기": "复制 · 移动已停止 - 点按查看", "복사 · 이동 중입니다": "正在复制 · 移动", @@ -1330,13 +1366,16 @@ "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "未能读取浏览器 Cookie。请在 设置 > 下载 > YouTube Cookie 中选择“应用内浏览器”,或确认所选浏览器已安装(Chrome · Edge 需关闭浏览器才能读取)。", "비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。", "비교하지 못했습니다: {0}": "无法比较: {0}", + "비교할 값 붙여넣기 (받은 곳의 해시)": "粘贴要比较的值 (来源处的哈希)", "비밀번호": "密码", "비밀번호 (개인 키만 쓰면 비움)": "密码 (仅使用私钥时留空)", "비밀번호 (선택)": "密码(可选)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", + "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", + "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", "비밀번호가 틀렸습니다": "密码错误", "비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)", @@ -1347,7 +1386,9 @@ "비어 있는 압축 파일입니다.": "压缩文件为空。", "비어 있는 폴더": "空文件夹", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", + "비어 있습니다": "空", "비어 있음": "空", + "비우기": "清空", "비우면 지움": "留空则删除", "빈 이름": "空名称", "빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹", @@ -1487,6 +1528,8 @@ "숨긴 항목 (. 으로 시작) 보이기": "显示隐藏项目 (以 . 开头)", "숨긴 항목 숨기기": "隐藏隐藏项目", "숨김": "隐藏", + "숨은 다른 창으로": "到隐藏的另一窗格", + "숨은 다른 창으로: X-plore 처럼 위에 \"갈 곳\" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다": "到隐藏的另一窗格: 像 X-plore 一样在顶部始终显示\"目标\",并直接复制 · 移动到该文件夹", "숨은 항목 표시": "显示隐藏项目", "숨은 항목을 감춥니다.": "已隐藏隐藏项目。", "숨은 항목을 보입니다.": "已显示隐藏项目。", @@ -1581,12 +1624,14 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "암호화 안 됨 (http)": "未加密 (http)", + "압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "压缩包内为只读 (只能解压取出)", "압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”", "압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축 푸는 중 · {0}": "正在解压 · {0}", "압축 풀기": "解压", "압축 풀기 · {0}": "解压 · {0}", + "압축에서 꺼내지 못했습니다: {0}": "无法从压缩包取出: {0}", "압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}", "압축이 있는 폴더": "压缩包所在文件夹", @@ -1608,6 +1653,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 지우기": "卸载应用", + "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。", "앱에 들어 있는 것 (기본)": "应用内置 (默认)", "앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "已内置于应用 (Tor {0},tor {1})。开启后在打开 Tor 画面之前不会启动任何内容。", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "重新安装应用后,Android 自动备份 (Google 备份) 恢复了之前的设置。\n· 视频列表 {0} 个\n· WebDAV 服务器: {1}", @@ -1661,8 +1707,10 @@ "열 수 없습니다: {0}": "无法打开: {0}", "열기": "打开", "열기 · 재생": "打开 · 播放", + "열어 본 때: {0}": "访问时间: {0}", "영구 삭제": "永久删除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)", + "영구 삭제하면 되살릴 수 없습니다.": "永久删除后无法还原。", "영상": "视频", "영상 {0}": "视频 {0}", "영상 · 음성 합치는 중": "正在合并视频 · 音频", @@ -1838,6 +1886,7 @@ "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", + "이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "此设备无法浏览 7z / RAR 内容 (请用其他应用打开)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", "이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)": "此设备无法在应用内打开 SMB (Windows 请使用 \\\\服务器\\共享 路径打开)", @@ -1893,8 +1942,10 @@ "이 폴더에 새 폴더": "在此文件夹中新建文件夹", "이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", + "이 폴더에는 그림 · 동영상이 없습니다": "此文件夹中没有图片或视频", "이 폴더에만: {0}": "仅此文件夹: {0}", "이 폴더의 동영상 재생": "播放此文件夹中的视频", + "이 폴더의 파일": "此文件夹中的文件", "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "本应用正以管理员权限运行,不推荐这样做。\n\n· 内置 Chrome 引擎的沙盒(限制网页的保护)已关闭,网页会在管理员权限进程中运行。网页中的漏洞可能直接变成此电脑的管理员权限。\n· Edge 引擎不受此影响。\n· 不以管理员权限运行本应用时,无需此设置即可使用 Chrome 引擎。", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "没有可打开此格式的应用。请长按或右键 → [用内置播放器播放] 试试。", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "此画面将应用内网页视图的连接经由 Tor 发送,并非官方 Tor Browser。\n\n· 所有连接 (包括名称解析) 都经由 Tor,Cookie · 记录 · 缓存与普通浏览器分开保存,关闭时清除 (可在设置中保留)。\n· 但隐藏浏览器指纹 (屏幕尺寸 · 字体 · 设备信息等) 的保护比官方 Tor Browser 弱。网站可能识别出本设备。\n· 如需强匿名性,请使用官方 Tor Browser (https://www.torproject.org)。", @@ -1983,6 +2034,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} 个无法读取的文件夹 (权限 · 连接问题) - 未在其中查找", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} 个文件夹无法读取 (大小可能小于实际)", + "읽지 못했습니다: {0}": "无法读取: {0}", "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 (가로로 긴 그림만)": "自动 (仅横向图片)", @@ -2078,6 +2130,8 @@ "저장 실패: {0}": "保存失败: {0}", "저장 안 함": "不保存", "저장 위치": "保存位置", + "저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "将存储 · 服务器 · 收藏放在树的最上层", + "저장 장치를 알 수 없습니다": "未知的存储", "저장 폴더": "保存文件夹", "저장 형식": "保存格式", "저장: {0}": "保存: {0}", @@ -2109,6 +2163,7 @@ "전체 {0}개 중 {1}번째": "共 {0} 个中的第 {1} 个", "전체 만들기": "全部制作", "전체 목록": "完整列表", + "전체 복사": "全部复制", "전체 선택": "全选", "전체 이동": "全部移动", "전체 화면": "全屏", @@ -2170,6 +2225,7 @@ "준비 중": "准备中", "준비 중…": "准备中…", "준비하는 중": "正在准备", + "줄 번호": "行号", "줄 삭제": "删除行", "중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.": "可能是中间人攻击,因此未连接。仅当确定服务器已重装或更换了密钥时,请在服务器设置中 [清除已信任的密钥] 后重新连接并确认新密钥。", "중국어": "中文", @@ -2216,6 +2272,7 @@ "지금 짝을 즐겨찾기에": "将当前一对加入收藏", "지금 폴더": "当前文件夹", "지금 폴더 추가": "添加当前文件夹", + "지금 폴더로": "使用当前文件夹", "지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏", "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)", "지금 확인": "立即检查", @@ -2238,6 +2295,8 @@ "지우지 못했습니다: {0}": "无法删除: {0}", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "已同步但未删除 · 需确认是否删除仅在目标中的 {0} 个项目", "지우지 않기 (지우기 끄기)": "不删除 (关闭删除)", + "지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "删除的项目会在此保留 {0} 天后自动删除 (设置 > 文件资源管理器 > 回收站保留)。", + "지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "删除的项目放入该存储的应用回收站,超过此天数后删除。关闭则立即永久删除", "지울 목록 보고 결정": "查看要删除的列表并决定", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", @@ -2263,6 +2322,7 @@ "차례 기다림": "排队等待", "창": "窗格", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)", + "창 바꿈: {0}": "已切换窗格: {0}", "창 배치": "窗格布局", "창 배치 · 버튼 구성": "窗格布局 · 按钮配置", "창 배치: 위아래 (누르면 좌우)": "窗格布局: 上下 (点按切换为左右)", @@ -2336,6 +2396,7 @@ "취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).": "已取消。正在解压的文件不会留下 (已解压的文件保留)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", + "칸 그림": "矩形树图", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "输入框中的选项: {0}\n已保存的选项: {1}\n\n保存修改的选项并执行吗?", "컴포넌트": "组件", "컴포넌트 목록을 받을 수 없습니다 ({0})": "无法获取组件列表({0})", @@ -2355,6 +2416,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "设置大小、修改时间或类型时,只查找文件。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", "크기 이상": "最小大小", + "크기: {0} ({1} 바이트)": "大小: {0} ({1} 字节)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "保持尺寸只做锐化并保存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余张数和预计时间。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", @@ -2368,6 +2430,7 @@ "타일 {0} · 약 {1}GB": "分块 {0} · 约 {1}GB", "타일이 작을수록 느립니다 (예: 16GB 폰에서 1024×768 을 2배로 - 512 약 4분 · 320 약 9분 30초).": "图块越小越慢(例如:在 16GB 手机上将 1024×768 放大 2 倍 - 512 约 4 分钟 · 320 约 9 分 30 秒)。", "탐색기": "文件管理", + "탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다": "在资源管理器内滑动切换到另一窗格。要切换到其他界面,请在顶部栏滑动", "탐색기 오른쪽 클릭 메뉴": "资源管理器右键菜单", "탐색기에서 동영상을 열 때": "从资源管理器打开视频时", "탐색기에서 연 동영상을 재생할 창": "播放从资源管理器打开的视频的窗口", @@ -2385,6 +2448,7 @@ "파일 {0}/{1}": "文件 {0}/{1}", "파일 {0}개": "{0} 个文件", "파일 {0}개 · {1}": "{0} 个文件 · {1}", + "파일 {0}개 · 폴더 {1}개": "{0} 个文件 · {1} 个文件夹", "파일 관리자에서 보기": "在文件管理器中显示", "파일 복사 · 이동 방법": "文件复制 · 移动方式", "파일 세는 중…": "正在统计文件…", @@ -2427,6 +2491,7 @@ "편집 목록에 있음": "已在编辑列表中", "편집 목록에 추가": "加入编辑列表", "편집 목록에 추가 (MKV 만들기 대상)": "加入编辑列表(制作 MKV 的对象)", + "펼친 폴더를 다시 누르면 접기": "再次点按已展开的文件夹可折叠", "평일 9~18시": "工作日 9~18 点", "포르투갈어": "葡萄牙语", "포트": "端口", @@ -2443,6 +2508,7 @@ "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", "폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)", "폴더 선택": "选择文件夹", + "폴더 속성 · {0}": "文件夹属性 · {0}", "폴더 수정": "编辑文件夹", "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "在列表中显示文件夹内文件大小的总和。大型文件夹或 NAS 可能需要较长时间读取。长按 [计算文件夹大小] 始终可用", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "同步文件夹内的内容 (不会在目标中新建同名文件夹)。", @@ -2468,7 +2534,9 @@ "폴더를 자기 안으로 복사할 수 없습니다": "不能将文件夹复制到其自身内部", "폴더를 자기 안으로 옮길 수 없습니다": "不能将文件夹移动到其自身内部", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "不能将文件夹{0}到其自身内部: {1}", + "폴더를 표시하면": "标记文件夹时", "폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。", + "폴더와 안의 것 전체 (기본)": "文件夹及其所有内容 (默认)", "폴란드어": "波兰语", "표시 (여러 개 고르기)": "标记 (多选)", "표시 이름": "显示名称", @@ -2522,6 +2590,7 @@ "한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)": "将一张图片中并排的两页分成两半逐页浏览 (从阅读方向的那页开始)", "한 쪽 맞추기": "适应整页", "한 창": "单窗格", + "한 창일 때 복사 · 이동": "单窗格时的复制 · 移动", "한국어": "韩语", "한글": "韩文", "할 일": "任务", @@ -2534,6 +2603,7 @@ "해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?": "需要放大模型。要下载哪一个?", "해상도를 올리지 못했습니다": "无法提高分辨率", "해상도를 올린 그림 저장 폴더": "提高分辨率后图片的保存文件夹", + "해시 · {0}": "哈希 · {0}", "해제": "解除", "허용": "允许", "허용했으면 다시 확인": "允许后重新检查", @@ -2593,10 +2663,20 @@ "회전 없음": "不旋转", "회차": "集数", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "手机 · 平板常无法硬件解码 AV1 · VP9,会卡顿或耗电。YouTube 的 H.264 最高 1080p,选择更高画质时以尺寸优先。", + "휴지통": "回收站", + "휴지통 · {0}개 · {1}": "回收站 · {0} 项 · {1}", + "휴지통 보관": "回收站保留", + "휴지통 비우기": "清空回收站", + "휴지통에서 지운 것은 되살릴 수 없습니다.": "从回收站删除的项目无法还原。", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)", "휴지통으로": "移到回收站", "휴지통으로 보내기": "移到回收站", + "휴지통으로 옮기지 못했습니다: {0} ({1})": "无法移到回收站: {0} ({1})", + "휴지통을 거치지 않고 영구 삭제": "不经回收站永久删除", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)", + "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 (设置 > 文件资源管理器 > 回收站保留)。", + "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 - 删除会立即永久删除 (设置 > 文件资源管理器 > 回收站保留)。", + "휴지통이 비어 있습니다": "回收站为空", "휴지통이 없는 곳입니다": "此位置没有回收站", "휴지통이 없습니다": "没有回收站", "히브리어": "希伯来语", diff --git a/integration_test/windows_xplore_shot_test.dart b/integration_test/windows_xplore_shot_test.dart new file mode 100644 index 0000000..17f8d19 --- /dev/null +++ b/integration_test/windows_xplore_shot_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더). +/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a) · 압축을 폴더처럼 (f). +/// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xplore'); + + for (final lang in ['ko', 'en']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('X-plore 화면 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = Directory.systemTemp.createTempSync('jj_xpshot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final a = Directory(p.join(tmp.path, 'Videos'))..createSync(); + final b = Directory(p.join(tmp.path, 'Backup'))..createSync(); + for (final n in ['Show S01', 'Movies']) { + Directory(p.join(a.path, n)).createSync(); + } + File(p.join(a.path, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0)); + File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo'); + File(p.join(a.path, 'Photos.zip')).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('2026/beach.jpg', 'x')) + ..addFile(ArchiveFile.string('readme.txt', 'readme')))); + ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerStyle = 'xplore' + ..explorerLayout = w < 720 ? 'single' : 'dual' + ..explorerOnePaneCopy = 'other' + ..explorerPaths = [a.path, b.path] + ..folderBookmarks = [FolderBookmark(name: 'Show S01', path: p.join(a.path, 'Show S01'))]; + final shotKey = GlobalKey(); + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)), + ))); + Future settle([int n = 12]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'xplore_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(20); + // Show S01 펼치기 · 즐겨찾기 펼치기 + await t.tap(find.text('Show S01').first); + await settle(); + await t.tap(find.byKey(const ValueKey('k2:\u0000favorites')).first); + await settle(); + await shot('tree'); + // f: 압축을 › 로 펼치기 (안의 폴더도) + Finder chevron(String name) => find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.chevron_right)); + await t.tap(chevron('Photos.zip').first); + await settle(); + await t.tap(chevron('2026').first); + await settle(); + await shot('archive'); + await t.pumpWidget(const SizedBox()); + await settle(4); + }); + } + } +} diff --git a/integration_test/windows_xplore_tools_shot_test.dart b/integration_test/windows_xplore_tools_shot_test.dart new file mode 100644 index 0000000..d32f3b7 --- /dev/null +++ b/integration_test/windows_xplore_tools_shot_test.dart @@ -0,0 +1,123 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/core/batch_rename.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/text_codec.dart'; +import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart'; +import 'package:jj_mkvmaker/ui/explorer_grid.dart'; +import 'package:jj_mkvmaker/ui/storage_page.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 g · h · i · P3 (관리자 지시 10/18): 격자 보기 · 글 뷰어 · 일괄 이름 (날짜) · 저장 공간 그림을 PNG 로 +/// (JJ_SHOT_DIR, 없으면 임시 폴더). 크기 412 · 960, 언어 ko · en. 시험 파일은 임시 폴더에만. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xptools'); + + for (final lang in ['ko', 'en']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('X-plore 도구 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = Directory.systemTemp.createTempSync('jj_xptshot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + // 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949) + final pics = []; + for (var i = 0; i < 7; i++) { + final im = img.Image(width: 64, height: 48); + img.fill(im, color: img.ColorRgb8(40 + i * 30, 120, 200 - i * 20)); + if (i == 0) im.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56'); + final f = File(p.join(tmp.path, 'IMG_${1000 + i}.jpg'))..writeAsBytesSync(img.encodeJpg(im)); + pics.add(f.path); + } + File(p.join(tmp.path, 'clip.mkv')).writeAsBytesSync(List.filled(10, 0)); + final txt = File(p.join(tmp.path, 'memo.txt')) + ..writeAsBytesSync(encodeText(List.generate(30, (i) => '${i + 1}번째 줄 - 한글 메모 line $i').join('\n'), 'CP949').bytes); + for (final (n, size) in [('Videos', 900000), ('Photos', 400000), ('Docs', 120000), ('Music', 60000)]) { + final d = Directory(p.join(tmp.path, 'disk', n))..createSync(recursive: true); + File(p.join(d.path, 'big.bin')).writeAsBytesSync(List.filled(size, 0)); + } + File(p.join(tmp.path, 'disk', 'loose.bin')).writeAsBytesSync(List.filled(80000, 0)); + + final shotKey = GlobalKey(); + final nav = GlobalKey(); + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(navigatorKey: nav, theme: buildTheme(), debugShowCheckedModeBanner: false, home: const Scaffold()), + ))); + Future settle([int n = 12]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(4); + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'xptools_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // g 격자 보기 + final entries = [ + for (final x in [...pics, p.join(tmp.path, 'clip.mkv'), txt.path]) + FileEntry(x, isDir: false, size: File(x).lengthSync(), modified: DateTime(2026, 10, 18)), + ]; + nav.currentState!.push(MaterialPageRoute( + builder: (_) => ExplorerGridPage( + title: 'Photos', + entries: entries, + imageExts: const ['jpg'], + videoThumb: (_) async => null, + onOpen: (_) async {}, + ), + )); + await settle(20); + await shot('grid'); + nav.currentState!.pop(); + await settle(); + + // h 글 뷰어 (CP949 를 스스로 알아봄) + nav.currentState!.push(MaterialPageRoute(builder: (_) => TextViewerPage(path: txt.path))); + await settle(20); + await shot('text'); + nav.currentState!.pop(); + await settle(); + + // i 일괄 이름 바꾸기 - [D] (사진은 찍은 때) + showBatchRename(nav.currentContext!, pics.take(4).toList(), + isDir: (_) => false, initial: const RenameRule(template: '[D]_[T]_[C]')); + await settle(30); + await shot('rename_date'); + await t.tap(find.text(lang == 'ko' ? '취소' : 'Cancel').last); + await settle(); + + // P3 저장 공간 그림 + nav.currentState!.push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: p.join(tmp.path, 'disk')))); + await settle(30); + await t.tap(find.text(lang == 'ko' ? '칸 그림' : 'Treemap')); + await settle(); + await shot('storage_chart'); + nav.currentState!.pop(); + await settle(); + await t.pumpWidget(const SizedBox()); + await settle(4); + }); + } + } +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 36de895..1245c15 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -396,6 +396,34 @@ class AppSettings { /// 창: 'dual' 두 창 (기본) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 · 'single' 한 창 String explorerLayout = 'auto'; + /// X-plore 비교 a: 한 창일 때 복사 · 이동 - 'clip' 담아 두었다가 붙여넣기 (지금 그대로, 처음 값) / + /// 'other' 숨은 다른 창의 폴더로 바로 (X-plore 처럼, 위에 "갈 곳" 줄). 예전 동작과 달라 처음 값은 그대로 (관리자 결정) + String explorerOnePaneCopy = 'clip'; + + /// X-plore 비교 d: 펼쳐 둔 지금 폴더를 다시 누르면 접기 (X-plore 처럼). 처음 값 끔 - 189 (관리자 지시: 이름으로는 접지 않아 + /// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다 + bool explorerRetapCollapse = false; + + /// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값) + Map batchRenameLast = {}; + + /// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께). + /// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로) + bool explorerRootsInTree = true; + + /// X-plore 비교 ⚖ (관리자 결정 10/18): X-plore 스타일에서 줄 끝 표시 동그라미를 늘 보이기 (처음 값 켬). 다른 스타일은 선택 모드에서만 (10/5) + bool explorerAlwaysMarks = true; + + /// X-plore 비교 ⚖: 폴더 표시 규칙 - 'tree' 자신 + 안의 것 전체 (지금, 처음 값 - 고르는 범위가 바뀌면 지우기 · 이동 대상이 바뀌어 그대로) / + /// 'xplore' 1번 = 폴더 자신, 2번 = 안의 파일 전체, 3번 = 풀기 + String explorerFolderMarkRule = 'tree'; + + /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (설정으로만, 처음 값 끔 - 10/8 사용자 결정 "화면 가운데를 밀면 화면 이동" 을 지킴, 관리자 판단 10/18) + bool explorerSwipePanes = false; + + /// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통 + int androidTrashDays = 14; + /// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본) / 'open' 한 번 = 바로 열기. /// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴. String explorerClick = Platform.isAndroid ? 'open' : 'select'; @@ -753,6 +781,14 @@ class AppSettings { 'torJavaScript': torJavaScript, 'torNoticeSeen': torNoticeSeen, 'explorerLayout': explorerLayout, + 'explorerOnePaneCopy': explorerOnePaneCopy, + 'explorerRetapCollapse': explorerRetapCollapse, + 'batchRenameLast': batchRenameLast, + 'explorerRootsInTree': explorerRootsInTree, + 'explorerAlwaysMarks': explorerAlwaysMarks, + 'explorerFolderMarkRule': explorerFolderMarkRule, + 'explorerSwipePanes': explorerSwipePanes, + 'androidTrashDays': androidTrashDays, 'explorerClick': explorerClick, // 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다 'explorerV2': true, @@ -965,6 +1001,14 @@ class AppSettings { ..torJavaScript = j['torJavaScript'] != false ..torNoticeSeen = j['torNoticeSeen'] == true // 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로 + ..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip' + ..explorerRetapCollapse = j['explorerRetapCollapse'] == true + ..batchRenameLast = j['batchRenameLast'] is Map ? Map.from(j['batchRenameLast'] as Map) : {} + ..explorerRootsInTree = j['explorerRootsInTree'] != false + ..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false + ..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree' + ..explorerSwipePanes = j['explorerSwipePanes'] == true + ..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 } ..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout']) ? j['explorerLayout'] as String : j['explorerLayout'] == 'dual' && j['explorerV2'] == true diff --git a/lib/core/app_trash.dart b/lib/core/app_trash.dart new file mode 100644 index 0000000..618f55c --- /dev/null +++ b/lib/core/app_trash.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; +import 'file_ops.dart' show isSameOrInside, samePath; + +/// X-plore 비교 e (관리자 결정 10/18): Android 앱 휴지통. Windows 는 시스템 휴지통 그대로. +/// - 저장 장치마다 맨 위의 `.jj_trash` 폴더 (같은 저장 장치 안에서 이름만 바꿔 옮긴다 - 빠르고 공간이 들지 않는다). +/// - 항목마다 `<시각>_<무작위>/` 폴더에 원래 것 + `info.json` (원래 경로 · 지운 때 · 폴더인지 · 크기). +/// - `.nomedia` 를 두어 갤러리 · 음악 앱이 휴지통의 그림 · 동영상을 보이지 않게. +/// - 보관 일 (설정, 처음 14일) 이 지나면 지운다. 0 = 휴지통 끔 (늘 영구 삭제, 예전처럼). +const trashFolderName = '.jj_trash'; + +class TrashItem { + /// 휴지통 안의 항목 폴더 (`.jj_trash/`) + final String entryDir; + + /// 원래 경로 + final String original; + final DateTime deletedAt; + final bool isDir; + final int size; + const TrashItem({required this.entryDir, required this.original, required this.deletedAt, required this.isDir, required this.size}); + + String get name => p.basename(original); + + /// 휴지통 안에 있는 원래 것 + String get stored => p.join(entryDir, name); +} + +/// 휴지통으로 옮기지 못함 (다른 저장 장치 · 권한 · 공간). 부르는 쪽이 영구 삭제할지 묻는다 +class TrashMoveException implements Exception { + final String path; + final Object cause; + const TrashMoveException(this.path, this.cause); + @override + String toString() => trf('휴지통으로 옮기지 못했습니다: {0} ({1})', [p.basename(path), cause]); +} + +class AppTrash { + /// 저장 장치 맨 위들 (Android: /storage/emulated/0 · /storage/XXXX-XXXX) + final List volumeRoots; + AppTrash(this.volumeRoots); + + /// [path] 가 있는 저장 장치의 휴지통 폴더 (어느 저장 장치에도 없으면 null) + String? trashDirFor(String path) { + String? best; + for (final r in volumeRoots) { + if (isSameOrInside(path, r) && (best == null || r.length > best.length)) best = r; + } + return best == null ? null : p.join(best, trashFolderName); + } + + /// 휴지통 안의 것인지 (휴지통 폴더 자신 포함 - 다시 휴지통으로 보내지 않게) + bool isInTrash(String path) { + final t = trashDirFor(path); + return t != null && isSameOrInside(path, t); + } + + static Future _sizeOf(String path) async { + final t = await FileSystemEntity.type(path, followLinks: false); + if (t == FileSystemEntityType.file) return File(path).length(); + if (t != FileSystemEntityType.directory) return 0; + var n = 0; + await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) { + if (e is File) { + try { + n += await e.length(); + } catch (_) {} + } + } + return n; + } + + /// [path] 를 휴지통으로 (같은 저장 장치 안 이름 바꾸기). 못 옮기면 [TrashMoveException] - 원래 것은 그대로 + Future moveToTrash(String path, {DateTime? now}) async { + final trash = trashDirFor(path); + if (trash == null) throw TrashMoveException(path, tr('저장 장치를 알 수 없습니다')); + final when = now ?? DateTime.now(); + final id = '${when.millisecondsSinceEpoch}_${Random().nextInt(1 << 30).toRadixString(36)}'; + final entry = Directory(p.join(trash, id)); + final isDir = await FileSystemEntity.isDirectory(path); + final size = await _sizeOf(path); + try { + await entry.create(recursive: true); + final nomedia = File(p.join(trash, '.nomedia')); + if (!await nomedia.exists()) await nomedia.writeAsString(''); + // 정보 먼저 (옮긴 뒤 앱이 꺼져도 어디서 왔는지 남게) + final info = TrashItem(entryDir: entry.path, original: path, deletedAt: when, isDir: isDir, size: size); + await File(p.join(entry.path, 'info.json')).writeAsString(jsonEncode({ + 'original': path, + 'deletedAt': when.toIso8601String(), + 'isDir': isDir, + 'size': size, + })); + if (isDir) { + await Directory(path).rename(info.stored); + } else { + await File(path).rename(info.stored); + } + return info; + } catch (e) { + // 옮기지 못했으면 (이름 바꾸기가 마지막 일이라 원래 것은 제자리) 만든 항목 폴더만 지운다 + try { + if (await entry.exists()) await entry.delete(recursive: true); + } catch (_) {} + throw TrashMoveException(path, e); + } + } + + /// 휴지통의 항목들 (지운 때가 최근인 것부터) + Future> list() async { + final out = []; + for (final r in volumeRoots) { + final dir = Directory(p.join(r, trashFolderName)); + if (!await dir.exists()) continue; + await for (final e in dir.list(followLinks: false).handleError((_) {})) { + if (e is! Directory) continue; + try { + final j = jsonDecode(await File(p.join(e.path, 'info.json')).readAsString()) as Map; + out.add(TrashItem( + entryDir: e.path, + original: j['original'] as String, + deletedAt: DateTime.parse(j['deletedAt'] as String), + isDir: j['isDir'] == true, + size: (j['size'] as num?)?.toInt() ?? 0, + )); + } catch (_) { + // 정보가 없는 항목 (깨짐) 은 건너뛴다 - 지우지 않는다 + } + } + } + out.sort((a, b) => b.deletedAt.compareTo(a.deletedAt)); + return out; + } + + /// 되살리기: 원래 자리로. 그 자리에 같은 이름이 있으면 "이름 (되살림).확장자". 돌려준 것은 되살린 경로 + Future restore(TrashItem item) async { + var target = item.original; + if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) { + final ext = item.isDir ? '' : p.extension(target); + final base = item.isDir ? target : p.join(p.dirname(target), p.basenameWithoutExtension(target)); + target = '$base (${tr('되살림')})$ext'; + for (var i = 2; await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound; i++) { + target = '$base (${tr('되살림')} $i)$ext'; + } + } + await Directory(p.dirname(target)).create(recursive: true); + if (item.isDir) { + await Directory(item.stored).rename(target); + } else { + await File(item.stored).rename(target); + } + await Directory(item.entryDir).delete(recursive: true); + return target; + } + + /// 영구 삭제 + Future purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true); + + /// 보관 일이 지난 것을 지운다. 지운 수 + Future purgeOlderThan(int days, {DateTime? now}) async { + final limit = (now ?? DateTime.now()).subtract(Duration(days: days)); + var n = 0; + for (final x in await list()) { + if (x.deletedAt.isBefore(limit)) { + try { + await purge(x); + n++; + } catch (_) {} + } + } + return n; + } + + /// 휴지통 크기 합 + Future totalSize() async => (await list()).fold(0, (a, b) => a + b.size); + + /// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에) + bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName))); +} diff --git a/lib/core/batch_rename.dart b/lib/core/batch_rename.dart index 1fedd30..44b07c9 100644 --- a/lib/core/batch_rename.dart +++ b/lib/core/batch_rename.dart @@ -5,11 +5,14 @@ import 'file_ops.dart'; import 'vfs.dart'; /// 78: 일괄 이름 바꾸기 규칙 (Total Commander · X-plore 참고). -/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다. +/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다. class RenameRule { - /// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호. 예: "Show S01E[C]" + /// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호, [D] = 날짜 (2026-10-18), [T] = 시각 (13-05-09). 예: "Show S01E[C]" final String template; + /// X-plore 비교 i: [D] · [T] 의 날짜 - 'photo' 찍은 때 (사진 Exif, 없으면 바뀐 때) / 'modified' 바뀐 때 + final String dateSource; + /// 번호: 시작 · 간격 · 자릿수 (2 → 01, 02 …) final int start, step, digits; @@ -29,18 +32,60 @@ class RenameRule { this.replace = '', this.ignoreCase = true, this.caseMode = 'keep', + this.dateSource = 'photo', }); + /// X-plore 비교 i: 마지막에 쓴 규칙 기억 (설정 batchRenameLast) + Map toJson() => { + 'template': template, + 'start': start, + 'step': step, + 'digits': digits, + 'find': find, + 'replace': replace, + 'ignoreCase': ignoreCase, + 'caseMode': caseMode, + 'dateSource': dateSource, + }; + + /// 틀리거나 없는 값은 처음 값으로 + factory RenameRule.fromJson(Map j) { + int n(String k, int d) => j[k] is int ? j[k] as int : d; + String s(String k, String d) => j[k] is String ? j[k] as String : d; + return RenameRule( + template: s('template', '[N]').isEmpty ? '[N]' : s('template', '[N]'), + start: n('start', 1), + step: n('step', 1), + digits: n('digits', 2).clamp(1, 9), + find: s('find', ''), + replace: s('replace', ''), + ignoreCase: j['ignoreCase'] != false, + caseMode: const ['keep', 'lower', 'upper', 'title'].contains(j['caseMode']) ? j['caseMode'] as String : 'keep', + dateSource: j['dateSource'] == 'modified' ? 'modified' : 'photo', + ); + } + + /// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지) + bool get usesDate => RegExp(r'\[[DT]\]', caseSensitive: false).hasMatch(template); + /// 바꾸는 것이 하나도 없는 규칙 bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep'; - /// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step) - String apply(String name, {required bool isDir, required int index}) { + /// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸) + String apply(String name, {required bool isDir, required int index, DateTime? date}) { final ext = isDir ? '' : p.extension(name); final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length); final n = start + index * step; final counter = n < 0 ? '-${(-n).toString().padLeft(digits, '0')}' : n.toString().padLeft(digits, '0'); - var out = template.replaceAll(RegExp(r'\[N\]', caseSensitive: false), base).replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter); + String two(int v) => v.toString().padLeft(2, '0'); + final day = date == null ? '' : '${date.year.toString().padLeft(4, '0')}-${two(date.month)}-${two(date.day)}'; + // 시각은 ':' 를 쓸 수 없어 (Windows) '-' 로 + final time = date == null ? '' : '${two(date.hour)}-${two(date.minute)}-${two(date.second)}'; + var out = template + .replaceAll(RegExp(r'\[N\]', caseSensitive: false), base) + .replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter) + .replaceAll(RegExp(r'\[D\]', caseSensitive: false), day) + .replaceAll(RegExp(r'\[T\]', caseSensitive: false), time); if (find.isNotEmpty) { out = out.replaceAll(RegExp(RegExp.escape(find), caseSensitive: !ignoreCase), replace); } @@ -70,10 +115,13 @@ class RenameRow { } /// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고) +/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸) List planRename(List paths, RenameRule rule, - {required bool Function(String path) isDir, required Map> siblings}) { + {required bool Function(String path) isDir, required Map> siblings, Map dates = const {}}) { bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); // Windows · 대부분의 NAS 는 대소문자를 가리지 않는다 - final names = [for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i)]; + final names = [ + for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i, date: dates[paths[i]]) + ]; final rows = []; for (var i = 0; i < paths.length; i++) { final dir = vDirname(paths[i]); diff --git a/lib/core/lan_scan.dart b/lib/core/lan_scan.dart new file mode 100644 index 0000000..ae26ba3 --- /dev/null +++ b/lib/core/lan_scan.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:io'; + +/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다. +/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께. +/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다. +class LanHost { + final String address; + final String? name; + const LanHost(this.address, this.name); +} + +/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선 · Tailscale 은 빼고 - 100.64/10 은 넓어 훑지 않는다) +Future> localPrivateIPv4() async { + final out = []; + for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) { + for (final a in ni.addresses) { + final b = a.rawAddress; + final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168); + if (private && !a.isLoopback) out.add(a); + } + } + return out; +} + +/// [hosts] 의 [port] 가 열려 있는지 동시에 [parallel] 개씩 본다. 열린 것만 (주소 순서대로) +Future> scanPort( + List hosts, { + int port = 445, + Duration timeout = const Duration(milliseconds: 400), + int parallel = 64, + bool Function()? cancelled, + void Function(int done, int total)? onProgress, + bool lookupNames = true, +}) async { + final open = {}; + var next = 0, done = 0; + Future worker() async { + while (next < hosts.length) { + if (cancelled?.call() ?? false) return; + final h = hosts[next++]; + try { + final s = await Socket.connect(h, port, timeout: timeout); + open.add(h); + s.destroy(); + } catch (_) {} + onProgress?.call(++done, hosts.length); + } + } + + await Future.wait([for (var i = 0; i < parallel; i++) worker()]); + final sorted = hosts.where(open.contains).toList(); + final out = []; + for (final h in sorted) { + String? name; + if (lookupNames) { + try { + final r = await InternetAddress(h).reverse().timeout(const Duration(seconds: 1)); + if (r.host != h) name = r.host; + } catch (_) {} + } + out.add(LanHost(h, name)); + } + return out; +} + +/// 이 기기의 사설망 (/24 마다) 의 SMB 서버들 +Future> findSmbServers({bool Function()? cancelled, void Function(int done, int total)? onProgress}) async { + final hosts = []; + for (final a in await localPrivateIPv4()) { + final b = a.rawAddress; + for (var i = 1; i < 255; i++) { + final h = '${b[0]}.${b[1]}.${b[2]}.$i'; + if (!hosts.contains(h)) hosts.add(h); + } + } + return scanPort(hosts, cancelled: cancelled, onProgress: onProgress); +} diff --git a/lib/core/photo_date.dart b/lib/core/photo_date.dart new file mode 100644 index 0000000..52c6065 --- /dev/null +++ b/lib/core/photo_date.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:image/image.dart' show decodeJpgExif; + +/// X-plore 비교 i: 일괄 이름 바꾸기의 날짜 - 사진은 찍은 때 (JPEG Exif DateTimeOriginal), 없으면 null. +/// 파일 앞부분만 읽는다 (Exif 는 맨 앞 APP1, 64KB 를 넘지 않음). +Future photoTakenAt(String path) async { + final ext = path.toLowerCase(); + if (!(ext.endsWith('.jpg') || ext.endsWith('.jpeg'))) return null; + RandomAccessFile? f; + try { + f = await File(path).open(); + final head = await f.read(256 * 1024); + return parseExifDate(_exifDate(head)); + } catch (_) { + return null; + } finally { + await f?.close(); + } +} + +String? _exifDate(Uint8List head) { + try { + final exif = decodeJpgExif(head); + if (exif == null) return null; + // 찍은 때 → 디지털로 만든 때 → 바뀐 때 (ifd0 의 DateTime) + for (final v in [exif.exifIfd[0x9003], exif.exifIfd[0x9004], exif.imageIfd[0x0132]]) { + final s = v?.toString().trim(); + if (s != null && s.isNotEmpty) return s; + } + } catch (_) {} + return null; +} + +/// "2024:05:01 12:34:56" → DateTime (틀리거나 0 이면 null) +DateTime? parseExifDate(String? s) { + final m = RegExp(r'^(\d{4})[:\-](\d{2})[:\-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})').firstMatch(s ?? ''); + if (m == null) return null; + final n = [for (var i = 1; i <= 6; i++) int.parse(m[i]!)]; + if (n[0] < 1900 || n[1] < 1 || n[1] > 12 || n[2] < 1 || n[2] > 31) return null; + return DateTime(n[0], n[1], n[2], n[3], n[4], n[5]); +} diff --git a/lib/ui/batch_rename_dialog.dart b/lib/ui/batch_rename_dialog.dart index e55b980..7628042 100644 --- a/lib/ui/batch_rename_dialog.dart +++ b/lib/ui/batch_rename_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../core/batch_rename.dart'; +import '../core/photo_date.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'theme.dart'; @@ -9,31 +10,41 @@ import 'theme.dart'; /// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과. /// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null. /// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다. -Future?> showBatchRename(BuildContext context, List paths, {required bool Function(String) isDir}) => +/// X-plore 비교 i: [initial] 은 마지막에 쓴 규칙 (창이 그 값으로 열림), [onRun] 은 바꾸기를 누를 때의 규칙 (기억하려고) +Future?> showBatchRename(BuildContext context, List paths, + {required bool Function(String) isDir, RenameRule initial = const RenameRule(), void Function(RenameRule rule)? onRun}) => showDialog>( context: context, barrierDismissible: false, - builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir), + builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir, initial: initial, onRun: onRun), ); class _BatchRenameDialog extends StatefulWidget { final List paths; final bool Function(String) isDir; - const _BatchRenameDialog({required this.paths, required this.isDir}); + final RenameRule initial; + final void Function(RenameRule rule)? onRun; + const _BatchRenameDialog({required this.paths, required this.isDir, required this.initial, this.onRun}); @override State<_BatchRenameDialog> createState() => _BatchRenameDialogState(); } class _BatchRenameDialogState extends State<_BatchRenameDialog> { - final _template = TextEditingController(text: '[N]'); - final _start = TextEditingController(text: '1'); - final _step = TextEditingController(text: '1'); - final _digits = TextEditingController(text: '2'); - final _find = TextEditingController(); - final _replace = TextEditingController(); - bool _ignoreCase = true; - String _case = 'keep'; + late final _template = TextEditingController(text: widget.initial.template); + late final _start = TextEditingController(text: '${widget.initial.start}'); + late final _step = TextEditingController(text: '${widget.initial.step}'); + late final _digits = TextEditingController(text: '${widget.initial.digits}'); + late final _find = TextEditingController(text: widget.initial.find); + late final _replace = TextEditingController(text: widget.initial.replace); + late bool _ignoreCase = widget.initial.ignoreCase; + late String _case = widget.initial.caseMode; + late String _dateSource = widget.initial.dateSource; + + /// X-plore 비교 i: 항목마다 바뀐 때 (목록에서) · 찍은 때 (사진 Exif, 틀에 [D] · [T] 가 있을 때 읽음) + final _modified = {}; + final _taken = {}; + bool _takenLoading = false; /// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음) final _siblings = >{}; @@ -50,7 +61,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { Future _loadSiblings() async { for (final dir in {for (final x in widget.paths) vDirname(x)}) { try { - _siblings[dir] = {for (final e in await vList(dir)) vBasename(e.path)}; + final list = await vList(dir); + _siblings[dir] = {for (final e in list) vBasename(e.path)}; + for (final e in list) { + if (widget.paths.contains(e.path)) _modified[e.path] = e.modified; + } } catch (_) { _siblings[dir] = {}; } @@ -58,6 +73,20 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { if (mounted) setState(() => _loading = false); } + /// 사진의 찍은 때를 한 번만 읽는다 (틀에 [D] · [T] 를 넣고 "찍은 때" 일 때) + Future _loadTaken() async { + if (_takenLoading || _taken.isNotEmpty) return; + _takenLoading = true; + for (final x in widget.paths) { + _taken[x] = await photoTakenAt(x); + } + if (mounted) setState(() => _takenLoading = false); + } + + Map get _dates => { + for (final x in widget.paths) x: ?((_dateSource == 'photo' ? _taken[x] : null) ?? _modified[x]), + }; + @override void dispose() { for (final x in [_template, _start, _step, _digits, _find, _replace]) { @@ -75,6 +104,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { replace: _replace.text, ignoreCase: _ignoreCase, caseMode: _case, + dateSource: _dateSource, ); void _insert(String tag) { @@ -92,6 +122,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { _running = true; _error = null; }); + widget.onRun?.call(_rule); try { final done = await runBatchRename([for (final r in rows) (r.path, r.newName)]); if (mounted) Navigator.pop(context, done); @@ -127,10 +158,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { @override Widget build(BuildContext context) { final rule = _rule; - final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings); + if (rule.usesDate && _dateSource == 'photo') _loadTaken(); + final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates); final changed = rows.where((r) => r.changed).length; final bad = rows.where((r) => r.problem != null).length; - final canRun = !_loading && !_running && bad == 0 && changed > 0; + final canRun = !_loading && !_takenLoading && !_running && bad == 0 && changed > 0; return AlertDialog( title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])), // 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게 @@ -147,8 +179,8 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { decoration: InputDecoration( // 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 labelText: tr('이름 틀'), - helperText: tr('[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]'), - helperMaxLines: 3, + helperText: tr('[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]'), + helperMaxLines: 4, isDense: true, ), onChanged: (_) => setState(() {}), @@ -159,6 +191,25 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { const SizedBox(width: 4), OutlinedButton(onPressed: _running ? null : () => _insert('[C]'), child: const Text('[C]')), ]), + // X-plore 비교 i: 날짜 · 시각 넣기와 어느 날짜를 쓸지 + Row(children: [ + OutlinedButton(onPressed: _running ? null : () => _insert('[D]'), child: const Text('[D]')), + const SizedBox(width: 4), + OutlinedButton(onPressed: _running ? null : () => _insert('[T]'), child: const Text('[T]')), + const SizedBox(width: 8), + // 좁은 화면 (412) 에서 긴 항목이 창을 넘었다 - 남은 폭 안에서 줄임 + Expanded( + child: DropdownButton( + isExpanded: true, + value: _dateSource, + items: [ + DropdownMenuItem(value: 'photo', child: Text(tr('날짜: 찍은 때 (사진, 없으면 바뀐 때)'), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: 'modified', child: Text(tr('날짜: 바뀐 때'), overflow: TextOverflow.ellipsis)), + ], + onChanged: _running ? null : (v) => setState(() => _dateSource = v!), + ), + ), + ]), const SizedBox(height: 6), Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [ _num(_start, tr('번호 시작')), diff --git a/lib/ui/explorer_grid.dart b/lib/ui/explorer_grid.dart new file mode 100644 index 0000000..f462608 --- /dev/null +++ b/lib/ui/explorer_grid.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../core/file_ops.dart'; +import '../core/playlist.dart' show isVideoFile; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// X-plore 비교 g: 격자 보기 - 지금 폴더의 그림 · 동영상을 썸네일 격자로 (X-plore 의 그림 보기 방식). +/// 누르면 열기 (그림 보기 · 재생), 길게 누르면 탐색기에서 그 자리로. 돌려주는 값: "그 자리로" 고른 경로 (없으면 null) +class ExplorerGridPage extends StatefulWidget { + final String title; + final List entries; + + /// 그림 확장자 (환경 설정 > 보기) + final List imageExts; + + /// 동영상 썸네일 (없으면 아이콘) + final Future Function(FileEntry e) videoThumb; + final Future Function(FileEntry e) onOpen; + const ExplorerGridPage({ + super.key, + required this.title, + required this.entries, + required this.imageExts, + required this.videoThumb, + required this.onOpen, + }); + + /// 격자에 넣을 것 (그림 · 동영상) + static List pick(List all, List imageExts) => + [for (final e in all) if (!e.isDir && (imageExts.contains(e.ext) || isVideoFile(e.path))) e]; + + @override + State createState() => _ExplorerGridPageState(); +} + +class _ExplorerGridPageState extends State { + /// 칸 크기 (밀어서 키우기 · 줄이기 대신 단추) + double _cell = 140; + + @override + Widget build(BuildContext context) { + final items = ExplorerGridPage.pick(widget.entries, widget.imageExts); + return Scaffold( + appBar: AppBar( + title: Text(trf('{0} · 그림 · 동영상 {1}개', [widget.title, items.length]), maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + IconButton( + tooltip: tr('작게'), + icon: const Icon(Icons.zoom_out), + onPressed: _cell > 80 ? () => setState(() => _cell -= 30) : null, + ), + IconButton( + tooltip: tr('크게'), + icon: const Icon(Icons.zoom_in), + onPressed: _cell < 320 ? () => setState(() => _cell += 30) : null, + ), + ], + ), + body: items.isEmpty + ? Center(child: Text(tr('이 폴더에는 그림 · 동영상이 없습니다'))) + : GridView.builder( + padding: const EdgeInsets.all(6), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: _cell, mainAxisSpacing: 6, crossAxisSpacing: 6), + itemCount: items.length, + itemBuilder: (_, i) => _tile(items[i]), + ), + ); + } + + Widget _tile(FileEntry e) { + final video = isVideoFile(e.path); + final dpr = MediaQuery.devicePixelRatioOf(context); + final px = (_cell * dpr).round(); + Widget picture; + if (video) { + picture = FutureBuilder( + future: widget.videoThumb(e), + builder: (_, s) => s.data == null + ? const Center(child: Icon(Icons.movie_outlined, size: 40, color: JjColors.textDim)) + : Image.file(s.data!, fit: BoxFit.cover, cacheWidth: px), + ); + } else { + picture = Image.file(File(e.path), + fit: BoxFit.cover, + cacheWidth: px, + errorBuilder: (_, _, _) => const Center(child: Icon(Icons.broken_image_outlined, size: 40, color: JjColors.textDim))); + } + return Tooltip( + message: e.name, + child: Material( + color: JjColors.panelHigh, + borderRadius: BorderRadius.circular(6), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => widget.onOpen(e), + onLongPress: () => Navigator.pop(context, e.path), + child: Stack(fit: StackFit.expand, children: [ + picture, + if (video) const Align(alignment: Alignment.center, child: Icon(Icons.play_circle_outline, color: Colors.white70, size: 32)), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white)), + ), + ), + ]), + ), + ), + ); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 5ecb338..36839bf 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; -import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent; +import 'package:flutter/gestures.dart' show kBackMouseButton, kDoubleTapTimeout, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; @@ -32,6 +32,8 @@ import 'player_page.dart'; import 'rsync_setup.dart'; import 'theme.dart'; import 'file_error.dart'; +import 'folder_picker.dart' show pickFolderOrDav; +import 'trash_page.dart'; import 'dav_external.dart'; import '../platform/windows/recycle_bin.dart'; import '../core/sync_preview.dart'; @@ -51,9 +53,13 @@ import 'explorer_search.dart'; import 'quick_look.dart'; import 'explorer_drag.dart'; import 'archive_ui.dart'; +import 'explorer_grid.dart'; +import 'text_viewer.dart'; import 'storage_page.dart'; -import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; +import '../core/archive_ops.dart' + show ArchiveEntry, ArchiveKind, ArchivePasswordException, archiveKindOf, extract7zPlan, extractZipPlan, list7z, listZip, planExtract, unsafeArchivePath; import 'batch_rename_dialog.dart'; +import '../core/app_trash.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; @@ -95,6 +101,10 @@ class ExplorerPage extends StatefulWidget { @visibleForTesting static bool? debugAndroidPaths; + /// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기 + @visibleForTesting + static bool? debugAndroidTrash; + /// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유) @visibleForTesting static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input); @@ -168,7 +178,9 @@ enum ExplorerButton { layout(Icons.view_quilt_outlined, '창 배치'), buttons(Icons.tune, '버튼 구성'), // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 - storage(Icons.pie_chart_outline, '저장 공간 분석'); + storage(Icons.pie_chart_outline, '저장 공간 분석'), + // X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) + trash(Icons.restore_from_trash_outlined, '휴지통'); final IconData icon; final String label; @@ -235,6 +247,9 @@ class _Pane extends ChangeNotifier { /// 74: 범위 고르기의 시작 (마지막으로 표시한 것) String? anchor; + /// X-plore 비교 c: 트리의 "즐겨찾기" 묶음을 펼쳤는지 + bool favoritesOpen = false; + /// 76: 빠른 필터 - 열려 있으면 이름에 [filter] 가 든 것만 보인다 (창마다, 닫으면 지움) bool filterOpen = false; final filterCtl = TextEditingController(); @@ -279,7 +294,11 @@ class _Row { /// 파일 목록 맨 위의 ".." (상위 폴더로) final bool isUp; - const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false}); + + /// X-plore 비교 c: 트리 맨 위 항목 - [otherRoot] 다른 저장 장치 · 서버, [favorites] 즐겨찾기 묶음, [favorite] 즐겨찾기 하나 + final int kind; + static const otherRoot = 1, favorites = 2, favorite = 3; + const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false, this.kind = 0}); } class _ExplorerPageState extends State { @@ -436,6 +455,8 @@ class _ExplorerPageState extends State { Future _init() async { _local = await _loadVolumes(); if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; + // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) + if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); if (Platform.isAndroid) unawaited(_checkAccess()); final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 @@ -832,6 +853,12 @@ class _ExplorerPageState extends State { Future _load(_Pane pane, String dir, {bool force = false}) async { if (!force && pane.cache.containsKey(dir)) return; + // X-plore 비교 f: 압축 안 (읽어 둔 목록에서) + if (_archiveOf(dir) != null) { + pane.cache[dir] = _sorted(_archiveList(dir), dir); + pane.changed(); + return; + } // 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다 if (force) _sizes.forget(dir); pane.loading.add(dir); @@ -963,7 +990,7 @@ class _ExplorerPageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !pane.scroll.hasClients) return; // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) - final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => samePath(r.entry.path, path)); + final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); if (i < 0) return; final pos = pane.scroll.position; // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. @@ -1041,9 +1068,42 @@ class _ExplorerPageState extends State { bool get _selectMode => c.settings.explorerClick != 'open'; /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) + /// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정) + /// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정) + bool get _alwaysMarks => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks; + + /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정) + bool get _swipePanes => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerSwipePanes; + + /// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다 + bool _switchPane(int dir) { + if (!_swipePanes) return false; + setState(() { + if (_dual || _split) { + _active = 1 - _active; + } else { + final t = _panes[0]; + _panes[0] = _panes[1]; + _panes[1] = t; + _active = 0; + } + }); + _remember(_pane, _pane.current); + _snack(trf('창 바꿈: {0}', [_displayPath(_pane.current)])); + return true; + } + + bool get _rootsInTree => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerRootsInTree; + List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) { final out = <_Row>[]; final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0)); + // X-plore 비교 c: 지금 저장 장치 앞뒤로 다른 저장 장치 · 서버 (접힌 줄), 맨 끝에 즐겨찾기 + final roots = _rootsInTree ? _volumes : const <(String, String)>[]; + final here = roots.indexWhere((v) => samePath(v.$1, pane.root)); + for (final (path, _) in roots.take(here < 0 ? 0 : here)) { + out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot)); + } out.add(_Row(rootEntry, 0, isRoot: true)); // 76: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로). // 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게) @@ -1054,7 +1114,8 @@ class _ExplorerPageState extends State { if (foldersOnly && !e.isDir) continue; final at = out.length; out.add(_Row(e, depth)); - final inner = e.isDir && walk(e.path, depth + 1); + // X-plore 비교 f: 펼친 압축 (ZIP · 7z · RAR) 도 안을 폴더처럼 + final inner = (e.isDir || _archives.containsKey(e.path)) && walk(e.path, depth + 1); if (!inner && !_passes(pane, e)) { out.length = at; } else { @@ -1065,9 +1126,83 @@ class _ExplorerPageState extends State { } walk(pane.root, 1); + if (roots.isNotEmpty) { + for (final (path, _) in here < 0 ? roots : roots.skip(here + 1)) { + out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot)); + } + final favs = c.settings.folderBookmarks; + if (favs.isNotEmpty) { + out.add(_Row(FileEntry('\u0000favorites', isDir: true, modified: DateTime(0)), 0, kind: _Row.favorites)); + if (pane.favoritesOpen) { + for (final b in favs) { + out.add(_Row(FileEntry(b.path, isDir: true, modified: DateTime(0)), 1, kind: _Row.favorite)); + } + } + } + } return out; } + /// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기 + Widget _extraRow(_Pane pane, _Row row) { + final look = _look; + final path = row.entry.path; + final volume = _volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2; + final fav = row.kind == _Row.favorite + ? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path)) + : null; + final (IconData icon, Color color, String label, bool? open) = switch (row.kind) { + _Row.otherRoot => ( + isDav(path) + ? Icons.cloud_outlined + : Platform.isWindows + ? Icons.storage + : Icons.sd_storage_outlined, + JjColors.accent, + volume, + false, + ), + _Row.favorites => (Icons.star, Colors.amber, tr('즐겨찾기'), pane.favoritesOpen), + _ => (Icons.star_outline, Colors.amber, fav!.name, null), + }; + void tap() { + setState(() => _active = _panes.indexOf(pane)); + if (row.kind == _Row.favorites) { + pane.favoritesOpen = !pane.favoritesOpen; + pane.changed(); + return; + } + unawaited(_goTo(pane, path, fresh: true).then((_) => _reveal(pane, path))); + } + + return Semantics( + button: true, + label: label, + excludeSemantics: true, + onTap: tap, + child: InkWell( + onTap: tap, + child: Row(children: [ + SizedBox(width: look.indent * row.depth + 4), + SizedBox( + width: treeChevronWidth(defaultTargetPlatform), + child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim), + ), + Icon(icon, size: look.iconSize * 0.8, color: color), + const SizedBox(width: 8), + Expanded( + child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)), + if (fav != null) + Text(_displayPath(path), + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)), + ]), + ), + ]), + ), + ); + } + // ───────── 누르기 ───────── /// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기 @@ -1087,6 +1222,9 @@ class _ExplorerPageState extends State { /// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록) /// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행 /// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로. + /// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고) + (String, DateTime)? _lastFolderTap; + Future _onTap(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); @@ -1109,6 +1247,17 @@ class _ExplorerPageState extends State { await _toggleMark(pane, e); return; } + // X-plore 비교 d: 펼쳐 둔 지금 폴더를 다시 누르면 접기 (설정). 두 번 누르기의 두 번째 (짧은 사이) 는 접지 않는다 + if (e.isDir && !list && c.settings.explorerRetapCollapse) { + final now = DateTime.now(); + final last = _lastFolderTap; + _lastFolderTap = (e.path, now); + final again = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; + if (!again && pane.expanded.contains(e.path) && samePath(pane.current, e.path)) { + await _toggle(pane, e); + return; + } + } if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list); pane.focused = e.path; if (!list) pane.current = vDirname(e.path); @@ -1206,6 +1355,26 @@ class _ExplorerPageState extends State { /// 위 폴더를 골라서 함께 골라진 것이면 그것만 빼고, 같은 폴더의 나머지는 고른 채로 둔다. Future _toggleMark(_Pane pane, FileEntry e) async { pane.focused = e.path; + // X-plore 비교 ⚖ (설정 'xplore'): 폴더 1번 = 폴더 자신 · 2번 = 안의 파일 전체 · 3번 = 풀기 + if (e.isDir && c.settings.explorerFolderMarkRule == 'xplore' && !widget.rsync) { + final self = pane.marked.any((m) => samePath(m, e.path)); + if (self) { + await _load(pane, e.path); + final kids = [for (final x in pane.cache[e.path] ?? const []) if (!x.isDir) x.path]; + setState(() { + pane.marked.removeWhere((m) => samePath(m, e.path)); + pane.marked.addAll(kids); + pane.expanded.add(e.path); + }); + return; + } + final kids = [for (final x in pane.cache[e.path] ?? const []) if (!x.isDir) x.path]; + final inside = kids.isNotEmpty && kids.every((k) => pane.marked.any((m) => samePath(m, k))); + if (inside) { + setState(() => pane.marked.removeWhere((m) => kids.any((k) => samePath(k, m)))); + return; + } + } if (!_isMarked(pane, e.path)) { setState(() { pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로 @@ -1274,6 +1443,14 @@ class _ExplorerPageState extends State { /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) Future _toggle(_Pane pane, FileEntry e) async { setState(() => _active = _panes.indexOf(pane)); + // X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용) + if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) { + if (!await _readArchive(e.path)) return; + pane.cache.remove(e.path); + pane.expanded.add(e.path); + await _load(pane, e.path); + return; + } if (pane.expanded.contains(e.path)) { pane.expanded.remove(e.path); // 접은 폴더 안이 지금 폴더였으면 접은 폴더로 @@ -1297,6 +1474,12 @@ class _ExplorerPageState extends State { /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 Future _open(String path, {_Pane? pane}) async { + // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 + if (_insideArchive(path)) { + final local = await _extractOne(path); + if (local == null || !mounted) return; + path = local; + } if (isVideoFile(path)) { await _play([path]); return; @@ -1319,7 +1502,37 @@ class _ExplorerPageState extends State { final local = await _fetch(path); if (local == null) return; final ok = await c.services.shell.openWith(local); - if (!ok) _snack(_noAppMessage(path)); + if (ok || !mounted) return; + // X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로 + if (textExtensions.contains(extOf(path))) return _viewText(local); + _snack(_noAppMessage(path)); + } + + /// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤) + Future _viewText(String path) async { + final local = await _fetch(path); + if (local == null || !mounted) return; + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => TextViewerPage(path: local))); + } + + /// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로 + Future _grid(_Pane pane, String dir) async { + await _load(pane, dir); + if (!mounted) return; + final list = _shown(pane, dir); + final at = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ExplorerGridPage( + title: vBasename(dir).isEmpty ? dir : vBasename(dir), + entries: list, + imageExts: c.settings.imageExts, + videoThumb: (e) => _BigThumbs.of(c, e), + onOpen: (e) => _open(e.path, pane: pane), + ), + )); + if (at == null || !mounted) return; + await _goTo(pane, dir); + pane.focused = at; + pane.changed(); } /// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart) @@ -1518,8 +1731,12 @@ class _ExplorerPageState extends State { if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), + // X-plore 비교 g: 그림 · 동영상을 썸네일 격자로 + if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')), if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), if (viewable) ('view', Icons.visibility_outlined, tr('보기')), + // X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용) + if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), @@ -1588,6 +1805,10 @@ class _ExplorerPageState extends State { await _playFolder(e.path); case 'playInternal': await _play([e.path], internal: true); + case 'grid': + await _grid(pane, e.path); + case 'text': + await _viewText(e.path); case 'openWith': final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path)); @@ -1697,7 +1918,164 @@ class _ExplorerPageState extends State { // ───────── 기능 ───────── _Pane get _pane => _panes[_active]; - _Pane? get _other => _dual ? _panes[1 - _active] : null; + _Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null; + + // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── + + /// 펼쳐 본 압축의 목록 (압축 경로 → 항목) + final _archives = >{}; + + /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) + bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null; + + /// [path] 가 들어 있는 (펼쳐 둔) 압축 - 압축 자신도 + String? _archiveOf(String path) { + String? best; + for (final a in _archives.keys) { + if ((samePath(path, a) || isSameOrInside(path, a)) && (best == null || a.length > best.length)) best = a; + } + return best; + } + + /// 압축 안의 항목인지 (압축 파일 자신은 아님) + bool _insideArchive(String path) { + final a = _archiveOf(path); + return a != null && !samePath(a, path); + } + + /// 압축 안 경로 → 압축 안의 이름 ("폴더/파일") + String _innerName(String archive, String path) => p.relative(path, from: archive).replaceAll('\\', '/'); + + /// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true + Future _readArchive(String path) async { + if (_archives.containsKey(path)) return true; + final kind = archiveKindOf(path)!; + try { + if (kind == ArchiveKind.zip) { + _archives[path] = await listZip(path); + } else { + final exe = await bundledSevenZip(); + if (exe == null) { + _snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); + return false; + } + _archives[path] = await list7z(exe, path); + } + return true; + } on ArchivePasswordException { + _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); + } catch (e) { + _snack(trf('압축을 읽지 못했습니다: {0}', [e])); + } + return false; + } + + /// 압축 안 폴더 [dir] 의 바로 아래 항목 (목록의 폴더 이름이 없어도 경로에서 폴더를 만든다) + List _archiveList(String dir) { + final arc = _archiveOf(dir)!; + final rel = samePath(dir, arc) ? '' : _innerName(arc, dir); + final out = {}; + for (final a in _archives[arc]!) { + if (a.isLink) continue; + final n = a.name.replaceAll('\\', '/').replaceAll(RegExp(r'^(\./)+|/+$'), ''); + if (n.isEmpty || unsafeArchivePath(n) != null) continue; + if (rel.isNotEmpty && !n.startsWith('$rel/')) continue; + final rest = rel.isEmpty ? n : n.substring(rel.length + 1); + if (rest.isEmpty) continue; + final first = rest.split('/').first; + final folder = rest.contains('/') || a.isDir; + final path = p.joinAll([arc, ...(rel.isEmpty ? first : '$rel/$first').split('/')]); + final old = out[first]; + if (old == null || (folder && !old.isDir)) { + out[first] = FileEntry(path, isDir: folder, size: folder ? 0 : a.size, modified: a.modified ?? DateTime(0)); + } + } + return out.values.toList(); + } + + /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) + Future _extractOne(String path) async { + final arc = _archiveOf(path)!; + final inner = _innerName(arc, path); + final dest = p.join(await c.services.storage.tempDirectory(), 'jj_arcview', '${arc.hashCode.toUnsigned(32)}'); + final plan = planExtract(_archives[arc]!, names: [inner]); + if (plan.encrypted) { + _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); + return null; + } + try { + if (archiveKindOf(arc) == ArchiveKind.zip) { + await extractZipPlan(arc, dest, plan, conflict: NameConflict.overwrite); + } else { + final exe = await bundledSevenZip(); + if (exe == null) return null; + await extract7zPlan(exe, arc, dest, plan, conflict: NameConflict.overwrite); + } + } catch (e) { + _snack(trf('압축에서 꺼내지 못했습니다: {0}', [e])); + return null; + } + final out = p.joinAll([dest, ...inner.split('/')]); + return File(out).existsSync() ? out : null; + } + + /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로 + Future _extractFromArchive(List paths, String? otherDir) async { + final arc = _archiveOf(paths.firstWhere(_insideArchive))!; + final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _innerName(arc, x)]; + final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); + if (done != null && mounted) await _refreshAll([done]); + } + + /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true + /// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) + bool _blockArchive(List paths, {bool self = false}) { + if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(x)))) return false; + _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); + return true; + } + + /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) + bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other'; + + /// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말) + String _folderName(String dir) => + _volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _displayPath(dir) : vBasename(dir))).$2; + + /// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더] + Widget _targetBar() { + final t = _panes[1 - _active]; + return Material( + color: JjColors.accent.withValues(alpha: 0.10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row(children: [ + const Icon(Icons.arrow_forward, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + Expanded( + child: Tooltip( + message: _displayPath(t.current), + child: Text(trf('복사 · 이동 갈 곳: {0}', [_folderName(t.current)]), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), + ), + ), + TextButton( + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () => _goTo(t, _pane.current), + child: Text(tr('지금 폴더로')), + ), + TextButton( + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () async { + final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current); + if (d != null && mounted) await _goTo(t, d); + }, + child: Text(tr('다른 폴더')), + ), + ]), + ), + ); + } /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) List _targets(_Pane pane) { @@ -1749,6 +2127,21 @@ class _ExplorerPageState extends State { } } + /// X-plore b: 가운데 버튼 길게 누르기 - 복사 ↔ 이동, 지우기 → 영구 삭제로 묻기 (확인 창은 그대로) + Future _buttonLong(ExplorerButton b) async { + switch (b) { + case ExplorerButton.copy: + return _button(ExplorerButton.move); + case ExplorerButton.move: + return _button(ExplorerButton.copy); + case ExplorerButton.delete: + _shiftDelete = true; + return _button(ExplorerButton.delete); + default: + return; + } + } + Future _button(ExplorerButton b) async { final pane = _pane; switch (b) { @@ -1783,6 +2176,8 @@ class _ExplorerPageState extends State { final t = _targets(pane); await _addMkv(t.isEmpty ? [pane.current] : t); case ExplorerButton.newFolder: + // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 + if (_blockArchive([pane.current], self: true)) return; final name = await _askName(tr('새 폴더'), ''); if (name == null) return; try { @@ -1825,6 +2220,8 @@ class _ExplorerPageState extends State { await _search(pane); case ExplorerButton.storage: await _storage(pane, pane.current); + case ExplorerButton.trash: + await _openTrash(); case ExplorerButton.sort: await _sortDialog(); case ExplorerButton.hidden: @@ -1893,6 +2290,7 @@ class _ExplorerPageState extends State { } Future _rename(_Pane pane, String path) async { + if (_blockArchive([path])) return; final name = await _askName(tr('이름 변경'), vBasename(path)); if (name == null || name == vBasename(path)) return; try { @@ -1910,6 +2308,7 @@ class _ExplorerPageState extends State { /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] Future _batchRename(_Pane pane, List paths) async { + if (_blockArchive(paths)) return; int at(String x) { final i = (pane.cache[vDirname(x)] ?? const []).indexWhere((e) => samePath(e.path, x)); return i < 0 ? 1 << 30 : i; @@ -1919,7 +2318,10 @@ class _ExplorerPageState extends State { final d = vDirname(a).compareTo(vDirname(b)); return d != 0 ? d : at(a).compareTo(at(b)); }); - final done = await showBatchRename(context, ordered, isDir: vIsDirSync); + final done = await showBatchRename(context, ordered, + isDir: vIsDirSync, + initial: RenameRule.fromJson(c.settings.batchRenameLast), + onRun: (r) => c.updateSettings((s) => s.batchRenameLast = r.toJson())); if (done == null || done.isEmpty || !mounted) return; await _afterBatchRename(pane, done); if (!mounted) return; @@ -1958,6 +2360,7 @@ class _ExplorerPageState extends State { /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). Future _delete(_Pane pane, List paths) async { + if (_blockArchive(paths)) return; paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) final shift = _shiftDelete; _shiftDelete = false; @@ -1967,7 +2370,10 @@ class _ExplorerPageState extends State { } // 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다) final local = Platform.isWindows && paths.every((x) => !isDav(x)); - final canRecycle = local && c.settings.recycleOnDelete && paths.every(hasRecycleBin); + // X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔) + final trash = _appTrash; + final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x)); + final canRecycle = (local && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash; var permanent = !canRecycle || shift; final ok = await showDialog( context: context, @@ -2001,12 +2407,18 @@ class _ExplorerPageState extends State { child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), + if (canTrash && !permanent) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), if (canRecycle) CheckboxListTile( contentPadding: EdgeInsets.zero, value: permanent, onChanged: (v) => set(() => permanent = v ?? false), - title: Text(tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), + title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), ), ]), actions: [ @@ -2029,7 +2441,19 @@ class _ExplorerPageState extends State { // 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터 final since = DateTime.now().subtract(const Duration(seconds: 2)); final sent = []; + // X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다) + final trashed = []; + final notTrashed = <(String, String)>[]; for (final x in paths) { + if (!permanent && canTrash) { + try { + trashed.add(await trash.moveToTrash(x)); + recycled++; + } on TrashMoveException catch (e) { + notTrashed.add((x, '${e.cause}')); + } + continue; + } try { if (!permanent) { switch (recycle(x)) { @@ -2050,6 +2474,39 @@ class _ExplorerPageState extends State { failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e')); } } + // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 + if (notTrashed.isNotEmpty && mounted) { + final nuke = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + icon: const Icon(Icons.warning_amber_rounded, color: Colors.orangeAccent), + title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)), + const SizedBox(height: 8), + Text(tr('영구 삭제하면 되살릴 수 없습니다.')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + onPressed: () => Navigator.pop(ctx, true), + child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length])), + ), + ], + ), + ); + if (nuke == true) { + for (final (x, _) in notTrashed) { + try { + await FileOps().delete([x]); + } catch (e) { + failed.add((x, '$e')); + } + } + } + } if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) { final nukeLong = await showDialog( context: context, @@ -2116,7 +2573,11 @@ class _ExplorerPageState extends State { ScaffoldMessenger.of(context).showSnackBar(SnackBar( duration: const Duration(seconds: 10), content: Text(text), - action: sent.isEmpty ? null : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), + action: trashed.isNotEmpty + ? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed)) + : sent.isEmpty + ? null + : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), )); } for (final x in _panes) { @@ -2128,6 +2589,45 @@ class _ExplorerPageState extends State { await _refreshAll(paths.map(p.dirname)); } + /// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제) + AppTrash? get _appTrash => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.androidTrashDays > 0 + ? AppTrash([for (final (v, _) in _local) v]) + : null; + + /// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로 + Future _undoTrash(List items) async { + final trash = _appTrash; + if (trash == null) return; + final back = []; + for (final i in items) { + try { + back.add(await trash.restore(i)); + } catch (_) {} + } + await _refreshAll(back.map(p.dirname)); + _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); + } + + /// 시험: [휴지통] 열기 + @visibleForTesting + Future debugOpenTrash() => _openTrash(); + + /// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통 + Future _openTrash() async { + final trash = _appTrash; + if (trash == null) { + if (Platform.isWindows) { + await Process.start('explorer.exe', ['shell:RecycleBinFolder']); + } else { + _snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).')); + } + return; + } + final back = await Navigator.of(context).push>( + MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays))); + if (back != null && back.isNotEmpty && mounted) await _refreshAll(back.map(p.dirname)); + } + /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { final failed = []; @@ -2153,6 +2653,15 @@ class _ExplorerPageState extends State { /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) Future _transfer(_Pane pane, List sources, {required bool move}) async { + // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 + if (sources.any(_insideArchive)) { + if (move) { + _blockArchive(sources); + return; + } + await _extractFromArchive(sources, _other?.current); + return; + } sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만 final other = _other; if (other == null) { @@ -2266,6 +2775,13 @@ class _ExplorerPageState extends State { _snack(_pickFirst(move)); return false; } + // X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로 + if (_blockArchive([dest], self: true)) return false; + if (sources.any(_insideArchive)) { + if (move) return !_blockArchive(sources); + await _extractFromArchive(sources, dest); + return true; + } // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) { _snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); @@ -3418,7 +3934,19 @@ class _ExplorerPageState extends State { if (_searchBack != null) _searchBackBar(), Expanded( child: Stack(children: [ - Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), + // X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만) + Positioned.fill( + child: !_ready + ? const Center(child: CircularProgressIndicator()) + : _swipePanes + ? GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragEnd: (d) { + final v = d.primaryVelocity ?? 0; + if (v.abs() >= 500) _switchPane(v < 0 ? 1 : -1); + }, + child: _body()) + : _body()), // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()), ]), @@ -3492,6 +4020,8 @@ class _ExplorerPageState extends State { final bar = s.explorerToolbar == 'hidden' && !widget.rsync ? null : _Toolbar( + // X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음) + onLongPress: widget.rsync ? null : _buttonLong, // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons), vertical: side && !rsyncOne, @@ -3532,7 +4062,10 @@ class _ExplorerPageState extends State { if (edge) bar, ] else ...[ if (middle) bar, - Expanded(child: keep(0, _paneView(0))), + Expanded( + child: _hiddenTarget + ? Column(children: [ListenableBuilder(listenable: _panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, _paneView(0)))]) + : keep(0, _paneView(0))), if (edge) bar, ], ]; @@ -3626,6 +4159,15 @@ class _ExplorerPageState extends State { /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) Future _onDrop(DragLoad load, int to, String dest) async { if (!canDropInto(load, to, dest) || !mounted) return; + // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 + if (_insideArchive(dest) || _archives.containsKey(dest)) { + _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); + return; + } + if (load.paths.any(_insideArchive)) { + await _extractFromArchive(load.paths, dest); + return; + } setState(() => _active = to); final mode = c.settings.explorerDropAction; final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; @@ -3812,21 +4354,24 @@ class _ExplorerPageState extends State { itemCount: rows.length, itemExtent: look.rowHeightOf(context), // 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey]) - itemBuilder: (_, k) => KeyedSubtree(key: _rowKey(rows[k]), child: _rowView(pane, rows[k], compact: foldersOnly)), + itemBuilder: (_, k) => KeyedSubtree( + key: _rowKey(rows[k]), + child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], compact: foldersOnly)), findChildIndexCallback: _rowIndexOf(rows), ), // 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다) if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)), ]), ), - if (_selecting && !foldersOnly) _marksBar(pane), + if ((_selecting || (_alwaysMarks && pane.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane), ])); } /// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을 /// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고 /// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build") - static Key _rowKey(_Row r) => ValueKey('${r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}'); + static Key _rowKey(_Row r) => + ValueKey('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}'); /// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리) static int? Function(Key) _rowIndexOf(List<_Row> rows) { @@ -4331,7 +4876,7 @@ class _ExplorerPageState extends State { final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); final marked = _isMarked(pane, e.path); - final open = e.isDir && !list && pane.expanded.contains(e.path); + final open = (e.isDir || _archives.containsKey(e.path)) && !list && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final name = row.isUp ? '..' @@ -4353,7 +4898,7 @@ class _ExplorerPageState extends State { Widget chevron() => SizedBox( width: chevronWidth, height: look.rowHeightOf(context), - child: e.isDir && !list && !row.isUp + child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp ? (loading ? const Center( child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) @@ -4484,7 +5029,7 @@ class _ExplorerPageState extends State { ), ), // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) - if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync)) + if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks)) SizedBox( width: 36, child: IconButton( @@ -4505,7 +5050,7 @@ class _ExplorerPageState extends State { }, ), ) - else if (_selecting || widget.rsync) + else if (_selecting || widget.rsync || _alwaysMarks) const SizedBox(width: 36), ]), ), @@ -4594,12 +5139,16 @@ class _Toolbar extends StatelessWidget { /// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기) final Set pinned; + + /// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없는 버튼은 null + final void Function(ExplorerButton)? onLongPress; const _Toolbar( {required this.buttons, required this.vertical, required this.onPressed, required this.enabled, required this.selected, + this.onLongPress, this.twoPanes = true, this.pinned = const {ExplorerButton.orient}, bool? panesSide}) @@ -4609,6 +5158,14 @@ class _Toolbar extends StatelessWidget { /// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우 final bool panesSide; + /// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null) + static String? longPressLabel(ExplorerButton b) => switch (b) { + ExplorerButton.copy => '이동', + ExplorerButton.move => '복사', + ExplorerButton.delete => '영구 삭제', + _ => null, + }; + /// 118: 지금 배치를 보여 준다 (누르면 반대로) String _orientLabel() => !twoPanes ? '두 창으로' : panesSide ? '좌우 ⇆' : '위아래 ⇅'; String _orientTip() => !twoPanes @@ -4645,10 +5202,14 @@ class _Toolbar extends StatelessWidget { Widget btn(ExplorerButton b) { final on = enabled(b); final sel = selected(b); + final pair = longPressLabel(b); + final long = onLongPress; return Tooltip( - message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)), + message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)) + + (pair != null && long != null ? ' · ${trf('길게 누르면 {0}', [tr(pair)])}' : ''), child: InkWell( onTap: on ? () => onPressed(b) : null, + onLongPress: on && pair != null && long != null ? () => long(b) : null, borderRadius: BorderRadius.circular(6), child: SizedBox( width: 72, diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart new file mode 100644 index 0000000..ff546fa --- /dev/null +++ b/lib/ui/file_tools.dart @@ -0,0 +1,271 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; + +import '../core/file_ops.dart' show formatSize; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 (읽기만 - 파일을 바꾸지 않는다) + +/// 파일의 MD5 · SHA-256 (조각으로 읽어 큰 파일도 메모리를 쓰지 않음). [onProgress] 0~1, [cancelled] 면 null +Future<({String md5, String sha256})?> fileHashes(String path, {void Function(double)? onProgress, bool Function()? cancelled}) async { + final f = File(path); + final total = await f.length(); + final m = _Sink(), s = _Sink(); + final mIn = md5.startChunkedConversion(m), sIn = sha256.startChunkedConversion(s); + var done = 0; + await for (final chunk in f.openRead()) { + if (cancelled?.call() ?? false) return null; + mIn.add(chunk); + sIn.add(chunk); + done += chunk.length; + onProgress?.call(total == 0 ? 1 : done / total); + } + mIn.close(); + sIn.close(); + return (md5: '${m.value}', sha256: '${s.value}'); +} + +class _Sink implements Sink { + Digest? value; + @override + void add(Digest data) => value = data; + @override + void close() {} +} + +/// 해시 창 - 계산 (진행 · 취소) 뒤 MD5 · SHA-256, 붙여 넣은 값과 비교 +Future showFileHash(BuildContext context, String path) async { + var progress = 0.0; + var cancelled = false; + ({String md5, String sha256})? result; + Object? error; + final compare = TextEditingController(); + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder(builder: (ctx, set) { + if (result == null && error == null && progress == 0) { + progress = 0.0001; + fileHashes(path, onProgress: (v) { + if (ctx.mounted) set(() => progress = v); + }, cancelled: () => cancelled) + .then((r) { + if (ctx.mounted) set(() => result = r); + }, onError: (Object e) { + if (ctx.mounted) set(() => error = e); + }); + } + final want = compare.text.trim().toLowerCase(); + final match = result != null && want.isNotEmpty ? (want == result!.md5 || want == result!.sha256) : null; + Widget line(String name, String v) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row(children: [ + SizedBox(width: 72, child: Text(name, style: const TextStyle(fontWeight: FontWeight.w600))), + Expanded(child: SelectableText(v, style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), + IconButton( + tooltip: tr('복사'), + icon: const Icon(Icons.copy, size: 16), + onPressed: () => Clipboard.setData(ClipboardData(text: v)), + ), + ]), + ); + return AlertDialog( + scrollable: true, + title: Text(trf('해시 · {0}', [p.basename(path)])), + content: SizedBox( + width: 560, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (error != null) Text(trf('읽지 못했습니다: {0}', [error]), style: const TextStyle(color: JjColors.danger)), + if (result == null && error == null) LinearProgressIndicator(value: progress), + if (result != null) ...[ + line('MD5', result!.md5), + line('SHA-256', result!.sha256), + const SizedBox(height: 10), + TextField( + controller: compare, + decoration: InputDecoration(labelText: tr('비교할 값 붙여넣기 (받은 곳의 해시)'), isDense: true), + onChanged: (_) => set(() {}), + ), + if (match != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(match ? tr('같습니다 ✔') : tr('다릅니다 - 파일이 바뀌었거나 다른 파일입니다'), + style: TextStyle(color: match ? Colors.greenAccent : JjColors.danger)), + ), + ], + ]), + ), + actions: [ + TextButton( + onPressed: () { + cancelled = true; + Navigator.pop(ctx); + }, + child: Text(result == null && error == null ? tr('취소') : tr('닫기')), + ), + ], + ); + }), + ); + compare.dispose(); +} + +/// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기] +class HexViewerPage extends StatefulWidget { + final String path; + final int chunk; + const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024}); + + /// 한 줄 (16 바이트): "00000010 41 42 ... AB.." + static String line(int offset, List bytes) { + final hex = StringBuffer(); + for (var i = 0; i < 16; i++) { + hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' '); + hex.write(i == 7 ? ' ' : ' '); + } + final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]); + return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text'; + } + + @override + State createState() => _HexViewerPageState(); +} + +class _HexViewerPageState extends State { + final _data = []; + int _size = 0; + Object? _error; + + @override + void initState() { + super.initState(); + _more(); + } + + Future _more() async { + try { + final f = File(widget.path); + _size = await f.length(); + final to = (_data.length + widget.chunk).clamp(0, _size); + await for (final x in f.openRead(_data.length, to)) { + _data.addAll(x); + } + } catch (e) { + _error = e; + } + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final rows = (_data.length + 15) ~/ 16; + return Scaffold( + appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)), + body: _error != null + ? Center(child: Text(trf('읽지 못했습니다: {0}', [_error]))) + : Column(children: [ + Expanded( + child: Scrollbar( + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: rows, + itemExtent: 18, + itemBuilder: (_, i) => Text( + HexViewerPage.line(i * 16, _data.sublist(i * 16, (i * 16 + 16).clamp(0, _data.length))), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + maxLines: 1, + softWrap: false, + ), + ), + ), + ), + if (_data.length < _size) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(6), + child: Row(children: [ + Expanded( + child: Text(trf('{0} / {1} 읽음', [formatSize(_data.length), formatSize(_size)]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), + ]), + ), + ), + ]), + ); + } +} + +/// 폴더 속성: 안의 파일 · 폴더 수, 크기, 만든 · 바뀐 때 (링크 · junction 은 따라가지 않음) +Future<({int files, int folders, int bytes, int links})> folderStats(String dir, {bool Function()? cancelled}) async { + var files = 0, folders = 0, bytes = 0, links = 0; + await for (final e in Directory(dir).list(recursive: true, followLinks: false).handleError((_) {})) { + if (cancelled?.call() ?? false) break; + if (e is Link) { + links++; + } else if (e is Directory) { + folders++; + } else if (e is File) { + files++; + try { + bytes += await e.length(); + } catch (_) {} + } + } + return (files: files, folders: folders, bytes: bytes, links: links); +} + +Future showFolderProperties(BuildContext context, String dir) async { + var cancelled = false; + final stat = await FileStat.stat(dir); + if (!context.mounted) return; + String date(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + final future = folderStats(dir, cancelled: () => cancelled); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('폴더 속성 · {0}', [p.basename(dir)])), + content: SizedBox( + width: 480, + child: FutureBuilder( + future: future, + builder: (_, s) { + final r = s.data; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + SelectableText(dir, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + if (r == null) const LinearProgressIndicator(), + if (r != null) ...[ + Text(trf('크기: {0} ({1} 바이트)', [formatSize(r.bytes), r.bytes])), + Text(trf('파일 {0}개 · 폴더 {1}개', [r.files, r.folders])), + if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), + ], + const SizedBox(height: 6), + Text(trf('바뀐 때: {0}', [date(stat.modified)])), + Text(trf('열어 본 때: {0}', [date(stat.accessed)])), + ]); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + cancelled = true; + Navigator.pop(ctx); + }, + child: Text(tr('닫기')), + ), + ], + ), + ); + cancelled = true; +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 9dd5f2b..f20b4a0 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -840,6 +840,72 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!), ), ), + // X-plore 비교 e: Android 앱 휴지통 보관 일 + if (Platform.isAndroid) + SettingTile( + title: Text(tr('휴지통 보관')), + subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')), + trailing: DropdownButton( + value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14, + items: [ + for (final d in const [0, 3, 7, 14, 30, 60]) + DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))), + ], + onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!), + ), + ), + // X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙 + SwitchListTile( + title: Text(tr('X-plore 스타일: 표시 동그라미 늘 보이기')), + subtitle: Text(tr('끄면 선택 모드에서만 보입니다 (다른 스타일은 늘 선택 모드에서만)')), + value: s.explorerAlwaysMarks, + onChanged: (v) => c.updateSettings((x) => x.explorerAlwaysMarks = v), + ), + SwitchListTile( + title: Text(tr('X-plore 스타일: 옆으로 밀면 창 바꾸기')), + subtitle: Text(tr('탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다')), + value: s.explorerSwipePanes, + onChanged: (v) => c.updateSettings((x) => x.explorerSwipePanes = v), + ), + SettingTile( + title: Text(tr('폴더를 표시하면')), + subtitle: Text(tr('X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기')), + trailing: DropdownButton( + value: s.explorerFolderMarkRule, + items: [ + DropdownMenuItem(value: 'tree', child: Text(tr('폴더와 안의 것 전체 (기본)'))), + DropdownMenuItem(value: 'xplore', child: Text(tr('X-plore 식 (1 · 2 · 3번)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerFolderMarkRule = v!), + ), + ), + // X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일) + SwitchListTile( + title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')), + subtitle: Text(tr('X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)')), + value: s.explorerRootsInTree, + onChanged: (v) => c.updateSettings((x) => x.explorerRootsInTree = v), + ), + // X-plore 비교 d: 지금 폴더를 다시 누르면 접기 + SwitchListTile( + title: Text(tr('펼친 폴더를 다시 누르면 접기')), + subtitle: Text(tr('X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)')), + value: s.explorerRetapCollapse, + onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v), + ), + // X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳 + SettingTile( + title: Text(tr('한 창일 때 복사 · 이동')), + subtitle: Text(tr('숨은 다른 창으로: X-plore 처럼 위에 "갈 곳" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다')), + trailing: DropdownButton( + value: s.explorerOnePaneCopy, + items: [ + DropdownMenuItem(value: 'clip', child: Text(tr('담아 두고 붙여넣기 (기본)'))), + DropdownMenuItem(value: 'other', child: Text(tr('숨은 다른 창으로'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerOnePaneCopy = v!), + ), + ), // 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한 CopySyncSettings(c: c), // WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭) diff --git a/lib/ui/storage_chart.dart b/lib/ui/storage_chart.dart new file mode 100644 index 0000000..2c94bc7 --- /dev/null +++ b/lib/ui/storage_chart.dart @@ -0,0 +1,121 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../core/file_ops.dart' show formatSize; +import 'theme.dart'; + +/// X-plore 비교 P3 (87 위): 저장 공간 그림 - 크기만큼 넓이를 차지하는 칸 (squarified treemap). +/// 칸을 누르면 그 폴더 안으로 ([path] 가 없는 칸 - 이 폴더의 파일 · 나머지 - 은 누를 수 없음). +class StorageBlock { + final String label; + final int size; + final String? path; + const StorageBlock(this.label, this.size, [this.path]); +} + +class StorageTreemap extends StatelessWidget { + final List blocks; + final void Function(StorageBlock b) onTap; + const StorageTreemap({super.key, required this.blocks, required this.onTap}); + + /// 칸 색 (크기 차례대로 돌려 씀) + static const _colors = [ + Color(0xFF2E7D9A), Color(0xFF7E57C2), Color(0xFF43A047), Color(0xFFEF6C00), + Color(0xFFC2185B), Color(0xFF00897B), Color(0xFF5C6BC0), Color(0xFF8D6E63), + ]; + + /// [blocks] (큰 것부터) 를 [area] 안에 나눠 놓은 사각형들. 크기가 0 인 것은 빠짐 + static List<(StorageBlock, Rect)> layout(List blocks, Rect area) { + final items = [for (final b in blocks) if (b.size > 0) b]..sort((a, b) => b.size.compareTo(a.size)); + final total = items.fold(0, (s, b) => s + b.size); + if (items.isEmpty || total == 0 || area.isEmpty) return const []; + final scale = area.width * area.height / total; + final out = <(StorageBlock, Rect)>[]; + var rect = area; + var i = 0; + while (i < items.length) { + final side = math.min(rect.width, rect.height); + // 한 줄에 넣을 것: 가장 나쁜 가로세로 비가 더 나빠지기 전까지 + final row = [items[i]]; + double worst(List r) { + final s = r.fold(0, (t, b) => t + b.size * scale); + final mx = r.map((b) => b.size * scale).reduce(math.max), mn = r.map((b) => b.size * scale).reduce(math.min); + return math.max(side * side * mx / (s * s), (s * s) / (side * side * mn)); + } + + var j = i + 1; + while (j < items.length && worst([...row, items[j]]) <= worst(row)) { + row.add(items[j]); + j++; + } + final rowArea = row.fold(0, (t, b) => t + b.size * scale); + final thick = side == 0 ? 0.0 : rowArea / side; + var at = 0.0; + final horizontal = rect.width >= rect.height; // 넓으면 왼쪽에 세로 줄, 높으면 위에 가로 줄 + for (final b in row) { + final len = thick == 0 ? 0.0 : b.size * scale / thick; + out.add(( + b, + horizontal + ? Rect.fromLTWH(rect.left, rect.top + at, thick, len) + : Rect.fromLTWH(rect.left + at, rect.top, len, thick), + )); + at += len; + } + rect = horizontal + ? Rect.fromLTRB(rect.left + thick, rect.top, rect.right, rect.bottom) + : Rect.fromLTRB(rect.left, rect.top + thick, rect.right, rect.bottom); + i = j; + } + return out; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (_, c) { + final cells = layout(blocks, Offset.zero & Size(c.maxWidth, c.maxHeight)); + if (cells.isEmpty) return const SizedBox(); + final total = blocks.fold(0, (s, b) => s + math.max(0, b.size)); + return Stack(children: [ + for (var k = 0; k < cells.length; k++) + Positioned.fromRect( + rect: cells[k].$2.deflate(1), + child: _cell(cells[k].$1, k, total), + ), + ]); + }); + } + + Widget _cell(StorageBlock b, int k, int total) { + final color = b.path == null ? JjColors.panelHigh : _colors[k % _colors.length]; + final pct = total == 0 ? 0 : (b.size * 100 / total); + return Tooltip( + message: '${b.label} · ${formatSize(b.size)} · ${pct.toStringAsFixed(1)}%', + child: Material( + color: color, + borderRadius: BorderRadius.circular(3), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: b.path == null ? null : () => onTap(b), + child: LayoutBuilder( + builder: (_, c) => c.maxWidth < 44 || c.maxHeight < 30 + ? const SizedBox() + : Padding( + padding: const EdgeInsets.all(4), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(b.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)), + if (c.maxHeight >= 46) + Text('${formatSize(b.size)} · ${pct.toStringAsFixed(pct < 10 ? 1 : 0)}%', + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white70)), + ]), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart index 4bef8f6..fdbf208 100644 --- a/lib/ui/storage_page.dart +++ b/lib/ui/storage_page.dart @@ -4,6 +4,7 @@ import '../core/file_ops.dart'; import '../core/storage_scan.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; +import 'storage_chart.dart'; import 'theme.dart'; /// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. @@ -30,7 +31,7 @@ class _StorageAnalysisPageState extends State { var _dirs = 0, _bytes = 0; var _stop = false; late String _cur = widget.root; - var _tab = 0; // 0 큰 폴더 · 1 큰 파일 + var _tab = 0; // 0 큰 폴더 · 1 큰 파일 · 2 그림 (X-plore 비교 P3) @override void initState() { @@ -144,10 +145,11 @@ class _StorageAnalysisPageState extends State { child: Wrap(spacing: 6, children: [ ChoiceChip(label: Text(tr('큰 폴더')), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)), ChoiceChip(label: Text(tr('큰 파일')), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)), + ChoiceChip(label: Text(tr('칸 그림')), selected: _tab == 2, onSelected: (_) => setState(() => _tab = 2)), ]), ), const Divider(height: 1), - Expanded(child: _tab == 0 ? _folders(s) : _files(s)), + Expanded(child: switch (_tab) { 0 => _folders(s), 1 => _files(s), _ => _chart(s) }), ]), ), ); @@ -183,6 +185,25 @@ class _StorageAnalysisPageState extends State { ); } + /// X-plore 비교 P3: 지금 폴더 안을 크기만큼의 칸으로. 큰 폴더 30개까지 따로, 나머지 폴더는 한 칸, 이 폴더 바로 안의 파일도 한 칸 + Widget _chart(StorageScan s) { + final dirs = s.biggestIn(_cur); + final total = s.sizeOf(_cur); + final shown = dirs.take(30).toList(); + final rest = dirs.skip(30).fold(0, (t, d) => t + s.sizeOf(d)); + final own = total - dirs.fold(0, (t, d) => t + s.sizeOf(d)); + final blocks = [ + for (final d in shown) StorageBlock(scanName(d), s.sizeOf(d), d), + if (rest > 0) StorageBlock(trf('나머지 폴더 {0}개', [dirs.length - shown.length]), rest), + if (own > 0) StorageBlock(tr('이 폴더의 파일'), own), + ]; + if (total == 0 || blocks.every((b) => b.size <= 0)) return Center(child: Text(tr('비어 있습니다'))); + return Padding( + padding: const EdgeInsets.all(8), + child: StorageTreemap(blocks: blocks, onTap: (b) => setState(() => _cur = b.path!)), + ); + } + Widget _files(StorageScan s) { final list = [for (final f in s.bigFiles) if (isSameOrInside(f.path, _cur)) f]; if (list.isEmpty) return Center(child: Text(tr('큰 파일이 없습니다'))); diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart new file mode 100644 index 0000000..9097737 --- /dev/null +++ b/lib/ui/text_viewer.dart @@ -0,0 +1,189 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; + +import '../core/charset_detector.dart'; +import '../core/file_ops.dart' show formatSize; +import '../core/text_codec.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// X-plore 비교 h: 글 뷰어 - 글 파일 전체를 읽기 전용으로. 큰 파일은 앞에서부터 나눠 읽고 [더 읽기], +/// 글자 코드는 스스로 판별 (UTF-8 · UTF-16 · CP949 · Shift-JIS · GBK) 하고 위에서 바꿀 수 있다. 줄 번호 · 글자 크기 · 복사. +class TextViewerPage extends StatefulWidget { + final String path; + + /// 한 번에 읽는 양 (시험에서 줄인다) + final int chunk; + const TextViewerPage({super.key, required this.path, this.chunk = 256 * 1024}); + + @override + State createState() => _TextViewerPageState(); +} + +class _TextViewerPageState extends State { + final _bytes = BytesBuilder(copy: false); + int _size = 0; + String? _charset; + + /// 사용자가 위에서 고른 글자 코드 (고르기 전에는 더 읽을 때마다 다시 판별 - 앞 조각만으로는 틀릴 수 있다) + bool _picked = false; + String _text = ''; + Object? _error; + double _font = 13; + bool _lineNumbers = true; + + @override + void initState() { + super.initState(); + _more(); + } + + bool get _complete => _bytes.length >= _size; + + Future _more() async { + try { + final f = File(widget.path); + _size = await f.length(); + final from = _bytes.length; + final to = (from + widget.chunk).clamp(0, _size); + await for (final x in f.openRead(from, to)) { + _bytes.add(x); + } + final all = _bytes.toBytes(); + if (!_picked) _charset = detectCharset(Uint8List.sublistView(all, 0, all.length.clamp(0, 256 * 1024))); + _decode(all); + } catch (e) { + _error = e; + } + if (mounted) setState(() {}); + } + + void _decode(Uint8List all) { + String t; + // 나눠 읽은 끝에서 두 바이트 글자 (CP949 등) 가 잘리면 글자 코드 풀이가 실패한다 - 끝 바이트를 다음 조각까지 미룬다 + var cut = 0; + while (true) { + try { + t = decodeText(Uint8List.sublistView(all, 0, all.length - cut), _charset!); + break; + } on FormatException { + if (_complete || cut >= 3 || cut >= all.length) rethrow; + cut++; + } + } + // 나눠 읽은 끝에서 글자가 반쯤 잘렸으면 다음 조각과 함께 읽을 때까지 뺀다 + if (!_complete) { + while (t.endsWith('�')) { + t = t.substring(0, t.length - 1); + } + } + _text = t; + } + + @override + Widget build(BuildContext context) { + final lines = _text.split('\n'); + // 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다) + final narrow = MediaQuery.sizeOf(context).width < 600; + return Scaffold( + appBar: AppBar( + title: Text(p.basename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + if (_charset != null) + DropdownButton( + value: supportedCharsets.containsKey(_charset) ? _charset : 'UTF-8', + underline: const SizedBox(), + items: [for (final e in supportedCharsets.entries) DropdownMenuItem(value: e.key, child: Text(e.value))], + onChanged: (v) => setState(() { + _charset = v; + _picked = true; + _decode(_bytes.toBytes()); + }), + ), + if (!narrow) ...[ + IconButton( + tooltip: tr('줄 번호'), + icon: Icon(_lineNumbers ? Icons.format_list_numbered : Icons.notes), + onPressed: () => setState(() => _lineNumbers = !_lineNumbers), + ), + IconButton(tooltip: tr('글자 작게'), icon: const Icon(Icons.text_decrease), onPressed: () => setState(() => _font = (_font - 1).clamp(9, 30))), + IconButton(tooltip: tr('글자 크게'), icon: const Icon(Icons.text_increase), onPressed: () => setState(() => _font = (_font + 1).clamp(9, 30))), + ], + IconButton( + tooltip: tr('전체 복사'), + icon: const Icon(Icons.copy), + onPressed: () { + Clipboard.setData(ClipboardData(text: _text)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다')))); + }, + ), + if (narrow) + PopupMenuButton( + tooltip: tr('더 보기'), + onSelected: (v) => setState(() { + switch (v) { + case 'lines': + _lineNumbers = !_lineNumbers; + case 'smaller': + _font = (_font - 1).clamp(9, 30); + case 'bigger': + _font = (_font + 1).clamp(9, 30); + } + }), + itemBuilder: (_) => [ + CheckedPopupMenuItem(value: 'lines', checked: _lineNumbers, child: Text(tr('줄 번호'))), + PopupMenuItem(value: 'smaller', child: Text(tr('글자 작게'))), + PopupMenuItem(value: 'bigger', child: Text(tr('글자 크게'))), + ], + ), + ], + ), + body: _error != null + ? Center(child: Text(trf('읽지 못했습니다: {0}', [_error]))) + : _charset == null + ? const Center(child: CircularProgressIndicator()) + : Column(children: [ + Expanded( + // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) + child: SelectionArea( + child: Scrollbar( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + itemCount: lines.length, + itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (_lineNumbers) + SizedBox( + width: 48, + child: Text('${i + 1}', + textAlign: TextAlign.right, + style: TextStyle(fontSize: _font - 2, color: JjColors.textDim, fontFamily: 'monospace')), + ), + if (_lineNumbers) const SizedBox(width: 8), + Expanded(child: Text(lines[i], style: TextStyle(fontSize: _font, fontFamily: 'monospace'))), + ]), + ), + ), + ), + ), + if (!_complete) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(6), + child: Row(children: [ + Expanded( + child: Text(trf('{0} / {1} 읽음', [formatSize(_bytes.length), formatSize(_size)]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), + ]), + ), + ), + ]), + ); + } +} diff --git a/lib/ui/trash_page.dart b/lib/ui/trash_page.dart new file mode 100644 index 0000000..37dad70 --- /dev/null +++ b/lib/ui/trash_page.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../core/app_trash.dart'; +import '../core/file_ops.dart' show formatSize; +import '../l10n/tr.dart'; +import 'confirm.dart'; +import 'theme.dart'; + +/// X-plore 비교 e: 앱 휴지통 화면 (Android) - 항목 · 원래 자리 · 지운 때, 골라서 되살리기 · 영구 삭제, 모두 비우기. +/// 돌려주는 값: 되살린 경로들 (탐색기가 그 폴더를 새로 읽는다) +class TrashPage extends StatefulWidget { + final AppTrash trash; + + /// 보관 일 (0 = 끔) - 안내 줄에 + final int days; + const TrashPage({super.key, required this.trash, required this.days}); + + @override + State createState() => _TrashPageState(); +} + +class _TrashPageState extends State { + List? _items; + final _picked = {}; + final _restored = []; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final items = await widget.trash.list(); + if (!mounted) return; + setState(() { + _items = items; + _picked.removeWhere((x) => !items.any((i) => i.entryDir == x)); + }); + } + + List get _chosen => [for (final i in _items ?? const []) if (_picked.contains(i.entryDir)) i]; + + void _say(String t) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); + + Future _restore(List items) async { + setState(() => _busy = true); + var n = 0; + final fails = []; + for (final i in items) { + try { + _restored.add(await widget.trash.restore(i)); + n++; + } catch (e) { + fails.add('${i.name}: $e'); + } + } + await _load(); + if (!mounted) return; + setState(() => _busy = false); + _say([ + if (n > 0) trf('{0}개를 원래 자리로 되살렸습니다', [n]), + if (fails.isNotEmpty) trf('되살리지 못함: {0}', [fails.take(3).join(' · ')]), + ].join(' · ')); + } + + Future _purge(List items, {bool all = false}) async { + final ok = await confirmAction( + context, + title: all ? tr('휴지통 비우기') : trf('{0}개를 영구 삭제할까요?', [items.length]), + body: tr('휴지통에서 지운 것은 되살릴 수 없습니다.'), + ok: tr('영구 삭제'), + ); + if (!ok || !mounted) return; + setState(() => _busy = true); + for (final i in items) { + try { + await widget.trash.purge(i); + } catch (_) {} + } + await _load(); + if (mounted) setState(() => _busy = false); + } + + String _date(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final items = _items; + final chosen = _chosen; + final total = (items ?? const []).fold(0, (a, b) => a + b.size); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) Navigator.pop(context, _restored); + }, + child: Scaffold( + appBar: AppBar( + title: Text(items == null ? tr('휴지통') : trf('휴지통 · {0}개 · {1}', [items.length, formatSize(total)])), + actions: [ + if (items != null && items.isNotEmpty) + TextButton.icon( + onPressed: _busy ? null : () => _purge(items, all: true), + icon: const Icon(Icons.delete_forever_outlined, color: JjColors.danger), + label: Text(tr('비우기'), style: const TextStyle(color: JjColors.danger)), + ), + ], + ), + body: Column(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + widget.days > 0 + ? trf('지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).', [widget.days]) + : tr('휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ), + if (_busy) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: items == null + ? const Center(child: CircularProgressIndicator()) + : items.isEmpty + ? Center(child: Text(tr('휴지통이 비어 있습니다'))) + : ListView.builder( + itemCount: items.length, + itemBuilder: (_, k) { + final i = items[k]; + final on = _picked.contains(i.entryDir); + return CheckboxListTile( + value: on, + onChanged: (v) => setState(() => v == true ? _picked.add(i.entryDir) : _picked.remove(i.entryDir)), + secondary: Icon(i.isDir ? Icons.folder : Icons.insert_drive_file_outlined, + color: i.isDir ? Colors.amber : null), + title: Text(i.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${i.original}\n${_date(i.deletedAt)} · ${formatSize(i.size)}', + maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + isThreeLine: true, + ); + }, + ), + ), + if (chosen.isNotEmpty) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row(children: [ + Expanded(child: Text(trf('{0}개 고름', [chosen.length]))), + TextButton( + onPressed: _busy ? null : () => _purge(chosen), + child: Text(tr('영구 삭제'), style: const TextStyle(color: JjColors.danger)), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _busy ? null : () => _restore(chosen), + icon: const Icon(Icons.restore), + label: Text(tr('되살리기')), + ), + ]), + ), + ), + ]), + ), + ); + } +} diff --git a/test/app_trash_test.dart b/test/app_trash_test.dart new file mode 100644 index 0000000..dfed207 --- /dev/null +++ b/test/app_trash_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/app_trash.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 e: Android 앱 휴지통 (저장 장치의 .jj_trash) - Windows 임시 폴더로 시험 +void main() { + late Directory tmp; + late AppTrash trash; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_trash_e_'); + trash = AppTrash([p.join(tmp.path, 'vol')]); + Directory(p.join(tmp.path, 'vol', 'a', 'sub')).createSync(recursive: true); + File(p.join(tmp.path, 'vol', 'a', 'f.txt')).writeAsStringSync('hello'); + File(p.join(tmp.path, 'vol', 'a', 'sub', 'g.txt')).writeAsStringSync('gg'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('파일 · 폴더를 휴지통으로 (같은 저장 장치의 .jj_trash, .nomedia) → 목록 → 되살리기 (자리에 같은 이름이면 "(되살림)")', () async { + final f = p.join(tmp.path, 'vol', 'a', 'f.txt'); + final sub = p.join(tmp.path, 'vol', 'a', 'sub'); + final i1 = await trash.moveToTrash(f, now: DateTime(2026, 10, 1)); + final i2 = await trash.moveToTrash(sub, now: DateTime(2026, 10, 2)); + expect(File(f).existsSync(), isFalse); + expect(Directory(sub).existsSync(), isFalse); + expect(File(p.join(tmp.path, 'vol', trashFolderName, '.nomedia')).existsSync(), isTrue); + expect(trash.isInTrash(i1.stored), isTrue); + final list = await trash.list(); + expect(list.map((x) => x.original), [sub, f], reason: '최근 것부터'); + expect((list.last.size, list.first.isDir, list.first.size), (5, true, 2)); + // 자리에 같은 이름이 새로 생겼으면 그것은 두고 다른 이름으로 + File(f).writeAsStringSync('new'); + final back = await trash.restore(list.last); + expect(p.basename(back), 'f (되살림).txt'); + expect(File(back).readAsStringSync(), 'hello'); + expect(File(f).readAsStringSync(), 'new'); + expect(await trash.restore(i2), sub); + expect(File(p.join(sub, 'g.txt')).readAsStringSync(), 'gg'); + expect(await trash.list(), isEmpty); + }); + + test('보관 일이 지난 것만 지운다 · 영구 삭제 · 저장 장치 밖 · 휴지통 안의 것은 다시 보내지 않음', () async { + final f = p.join(tmp.path, 'vol', 'a', 'f.txt'); + final g = p.join(tmp.path, 'vol', 'a', 'sub', 'g.txt'); + await trash.moveToTrash(f, now: DateTime(2026, 9, 1)); + await trash.moveToTrash(g, now: DateTime(2026, 10, 15)); + expect(await trash.purgeOlderThan(14, now: DateTime(2026, 10, 18)), 1); + final left = await trash.list(); + expect(left.single.original, g); + await trash.purge(left.single); + expect(await trash.list(), isEmpty); + // 어느 저장 장치에도 없는 경로 → 옮기지 못함 (원래 것 그대로) + final out = File(p.join(tmp.path, 'outside.txt'))..writeAsStringSync('x'); + expect(() => trash.moveToTrash(out.path), throwsA(isA())); + expect(out.existsSync(), isTrue); + expect(trash.isInTrash(p.join(tmp.path, 'vol', trashFolderName, 'x')), isTrue); + expect(trash.isTrashRoot(p.join(tmp.path, 'vol', trashFolderName)), isTrue); + }); +} diff --git a/test/batch_rename_date_test.dart b/test/batch_rename_date_test.dart new file mode 100644 index 0000000..3fc95b1 --- /dev/null +++ b/test/batch_rename_date_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/core/batch_rename.dart'; +import 'package:jj_mkvmaker/core/photo_date.dart'; +import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 i: 일괄 이름 바꾸기의 날짜 · 시각 ([D] · [T], 사진은 찍은 때) · 마지막 규칙 기억 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_rendate_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('[D] · [T]: 날짜 2026-10-18 · 시각 13-05-09 (":" 는 못 써서 "-"), 날짜가 없으면 빈칸', () { + const r = RenameRule(template: '[D] [T] [N]'); + expect(r.usesDate, isTrue); + expect(r.apply('a.jpg', isDir: false, index: 0, date: DateTime(2026, 10, 18, 13, 5, 9)), '2026-10-18 13-05-09 a.jpg'); + expect(const RenameRule(template: '[d]_[C]').apply('a.jpg', isDir: false, index: 0, date: DateTime(2026, 1, 2)), '2026-01-02_01.jpg'); + expect(const RenameRule(template: 'x[D]').apply('a.jpg', isDir: false, index: 0), 'x.jpg'); + expect(const RenameRule().usesDate, isFalse); + }); + + test('규칙 기억: toJson → fromJson 그대로, 틀린 값은 처음 값으로', () { + const r = RenameRule(template: '[D]_[C]', start: 3, step: 2, digits: 4, find: 'a', replace: 'b', ignoreCase: false, caseMode: 'upper', dateSource: 'modified'); + expect(RenameRule.fromJson(r.toJson()).toJson(), r.toJson()); + final bad = RenameRule.fromJson({'template': '', 'digits': 99, 'caseMode': '??', 'dateSource': 1, 'start': 'x'}); + expect((bad.template, bad.digits, bad.caseMode, bad.dateSource, bad.start), ('[N]', 9, 'keep', 'photo', 1)); + }); + + test('사진의 찍은 때 (JPEG Exif DateTimeOriginal) · 없거나 JPEG 이 아니면 null', () async { + expect(parseExifDate('2024:05:01 12:34:56'), DateTime(2024, 5, 1, 12, 34, 56)); + expect(parseExifDate('0000:00:00 00:00:00'), isNull); + final image = img.Image(width: 8, height: 8); + image.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56'); + final withExif = File(p.join(tmp.path, 'a.jpg'))..writeAsBytesSync(img.encodeJpg(image)); + expect(await photoTakenAt(withExif.path), DateTime(2024, 5, 1, 12, 34, 56)); + final plain = File(p.join(tmp.path, 'b.jpg'))..writeAsBytesSync(img.encodeJpg(img.Image(width: 8, height: 8))); + expect(await photoTakenAt(plain.path), isNull); + expect(await photoTakenAt(p.join(tmp.path, 'none.png')), isNull); + }); + + testWidgets('창: 마지막 규칙으로 열리고, [바꾸기] 때의 규칙을 돌려준다 · [D] 는 사진의 찍은 때', (tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final image = img.Image(width: 8, height: 8); + image.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56'); + final photo = File(p.join(tmp.path, 'IMG_1.jpg'))..writeAsBytesSync(img.encodeJpg(image)); + RenameRule? used; + final nav = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold())); + showBatchRename(nav.currentContext!, [photo.path], + isDir: (_) => false, initial: const RenameRule(template: '[D]_[N]'), onRun: (r) => used = r); + bool ready() => + find.text('2024-05-01_IMG_1.jpg').evaluate().isNotEmpty && + tester.widget(find.widgetWithText(FilledButton, '바꾸기 (1개)')).onPressed != null; + for (var i = 0; i < 60 && !ready(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + expect(find.widgetWithText(TextField, '[D]_[N]'), findsOneWidget, reason: '마지막 규칙으로 열림'); + expect(find.text('2024-05-01_IMG_1.jpg'), findsOneWidget); + expect(tester.widget(find.widgetWithText(FilledButton, '바꾸기 (1개)')).onPressed, isNotNull, reason: '찍은 때를 다 읽으면 켜짐'); + await tester.tap(find.widgetWithText(FilledButton, '바꾸기 (1개)')); + for (var i = 0; i < 100 && !File(p.join(tmp.path, '2024-05-01_IMG_1.jpg')).existsSync(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + expect(File(p.join(tmp.path, '2024-05-01_IMG_1.jpg')).existsSync(), isTrue); + expect(used?.template, '[D]_[N]'); + }); +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index da0ed0e..e54b25c 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:io'; +import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -16,6 +18,8 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/quick_look.dart'; import 'package:jj_mkvmaker/ui/explorer_drag.dart'; +import 'package:jj_mkvmaker/ui/explorer_grid.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; @@ -78,10 +82,10 @@ void main() { testWidgets('두 창: 폴더 한 번 = 펼치기 · 파일 한 번 = 선택 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { await open(tester); - // 두 창 + 가운데 버튼 줄, 선택 동그라미는 평소에 안 보임 + // 두 창 + 가운데 버튼 줄. 선택 동그라미: X-plore 스타일 (처음 값) 은 늘 보임 (관리자 결정 10/18 - 설정으로 끌 수 있음) expect(find.text('상위 폴더'), findsOneWidget); expect(find.text('doc.txt'), findsOneWidget); - expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); + expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 모니터링 · rsync 는 Rsync 화면에만 expect(find.text('모니터링'), findsNothing); expect(find.text('좌 → 우'), findsNothing); @@ -135,6 +139,233 @@ void main() { expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); }); + testWidgets('X-plore a: 한 창 + "숨은 다른 창으로" - 위에 갈 곳 줄 · [복사] 는 그 폴더로 · [지금 폴더로] 로 바꾸기 (처음 값은 담아 두기)', (tester) async { + expect(AppSettings().explorerOnePaneCopy, 'clip', reason: '처음 값은 지금 그대로'); + c.settings + ..explorerLayout = 'single' + ..explorerOnePaneCopy = 'other'; + await open(tester); + expect(find.textContaining('복사 · 이동 갈 곳'), findsOneWidget); + expect(find.textContaining('갈 곳: right'), findsOneWidget, reason: '숨은 창의 폴더 이름 (전체 경로는 풍선 도움말)'); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.tap(find.text('복사').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); + // [지금 폴더로] → 갈 곳이 지금 보는 폴더 (left) + await act(tester, () => tester.tap(find.text('지금 폴더로'))); + await settle(tester, () => c.settings.explorerPaths[1] == left, rounds: 50); + expect(c.settings.explorerPaths[1], left); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await tester.longPress(find.text('복사').first); + await calm(tester); + expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget, reason: '복사 길게 = 이동'); + await tester.tap(find.text('취소').last); + await calm(tester); + await tester.longPress(find.text('삭제').first); + await calm(tester); + expect(find.widgetWithText(FilledButton, '영구 삭제'), findsOneWidget, reason: '삭제 길게 = 영구 삭제로 묻기'); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + }); + + testWidgets('X-plore d: 설정 "다시 누르면 접기" - 펼친 지금 폴더를 다시 누르면 접힘 · 두 번 누르기의 두 번째는 접지 않음 (처음 값 끔)', (tester) async { + expect(AppSettings().explorerRetapCollapse, isFalse, reason: '189 그대로'); + c.settings.explorerRetapCollapse = true; + await open(tester); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + // 조금 뒤 다시 누름 - 접힘 + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 800))); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); + expect(find.text('inner.txt'), findsNothing); + }); + + testWidgets('X-plore c: X-plore 스타일 트리 맨 위에 다른 저장 장치 · 즐겨찾기 - 누르면 그곳으로, 즐겨찾기는 펼침', (tester) async { + ExplorerPage.debugVolumes = () => [(left, 'L:'), (right, 'R:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + c.settings + ..explorerStyle = 'xplore' + ..folderBookmarks = [FolderBookmark(name: '내 즐겨찾기', path: p.join(left, 'sub'))]; + await open(tester); + final otherRoot = find.byKey(ValueKey('k1:$right')); + expect(otherRoot, findsWidgets, reason: '왼쪽 창 트리에 R: (다른 저장 장치) 줄'); + final favs = find.byKey(const ValueKey('k2:\u0000favorites')); + expect(favs, findsWidgets); + expect(find.text('내 즐겨찾기'), findsNothing, reason: '접힌 즐겨찾기'); + await act(tester, () => tester.tap(favs.first)); + await calm(tester); + expect(find.text('내 즐겨찾기'), findsOneWidget); + await act(tester, () => tester.tap(find.text('내 즐겨찾기'))); + await settle(tester, () => c.settings.explorerPaths.first == p.join(left, 'sub'), rounds: 80); + expect(c.settings.explorerPaths.first, p.join(left, 'sub')); + await act(tester, () => tester.tap(find.byKey(ValueKey('k1:$right')).first)); + await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 80); + expect(c.settings.explorerPaths.first, right, reason: '다른 저장 장치 줄 = 그 저장 장치로'); + // 다른 스타일은 그대로 (맨 위 항목 없음) + await tester.runAsync(() => c.updateSettings((x) => x.explorerStyle = 'windows')); + await calm(tester); + expect(find.byKey(const ValueKey('k2:\u0000favorites')), findsNothing); + }); + + testWidgets('X-plore ⚖: 폴더 표시 X-plore 식 (1번 폴더 · 2번 안의 파일 · 3번 풀기) - 처음 값은 지금 규칙', (tester) async { + expect(AppSettings().explorerFolderMarkRule, 'tree', reason: '고르는 범위가 바뀌면 지우기 · 이동 대상이 바뀌어 처음 값은 그대로'); + c.settings.explorerFolderMarkRule = 'xplore'; + await open(tester); + IconData markIcon(String name) => (tester + .widget(find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton))) + .icon as Icon) + .icon!; + Future mark() async { + // 표시 동그라미 (풍선 도움말이 표시 여부에 따라 바뀌므로 줄 안의 단추로 찾는다) + await act(tester, () => tester.tap(find.descendant(of: find.ancestor(of: find.text('sub'), matching: find.byType(Material)).first, matching: find.byType(IconButton)).first)); + await settle(tester, () => false, rounds: 10); + } + + await mark(); + expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle, Icons.check_circle), reason: '1번 = 폴더 자신 (안의 것도 포함)'); + await mark(); + expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle_outline, Icons.check_circle), reason: '2번 = 안의 파일 전체'); + await mark(); + expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle_outline, Icons.check_circle_outline), reason: '3번 = 풀기'); + }); + + testWidgets('X-plore ⚖: 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (한 창이면 숨은 창과 바꿈)', (tester) async { + expect(AppSettings().explorerSwipePanes, isFalse, reason: '처음 값은 끔 - 화면 가운데를 밀면 화면 이동 (10/8 사용자 결정)'); + c.settings.explorerSwipePanes = true; + c.settings.explorerLayout = 'single'; + await open(tester); + expect(c.settings.explorerPaths, [left, right]); + // 화면 가운데 높이에서 (위 · 아래 가장자리 1/5 는 밀기로 보지 않는다 - SwipeNav) + await tester.flingFrom(tester.getCenter(find.byType(ExplorerPage)), const Offset(-400, 0), 1500); + await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 60); + expect(c.settings.explorerPaths, [right, left], reason: '숨은 창이 보이는 창으로'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('X-plore f: 압축을 트리 안에서 폴더처럼 펼치기 · 안은 읽기 전용 · 복사는 꺼내기', (tester) async { + final zip = p.join(left, 'pack.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('top.txt', 'top')) + ..addFile(ArchiveFile.string('a/b.txt', 'bee')))); + await open(tester); + await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); + // › 로 펼치면 압축 안의 것이 폴더처럼 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다) + await act(tester, () => tester.tap(chevronOf('pack.zip').first)); + await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty); + expect(find.text('a'), findsOneWidget); + await act(tester, () => tester.tap(chevronOf('a').first)); + await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); + expect(find.text('b.txt'), findsOneWidget); + // 안의 것을 지우려 하면 막는다 (압축은 그대로) + await act(tester, () => tester.tap(find.text('top.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); + expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); + expect(File(zip).existsSync(), isTrue); + expect(Directory(p.join(left, 'pack')).existsSync(), isFalse, reason: '풀지 않음'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + // 압축 안의 폴더를 펼쳐 지금 폴더가 압축 안이면 [새 폴더] 도 막는다 (이름을 묻지 않음) + await act(tester, () => tester.tap(find.text('a'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('새 폴더').first)); + await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); + expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); + expect(find.byType(TextField), findsNothing, reason: '이름 묻는 창 없음'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('X-plore g · h: 폴더 메뉴 [격자로 보기] (길게 = 그 자리로) · 파일 메뉴 [글 뷰어로 보기]', (tester) async { + File(p.join(left, 'sub', 'pic.png')).writeAsBytesSync([0x89, 0x50]); + await open(tester); + // g: 폴더를 길게 → 격자로 보기 → 그림만 (글 파일은 빠짐) + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.text('격자로 보기 (그림 · 동영상)'))); + await settle(tester, () => find.byType(ExplorerGridPage).evaluate().isNotEmpty); + expect(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('pic.png')), findsOneWidget); + expect(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('inner.txt')), findsNothing); + // 칸을 길게 누르면 탐색기에서 그 자리로 (격자 닫힘) + await tester.longPress(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('pic.png'))); + await settle(tester, () => find.byType(ExplorerGridPage).evaluate().isEmpty && find.descendant(of: find.byType(ExplorerPage), matching: find.text('pic.png')).evaluate().isNotEmpty); + expect(find.descendant(of: find.byType(ExplorerPage), matching: find.text('pic.png')), findsOneWidget, reason: '탐색기에 그 폴더가 펼쳐져 보임 (길게 누른 칸의 이름 풍선은 빼고)'); + // h: 파일을 길게 → 글 뷰어로 보기 (읽기 전용) + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('글 뷰어로 보기'))); + await settle(tester, () => find.text('hello').evaluate().isNotEmpty); + expect(find.byType(TextViewerPage), findsOneWidget); + expect(shell.opened, isEmpty, reason: '다른 앱으로 열지 않음'); + await act(tester, () async => Navigator.of(tester.element(find.byType(TextViewerPage))).pop()); + await settle(tester, () => find.byType(TextViewerPage).evaluate().isEmpty); + }); + + testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async { + // 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게) + ExplorerPage.debugAndroidTrash = true; + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; + addTearDown(() { + ExplorerPage.debugAndroidTrash = null; + ExplorerPage.debugVolumes = null; + }); + await open(tester); + final doc = p.join(left, 'doc.txt'); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + expect(find.textContaining('앱 휴지통에 14일'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !File(doc).existsSync() && find.text('되돌리기').evaluate().isNotEmpty); + expect(File(doc).existsSync(), isFalse); + expect(Directory(p.join(tmp.path, '.jj_trash')).existsSync(), isTrue); + // [되돌리기] + await act(tester, () => tester.tap(find.text('되돌리기'))); + await settle(tester, () => File(doc).existsSync()); + expect(File(doc).readAsStringSync(), 'hello'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + // 다시 지우고 [휴지통] 화면에서 되살리기 + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !File(doc).existsSync()); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + // 휴지통 화면을 연다 (닫힐 때까지 기다리는 Future 라 기다리지 않는다) + unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future); + await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byType(CheckboxListTile).first)); + await calm(tester); + await act(tester, () => tester.tap(find.text('되살리기'))); + await settle(tester, () => File(doc).existsSync()); + expect(File(doc).readAsStringSync(), 'hello'); + await tester.pageBack(); + await calm(tester); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 2)); + }); + testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async { c.settings ..copyMethodFile = 'robocopy' @@ -267,8 +498,13 @@ void main() { await settle(tester, () => false, rounds: 5); expect(find.textContaining('0개 표시함'), findsNWidgets(2)); expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle_outline)); - // [선택 끝] → 동그라미가 사라진다 + // [선택 끝] → 표시가 모두 풀린다 (X-plore 스타일은 동그라미 자체는 늘 보임 - 관리자 결정 10/18) await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 + expect(find.byTooltip('표시 지우기'), findsNothing); + expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); + // 설정에서 끄면 선택 모드에서만 (예전대로) + await tester.runAsync(() => c.updateSettings((x) => x.explorerAlwaysMarks = false)); + await settle(tester, () => false, rounds: 5); expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); }); @@ -1137,16 +1373,24 @@ void main() { expect(find.text('폴더 열기'), findsOneWidget, reason: '끝난 알림에 [폴더 열기]'); // 패널은 끝난 뒤 잠깐 남는다 - [숨기기] expect(find.text('숨기기'), findsOneWidget); + // 패널 · 작은 표시는 끝나고 1.2초 (시험 시계) 뒤 내려간다. pumpAndSettle (calm) 은 애니메이션이 있는 동안 시계를 그만큼 + // 넘길 수 있어 (PC 가 바쁠 때 흔들림 - 관리자 지시), 정해진 횟수 대신 조건이 될 때까지 시계를 조금씩만 돌린다 + Future until(bool Function() done) async { + for (var i = 0; i < 100 && !done(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 10)); + } + } + await act(tester, () => tester.tap(find.text('숨기기'))); - await calm(tester); + await until(() => c.settings.explorerProgressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty); expect(c.settings.explorerProgressHidden, isTrue, reason: '숨김은 기억'); expect(find.text('숨기기'), findsNothing); expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시'); - // (아래 알림이 잠깐 작은 표시를 덮는다 - 알림을 닫고 누름) + // 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다) + tester.widget(find.byType(TransferProgressChip)).onTap(); + await until(() => !c.settings.explorerProgressHidden); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - await act(tester, () => tester.tap(find.text('복사 · 이동 끝'))); - await calm(tester); expect(c.settings.explorerProgressHidden, isFalse, reason: '누르면 다시 펼침'); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await calm(tester); diff --git a/test/file_tools_test.dart b/test/file_tools_test.dart new file mode 100644 index 0000000..2c70bec --- /dev/null +++ b/test/file_tools_test.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/file_tools.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ftools_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('P3 해시: 알려진 값 (MD5 · SHA-256 "abc") · 취소면 null', () async { + final f = File(p.join(tmp.path, 'abc.txt'))..writeAsStringSync('abc'); + final h = await fileHashes(f.path); + expect(h!.md5, '900150983cd24fb0d6963f7d28e17f72'); + expect(h.sha256, 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(await fileHashes(f.path, cancelled: () => true), isNull); + }); + + test('P3 16진 보기 한 줄 · 폴더 속성 (파일 · 폴더 수 · 크기)', () async { + expect(HexViewerPage.line(16, 'AB\n'.codeUnits), startsWith('00000010 41 42 0a')); + expect(HexViewerPage.line(16, 'AB\n'.codeUnits), endsWith(' AB.')); + Directory(p.join(tmp.path, 'd', 'e')).createSync(recursive: true); + File(p.join(tmp.path, 'd', 'a.bin')).writeAsBytesSync(List.filled(10, 1)); + File(p.join(tmp.path, 'd', 'e', 'b.bin')).writeAsBytesSync(List.filled(5, 1)); + final s = await folderStats(p.join(tmp.path, 'd')); + expect((s.files, s.folders, s.bytes), (2, 1, 15)); + }); +} diff --git a/test/lan_scan_test.dart b/test/lan_scan_test.dart new file mode 100644 index 0000000..039fede --- /dev/null +++ b/test/lan_scan_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/lan_scan.dart'; + +/// X-plore 비교 j: 같은 망 SMB 서버 찾기의 포트 훑기 (실제 망 대신 이 기기의 열린 · 닫힌 포트로) +void main() { + test('열린 포트만 찾는다 · 순서는 넣은 대로 · 진행 · 취소', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + server.listen((s) => s.destroy()); + final closed = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closedPort = closed.port; + await closed.close(); + var progress = 0; + final found = await scanPort(['127.0.0.1', '127.0.0.2'], port: server.port, lookupNames: false, onProgress: (d, _) => progress = d); + expect(found.map((h) => h.address), ['127.0.0.1'], reason: '127.0.0.2 는 듣지 않음 (Windows 는 127.0.0.1 에만 묶임)'); + expect(progress, 2); + expect(await scanPort(['127.0.0.1'], port: closedPort, lookupNames: false), isEmpty); + expect(await scanPort(['127.0.0.1'], port: server.port, lookupNames: false, cancelled: () => true), isEmpty, reason: '취소'); + expect((await localPrivateIPv4()).every((a) => !a.isLoopback), isTrue); + }); +} diff --git a/test/storage_chart_test.dart b/test/storage_chart_test.dart new file mode 100644 index 0000000..fb864f7 --- /dev/null +++ b/test/storage_chart_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/storage_chart.dart'; + +/// X-plore 비교 P3 (87 위): 저장 공간 그림 (treemap) - 넓이는 크기에 비례, 겹치지 않고 다 채움, 누르면 그 폴더 +void main() { + test('칸 나누기: 넓이 비례 · 영역 안 · 겹치지 않음 · 0 은 빠짐', () { + const area = Rect.fromLTWH(0, 0, 400, 300); + final blocks = [ + const StorageBlock('a', 600, 'A'), + const StorageBlock('b', 300, 'B'), + const StorageBlock('c', 100, 'C'), + const StorageBlock('d', 200), + const StorageBlock('z', 0, 'Z'), + ]; + final cells = StorageTreemap.layout(blocks, area); + expect(cells.map((c) => c.$1.label), ['a', 'b', 'd', 'c'], reason: '큰 것부터, 0 은 빠짐'); + const total = 1200; + for (final (b, r) in cells) { + expect(r.width * r.height, closeTo(area.width * area.height * b.size / total, 0.5)); + expect(area.inflate(0.01).contains(r.topLeft) && area.inflate(0.01).contains(r.bottomRight - const Offset(0.001, 0.001)), isTrue); + } + for (var i = 0; i < cells.length; i++) { + for (var j = i + 1; j < cells.length; j++) { + final o = cells[i].$2.intersect(cells[j].$2); + expect(o.width <= 0.01 || o.height <= 0.01, isTrue, reason: '${cells[i].$1.label} · ${cells[j].$1.label} 겹침'); + } + } + expect(StorageTreemap.layout(const [], area), isEmpty); + }); + + testWidgets('폴더 칸을 누르면 그 폴더, 경로 없는 칸 (이 폴더의 파일) 은 누를 수 없음', (t) async { + final tapped = []; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: StorageTreemap( + blocks: const [StorageBlock('Videos', 800, '/v'), StorageBlock('files', 200)], + onTap: (b) => tapped.add(b.path!), + ), + ), + ), + )); + await t.tap(find.text('Videos')); + await t.tap(find.text('files')); + expect(tapped, ['/v']); + }); +} diff --git a/test/xplore_tools_test.dart b/test/xplore_tools_test.dart new file mode 100644 index 0000000..24d1119 --- /dev/null +++ b/test/xplore_tools_test.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/text_codec.dart'; +import 'package:jj_mkvmaker/ui/explorer_grid.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 g · h: 격자 보기 · 글 뷰어 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_xptools_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + testWidgets('h 글 뷰어: CP949 를 알아보고 나눠 읽기 [더 읽기]', (t) async { + final text = List.generate(40, (i) => '줄 $i 한글').join('\n'); + final f = File(p.join(tmp.path, 'k.txt'))..writeAsBytesSync(encodeText(text, 'CP949').bytes); + await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path, chunk: 120))); + for (var i = 0; i < 20 && find.textContaining('줄 0').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + expect(find.text('줄 0 한글'), findsOneWidget); + expect(find.text('더 읽기'), findsOneWidget, reason: '큰 파일은 나눠 읽음'); + for (var k = 0; k < 10 && find.text('더 읽기').evaluate().isNotEmpty; k++) { + await t.tap(find.text('더 읽기')); + for (var i = 0; i < 5; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + } + expect(find.text('더 읽기'), findsNothing, reason: '다 읽음'); + expect(find.text('CP949 (EUC-KR)'), findsOneWidget); + }); + + testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { + final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); + final opened = []; + final entries = [ + FileEntry(png.path, isDir: false, size: 2, modified: DateTime(2026)), + FileEntry(p.join(tmp.path, 'b.mkv'), isDir: false, size: 2, modified: DateTime(2026)), + FileEntry(p.join(tmp.path, 'c.txt'), isDir: false, size: 2, modified: DateTime(2026)), + FileEntry(p.join(tmp.path, 'dir'), isDir: true, modified: DateTime(2026)), + ]; + expect(ExplorerGridPage.pick(entries, const ['png']).map((e) => e.name), ['a.png', 'b.mkv']); + await t.pumpWidget(MaterialApp( + home: ExplorerGridPage( + title: 'T', + entries: entries, + imageExts: const ['png'], + videoThumb: (_) async => null, + onOpen: (e) async => opened.add(e.name), + ), + )); + await t.pump(); + expect(find.text('a.png'), findsOneWidget); + expect(find.text('c.txt'), findsNothing); + await t.tap(find.text('b.mkv')); + expect(opened, ['b.mkv']); + }); +}