Compare commits
46 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
1b3160939c | |
|
|
5366bb3a14 | |
|
|
ab4c82d0b0 | |
|
|
3d3ab5666f | |
|
|
44c69af83e | |
|
|
9e40b485a9 | |
|
|
f43f067d4b | |
|
|
ed459a0229 | |
|
|
86b8ddd603 | |
|
|
dfed3fa6bb | |
|
|
f947935c19 | |
|
|
849c83031d | |
|
|
634fcbc920 | |
|
|
bd0801ce66 | |
|
|
716057734a | |
|
|
b1a43c36c5 | |
|
|
f45ad3b25f | |
|
|
55caef5c3f | |
|
|
0d86a9b163 | |
|
|
60b994a9e6 | |
|
|
2bacd88925 | |
|
|
484f11d9c2 | |
|
|
d2dc267ae2 | |
|
|
7f2263bbb9 | |
|
|
f5a01a1ecd | |
|
|
0ef2c2c33b | |
|
|
d7da812da6 | |
|
|
57676371dc | |
|
|
c593440249 | |
|
|
0bd1466212 | |
|
|
b2c400a0fb | |
|
|
f6de74010c | |
|
|
e228e84adb | |
|
|
aba095b342 | |
|
|
2f4ad6c146 | |
|
|
f377a14184 | |
|
|
84863c1569 | |
|
|
6e646b60c1 | |
|
|
a8bc585b1a | |
|
|
2033c48e5e | |
|
|
a19d68e167 | |
|
|
44a6c4e6b2 | |
|
|
197ba047c4 | |
|
|
954703c4f0 | |
|
|
10954a632b | |
|
|
39c6d9dce4 |
|
|
@ -73,6 +73,7 @@
|
|||
"- 감지한 언어": "- detected",
|
||||
"-1 은 아무렇게나": "-1 = random",
|
||||
".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
|
||||
"/ 로 시작하는 전체 경로로": "use a full path starting with /",
|
||||
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
|
||||
"0 으로": "Reset to 0",
|
||||
"1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image",
|
||||
|
|
@ -153,6 +154,7 @@
|
|||
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)",
|
||||
"Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open",
|
||||
"BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
|
||||
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "use a full path starting with a drive, like C:\\",
|
||||
"Chrome (내려받기)": "Chrome (download)",
|
||||
"Chrome (내장)": "Chrome (built-in)",
|
||||
"Chrome 에서 가져오기": "Import from Chrome",
|
||||
|
|
@ -356,6 +358,7 @@
|
|||
"VLC 로 열기": "Open with VLC",
|
||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "If nothing is sent or received for this long while downloading or uploading over WebDAV · SFTP · FTP · SMB, it fails with a notice (so it does not wait forever when the server stops)",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).",
|
||||
|
|
@ -466,6 +469,7 @@
|
|||
"rsync 양쪽 (⇄)": "rsync both ways (⇄)",
|
||||
"rsync 에 고르기": "Pick for rsync",
|
||||
"rsync 옵션": "rsync options",
|
||||
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "These are left out by the rsync options (--exclude etc.), but --delete-excluded deletes them from the target. To keep them, remove --delete-excluded from the options.",
|
||||
"rsync 종료 코드 {0}": "rsync exit code {0}",
|
||||
"settings.json 없음": "settings.json missing",
|
||||
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
|
||||
|
|
@ -524,6 +528,7 @@
|
|||
"{0} 을(를) 설치했습니다.": "Installed {0}.",
|
||||
"{0} 을(를) 지울까요?": "Delete {0}?",
|
||||
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "The SHA256 of {0} does not match (damaged or a different file). Installation stopped.",
|
||||
"{0} 의 값이 없습니다": "{0} has no value",
|
||||
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).",
|
||||
"{0} 이상": "{0} or larger",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
|
||||
|
|
@ -609,6 +614,7 @@
|
|||
"{0}장을 올려 저장했습니다{1}": "Upscaled and saved {0}{1}",
|
||||
"{0}줄": "{0} lines",
|
||||
"{0}초": "{0} s",
|
||||
"{0}초 (기본)": "{0} s (default)",
|
||||
"{0}초 안에 준비되지 않았습니다.": "It wasn't ready within {0} seconds.",
|
||||
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).",
|
||||
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· yt-dlp update check on Android (connects separately inside the download tools)",
|
||||
|
|
@ -653,6 +659,7 @@
|
|||
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
|
||||
"값을 적어 주세요 (예: --exclude=*.tmp)": "Add a value (e.g. --exclude=*.tmp)",
|
||||
"강하게 (0.4)": "Strong (0.4)",
|
||||
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original",
|
||||
"같습니다 ✔": "Match ✔",
|
||||
|
|
@ -696,6 +703,7 @@
|
|||
"개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect",
|
||||
"거꾸로": "Reverse",
|
||||
"거꾸로 된 범위: {0}": "Reversed range: {0}",
|
||||
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "{0} excluded items will also be deleted (--delete-excluded)",
|
||||
"건너뛰기": "Skip",
|
||||
"건너뛴 링크 {0}개": "{0} links skipped",
|
||||
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",
|
||||
|
|
@ -721,6 +729,7 @@
|
|||
"경로 입력 · 네트워크 서버": "Enter path · Network servers",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
|
||||
"경로로 읽힙니다: {0}": "Read as a path: {0}",
|
||||
"경로를 복사했습니다: {0}": "Copied path: {0}",
|
||||
"계산 안 함": "Don't calculate",
|
||||
"계속": "Always",
|
||||
|
|
@ -745,6 +754,7 @@
|
|||
"고치기": "Edit",
|
||||
"고친 옵션을 저장하지 않았습니다": "The edited options aren't saved",
|
||||
"고화질": "High quality",
|
||||
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Retype with straight quotes and hyphens (-)",
|
||||
"골라서 시작": "Choose and start",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
|
||||
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)",
|
||||
|
|
@ -814,6 +824,7 @@
|
|||
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
|
||||
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
|
||||
"기능 버튼 줄": "Button bar",
|
||||
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the log or batch file: {0}",
|
||||
"기본": "Default",
|
||||
"기본 DNS": "Primary DNS",
|
||||
"기본 앱으로 열기": "Open with default app",
|
||||
|
|
@ -1034,6 +1045,7 @@
|
|||
"대상 폴더가 없습니다: {0}": "Target folder not found: {0}",
|
||||
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "Confirm whether to delete {0} items that exist only in the target (nothing is deleted until you confirm)",
|
||||
"대상에서 {0}개를 지울까요?": "Delete {0} items from the target?",
|
||||
"대상에서 지울까요?": "Delete from the target?",
|
||||
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)",
|
||||
"대상이 원본과 같거나 원본 안에 있습니다": "The target is the same as or inside the source",
|
||||
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "The target is the same as or inside the source (the source would change every sync). Choose another folder.",
|
||||
|
|
@ -1123,6 +1135,8 @@
|
|||
"등록 중 일부가 실패했습니다.": "Some registrations failed.",
|
||||
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
|
||||
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)",
|
||||
"따옴표가 닫히지 않았습니다: {0}": "Unclosed quote: {0}",
|
||||
"따옴표를 닫아 주세요": "Close the quote",
|
||||
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)",
|
||||
"라오어": "Lao",
|
||||
"라이선스 원문 보기": "View full license text",
|
||||
|
|
@ -1194,6 +1208,7 @@
|
|||
"먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "First asks via mDNS (NAS devices that announce their names), then tries an SMB (445) connection to devices on the network this device is on (Wi-Fi · wired) and closes it right away. No files or account details are sent.",
|
||||
"멈추고 옮기기": "Stop and move",
|
||||
"멈춤": "Stopped",
|
||||
"멈춤 (시작 안 됨)": "Stalled",
|
||||
"멈춤 (이번 실행 동안)": "Pause (this session)",
|
||||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again",
|
||||
"멈춤 · {0}": "Stopped · {0}",
|
||||
|
|
@ -1229,6 +1244,8 @@
|
|||
"목록": "list",
|
||||
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
|
||||
"목록 보기": "View list",
|
||||
"목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}",
|
||||
"목록 파일의 전체 경로를 적어 주세요": "write the full path of the list file",
|
||||
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.",
|
||||
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here",
|
||||
"목록에서 빼기": "Remove from list",
|
||||
|
|
@ -1258,6 +1275,7 @@
|
|||
"묻기 (기본)": "Ask (default)",
|
||||
"물어보지 않기": "Never ask",
|
||||
"미리 보기": "Quick look",
|
||||
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "The preview shows nothing to delete - but because of the rules above, items may actually be deleted",
|
||||
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
|
||||
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)",
|
||||
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "Can't preview (unknown text encoding)",
|
||||
|
|
@ -1553,6 +1571,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "The server returned no image",
|
||||
"서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.",
|
||||
"서버가 답하지 않습니다 ({0}초)": "The server is not responding ({0} s)",
|
||||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk",
|
||||
"서버가 이 기능을 허용하지 않음": "the server does not allow this",
|
||||
|
|
@ -1881,6 +1900,8 @@
|
|||
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).",
|
||||
"옮길까요?": "move?",
|
||||
"옵션": "Options",
|
||||
"옵션은 - 나 -- 로 시작합니다": "Options start with - or --",
|
||||
"옵션을 알아볼 수 없습니다: {0} - {1}": "Unrecognized option: {0} - {1}",
|
||||
"옵션을 저장했습니다": "Options saved",
|
||||
"완료": "Done",
|
||||
"완료 시 자동 동영상 추가": "Auto-add videos when done",
|
||||
|
|
@ -1902,6 +1923,7 @@
|
|||
"우르두어": "Urdu",
|
||||
"우크라이나어": "Ukrainian",
|
||||
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "Remote locations (WebDAV · SFTP · SMB · FTP) do not follow the filter options (--exclude, --include, --filter, …) - excluded items are copied too, and deleted if the run deletes. The comparison list above is what will actually happen.",
|
||||
"원격 서버 응답 기다리기": "Wait for remote server response",
|
||||
"원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}",
|
||||
"원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place",
|
||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)",
|
||||
|
|
@ -2045,6 +2067,7 @@
|
|||
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "This connection is neither signed nor encrypted ({0}). Others on the same network could read or change the files, so it's best to turn on SMB signing or SMB3 encryption in the NAS settings",
|
||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "Some filter rules in these options can't be previewed - more may actually be deleted: {0}",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "Some filter rules in these options cannot be previewed, so nothing is deleted automatically",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
|
||||
"이 이동 버튼 지우기": "Delete this move button",
|
||||
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",
|
||||
|
|
@ -2412,6 +2435,7 @@
|
|||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
|
||||
"지금 작업 취소: {0}": "Cancelled current job: {0}",
|
||||
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
|
||||
"지금 지우기 (확인)": "Delete now (confirm)",
|
||||
"지금 짝 추가": "Add current pair",
|
||||
"지금 짝을 즐겨찾기에": "Add the current pair to favorites",
|
||||
"지금 폴더": "Current folder",
|
||||
|
|
@ -2424,14 +2448,17 @@
|
|||
"지금까지 찾은 {0}개 모두": "All {0} found so far",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.",
|
||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
|
||||
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}",
|
||||
"지난 작업 기록": "Old job logs",
|
||||
"지문 확인하고 연결": "Check fingerprint and connect",
|
||||
"지우고 맞추기": "Delete and sync",
|
||||
"지우고 초기화": "Delete and reset",
|
||||
"지우기": "Delete",
|
||||
"지우기 포함": "with delete",
|
||||
"지우기가 들어 있는 작업입니다": "This task deletes files",
|
||||
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "This run deletes files, so it can't run without a comparison. Check the source and target, then press [Compare again].",
|
||||
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "This run deletes files, so you can run it after the list of deletions appears.",
|
||||
"지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.": "The rsync options changed after you confirmed deleting, and more items would now be deleted. Please confirm again.",
|
||||
"지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "The folders being deleted contain {0} items hidden from the list - they will be deleted too",
|
||||
"지우면 휴지통으로": "Delete to the Recycle Bin",
|
||||
"지우지 못한 파일: {0}": "Couldn't delete: {0}",
|
||||
|
|
@ -2579,6 +2606,9 @@
|
|||
"큰 파일": "Large files",
|
||||
"큰 파일이 없습니다": "No large files",
|
||||
"큰 폴더": "Large folders",
|
||||
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "Options containing a double quote (\") cannot be passed to rsync on Windows: {0}",
|
||||
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "Arguments containing a double quote (\") cannot be passed as-is to rsync on Windows, so it was not run: {0}",
|
||||
"큰따옴표 (\") 를 빼 주세요": "Remove the double quote (\")",
|
||||
"키 있음": "Key saved",
|
||||
"타갈로그어": "Tagalog",
|
||||
"타밀어": "Tamil",
|
||||
|
|
@ -2702,6 +2732,7 @@
|
|||
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder",
|
||||
"표시 지우기": "Clear marks",
|
||||
"표준": "Standard",
|
||||
"표준 입력 (-) 은 쓸 수 없습니다": "Standard input (-) cannot be used",
|
||||
"푸는 중": "Extracting",
|
||||
"푸는 중: {0}": "Extracting: {0}",
|
||||
"풀 수 없는 이름 {0}개": "{0} invalid names",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
"- 감지한 언어": "- 検出した言語",
|
||||
"-1 은 아무렇게나": "-1 はランダム",
|
||||
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
|
||||
"/ 로 시작하는 전체 경로로": "/ で始まるフルパスで",
|
||||
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
|
||||
"0 으로": "0 に戻す",
|
||||
"1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中",
|
||||
|
|
@ -153,6 +154,7 @@
|
|||
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)",
|
||||
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間",
|
||||
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
|
||||
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "C:\\ のようにドライブで始まるフルパスで",
|
||||
"Chrome (내려받기)": "Chrome(ダウンロード)",
|
||||
"Chrome (내장)": "Chrome(内蔵)",
|
||||
"Chrome 에서 가져오기": "Chrome から取り込む",
|
||||
|
|
@ -356,6 +358,7 @@
|
|||
"VLC 로 열기": "VLC で開く",
|
||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "WebDAV · SFTP · FTP · SMB でダウンロード・アップロード中に、この時間何もやり取りがなければ失敗として知らせます (サーバーが止まってもずっと待たないように)",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
|
||||
|
|
@ -466,6 +469,7 @@
|
|||
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
|
||||
"rsync 에 고르기": "rsync に選ぶ",
|
||||
"rsync 옵션": "rsync オプション",
|
||||
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "rsync オプションで除外したもの (--exclude など) ですが、--delete-excluded があるため対象から削除します。残すにはオプションから --delete-excluded を外してください。",
|
||||
"rsync 종료 코드 {0}": "rsync 終了コード {0}",
|
||||
"settings.json 없음": "settings.json がありません",
|
||||
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)",
|
||||
|
|
@ -524,6 +528,7 @@
|
|||
"{0} 을(를) 설치했습니다.": "{0} をインストールしました。",
|
||||
"{0} 을(를) 지울까요?": "{0} を削除しますか?",
|
||||
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} の SHA256 が一致しません (破損しているか別のファイル)。インストールを中止しました。",
|
||||
"{0} 의 값이 없습니다": "{0} の値がありません",
|
||||
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。",
|
||||
"{0} 이상": "{0} 以上",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
|
||||
|
|
@ -609,6 +614,7 @@
|
|||
"{0}장을 올려 저장했습니다{1}": "{0} ページを高解像度化して保存しました{1}",
|
||||
"{0}줄": "{0}行",
|
||||
"{0}초": "{0}秒",
|
||||
"{0}초 (기본)": "{0}秒 (既定)",
|
||||
"{0}초 안에 준비되지 않았습니다.": "{0} 秒以内に準備できませんでした。",
|
||||
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。",
|
||||
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android の yt-dlp 新バージョン確認 (ダウンロードツール内で別に接続)",
|
||||
|
|
@ -653,6 +659,7 @@
|
|||
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
|
||||
"값을 적어 주세요 (예: --exclude=*.tmp)": "値を入力してください (例: --exclude=*.tmp)",
|
||||
"강하게 (0.4)": "強め (0.4)",
|
||||
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
|
||||
"같습니다 ✔": "一致します ✔",
|
||||
|
|
@ -696,6 +703,7 @@
|
|||
"개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません",
|
||||
"거꾸로": "逆順",
|
||||
"거꾸로 된 범위: {0}": "逆順の範囲: {0}",
|
||||
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "除外したもの {0} 件も削除されます (--delete-excluded)",
|
||||
"건너뛰기": "スキップ",
|
||||
"건너뛴 링크 {0}개": "スキップしたリンク {0} 件",
|
||||
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",
|
||||
|
|
@ -721,6 +729,7 @@
|
|||
"경로 입력 · 네트워크 서버": "パス入力・ネットワークサーバー",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
|
||||
"경로로 읽힙니다: {0}": "パスとして読まれます: {0}",
|
||||
"경로를 복사했습니다: {0}": "パスをコピーしました: {0}",
|
||||
"계산 안 함": "計算しない",
|
||||
"계속": "常時",
|
||||
|
|
@ -745,6 +754,7 @@
|
|||
"고치기": "編集",
|
||||
"고친 옵션을 저장하지 않았습니다": "編集したオプションが保存されていません",
|
||||
"고화질": "高画質",
|
||||
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "半角の引用符とハイフン (-) で入力し直してください",
|
||||
"골라서 시작": "選んで開始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
|
||||
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)",
|
||||
|
|
@ -814,6 +824,7 @@
|
|||
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
|
||||
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
|
||||
"기능 버튼 줄": "機能ボタンの列",
|
||||
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "ログ・バッチファイルはフルパスで書いてください: {0}",
|
||||
"기본": "既定",
|
||||
"기본 DNS": "優先 DNS",
|
||||
"기본 앱으로 열기": "既定のアプリで開く",
|
||||
|
|
@ -1034,6 +1045,7 @@
|
|||
"대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}",
|
||||
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "対象にだけある {0} 個を削除するか確認が必要です (確認するまで削除しません)",
|
||||
"대상에서 {0}개를 지울까요?": "対象から {0} 個を削除しますか?",
|
||||
"대상에서 지울까요?": "対象から削除しますか?",
|
||||
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)",
|
||||
"대상이 원본과 같거나 원본 안에 있습니다": "対象が元と同じか、元の中にあります",
|
||||
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "対象が元と同じか元の中にあります (同期するたびに元が変わります)。別のフォルダーを選んでください。",
|
||||
|
|
@ -1123,6 +1135,8 @@
|
|||
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
|
||||
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
|
||||
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)",
|
||||
"따옴표가 닫히지 않았습니다: {0}": "引用符が閉じていません: {0}",
|
||||
"따옴표를 닫아 주세요": "引用符を閉じてください",
|
||||
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)",
|
||||
"라오어": "ラオ語",
|
||||
"라이선스 원문 보기": "ライセンス原文を見る",
|
||||
|
|
@ -1194,6 +1208,7 @@
|
|||
"먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "まず mDNS で問い合わせ (名前を知らせる NAS)、続いてこの端末が接続しているネットワーク (Wi-Fi · 有線) の機器に SMB (445) で接続だけ試し、すぐに閉じます。ファイルやアカウント情報は送りません。",
|
||||
"멈추고 옮기기": "止めて移す",
|
||||
"멈춤": "停止",
|
||||
"멈춤 (시작 안 됨)": "停滞 (開始されません)",
|
||||
"멈춤 (이번 실행 동안)": "停止 (今回の実行中)",
|
||||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します",
|
||||
"멈춤 · {0}": "停止 · {0}",
|
||||
|
|
@ -1229,6 +1244,8 @@
|
|||
"목록": "一覧",
|
||||
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
|
||||
"목록 보기": "リストを見る",
|
||||
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
|
||||
"목록 파일의 전체 경로를 적어 주세요": "リストファイルのフルパスを書いてください",
|
||||
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
|
||||
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
|
||||
"목록에서 빼기": "リストから外す",
|
||||
|
|
@ -1258,6 +1275,7 @@
|
|||
"묻기 (기본)": "確認する (既定)",
|
||||
"물어보지 않기": "尋ねない",
|
||||
"미리 보기": "クイックプレビュー",
|
||||
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "プレビューでは削除するものはありません - ただし上のルールのため実際には削除されることがあります",
|
||||
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
|
||||
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)",
|
||||
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "プレビューできません (文字コードが分かりません)",
|
||||
|
|
@ -1553,6 +1571,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした",
|
||||
"서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。",
|
||||
"서버가 답하지 않습니다 ({0}초)": "サーバーが応答しません ({0} 秒)",
|
||||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください",
|
||||
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
|
||||
|
|
@ -1881,6 +1900,8 @@
|
|||
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。",
|
||||
"옮길까요?": "移動しますか?",
|
||||
"옵션": "オプション",
|
||||
"옵션은 - 나 -- 로 시작합니다": "オプションは - か -- で始まります",
|
||||
"옵션을 알아볼 수 없습니다: {0} - {1}": "オプションを認識できません: {0} - {1}",
|
||||
"옵션을 저장했습니다": "オプションを保存しました",
|
||||
"완료": "完了",
|
||||
"완료 시 자동 동영상 추가": "完了時に動画を自動追加",
|
||||
|
|
@ -1902,6 +1923,7 @@
|
|||
"우르두어": "ウルドゥー語",
|
||||
"우크라이나어": "ウクライナ語",
|
||||
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。",
|
||||
"원격 서버 응답 기다리기": "リモートサーバーの応答待ち",
|
||||
"원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}",
|
||||
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
|
||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
|
||||
|
|
@ -2045,6 +2067,7 @@
|
|||
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
|
||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "このオプションの一部のフィルタールールはプレビューできないため、自動では削除しません",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
|
||||
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
||||
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
|
||||
|
|
@ -2412,6 +2435,7 @@
|
|||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
|
||||
"지금 작업 취소: {0}": "現在の作業をキャンセル: {0}",
|
||||
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
|
||||
"지금 지우기 (확인)": "今すぐ削除 (確認)",
|
||||
"지금 짝 추가": "今のペアを追加",
|
||||
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
|
||||
"지금 폴더": "現在のフォルダー",
|
||||
|
|
@ -2424,14 +2448,17 @@
|
|||
"지금까지 찾은 {0}개 모두": "これまでに見つかった {0} 件すべて",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
|
||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
|
||||
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
|
||||
"지난 작업 기록": "過去の作業記録",
|
||||
"지문 확인하고 연결": "フィンガープリントを確認して接続",
|
||||
"지우고 맞추기": "削除して同期",
|
||||
"지우고 초기화": "削除して初期化",
|
||||
"지우기": "削除",
|
||||
"지우기 포함": "削除あり",
|
||||
"지우기가 들어 있는 작업입니다": "削除を含む作業です",
|
||||
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "削除を含む実行のため、比較できないと実行できません。元・対象を確認してから [もう一度比較] を押してください。",
|
||||
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "削除を含む実行のため、削除一覧が表示されてから実行できます。",
|
||||
"지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.": "削除を確認した後に rsync オプションが変わり、削除するものが増えました。もう一度確認してください。",
|
||||
"지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "削除するフォルダーの中にリストから隠した項目が {0} 件あります - 一緒に削除されます",
|
||||
"지우면 휴지통으로": "削除するとごみ箱へ",
|
||||
"지우지 못한 파일: {0}": "削除できなかったファイル: {0}",
|
||||
|
|
@ -2579,6 +2606,9 @@
|
|||
"큰 파일": "大きいファイル",
|
||||
"큰 파일이 없습니다": "大きいファイルはありません",
|
||||
"큰 폴더": "大きいフォルダー",
|
||||
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "ダブルクォート (\") を含むオプションは Windows の rsync に渡せません: {0}",
|
||||
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "ダブルクォート (\") を含む引数は Windows の rsync にそのまま渡せないため、実行しませんでした: {0}",
|
||||
"큰따옴표 (\") 를 빼 주세요": "二重引用符 (\") を外してください",
|
||||
"키 있음": "キーあり",
|
||||
"타갈로그어": "タガログ語",
|
||||
"타밀어": "タミル語",
|
||||
|
|
@ -2702,6 +2732,7 @@
|
|||
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します",
|
||||
"표시 지우기": "マークを解除",
|
||||
"표준": "標準",
|
||||
"표준 입력 (-) 은 쓸 수 없습니다": "標準入力 (-) は使えません",
|
||||
"푸는 중": "展開中",
|
||||
"푸는 중: {0}": "展開中: {0}",
|
||||
"풀 수 없는 이름 {0}개": "展開できない名前 {0} 件",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
"- 감지한 언어": "- 检测到的语言",
|
||||
"-1 은 아무렇게나": "-1 为随机",
|
||||
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
|
||||
"/ 로 시작하는 전체 경로로": "请使用以 / 开头的完整路径",
|
||||
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
|
||||
"0 으로": "归零",
|
||||
"1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化",
|
||||
|
|
@ -153,6 +154,7 @@
|
|||
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)",
|
||||
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间",
|
||||
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
|
||||
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "请使用以驱动器开头的完整路径,如 C:\\",
|
||||
"Chrome (내려받기)": "Chrome(下载)",
|
||||
"Chrome (내장)": "Chrome(内置)",
|
||||
"Chrome 에서 가져오기": "从 Chrome 导入",
|
||||
|
|
@ -356,6 +358,7 @@
|
|||
"VLC 로 열기": "用 VLC 打开",
|
||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "通过 WebDAV · SFTP · FTP · SMB 下载或上传时,若这段时间内没有任何数据往来,将提示失败 (服务器停止时不会一直等待)",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。",
|
||||
|
|
@ -466,6 +469,7 @@
|
|||
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
|
||||
"rsync 에 고르기": "选为 rsync",
|
||||
"rsync 옵션": "rsync 选项",
|
||||
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "这些是 rsync 选项中排除的项目 (--exclude 等),但因为有 --delete-excluded,会从目标中删除。若要保留,请从选项中去掉 --delete-excluded。",
|
||||
"rsync 종료 코드 {0}": "rsync 退出代码 {0}",
|
||||
"settings.json 없음": "缺少 settings.json",
|
||||
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
|
||||
|
|
@ -524,6 +528,7 @@
|
|||
"{0} 을(를) 설치했습니다.": "已安装 {0}。",
|
||||
"{0} 을(를) 지울까요?": "要删除 {0} 吗?",
|
||||
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} 的 SHA256 不一致(已损坏或是其他文件)。已停止安装。",
|
||||
"{0} 의 값이 없습니다": "{0} 缺少值",
|
||||
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。",
|
||||
"{0} 이상": "{0} 及以上",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
|
||||
|
|
@ -609,6 +614,7 @@
|
|||
"{0}장을 올려 저장했습니다{1}": "已提高并保存 {0} 页{1}",
|
||||
"{0}줄": "{0} 行",
|
||||
"{0}초": "{0} 秒",
|
||||
"{0}초 (기본)": "{0} 秒 (默认)",
|
||||
"{0}초 안에 준비되지 않았습니다.": "未能在 {0} 秒内准备好。",
|
||||
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。",
|
||||
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android 上的 yt-dlp 新版本检查 (在下载工具内部另行连接)",
|
||||
|
|
@ -653,6 +659,7 @@
|
|||
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
|
||||
"값을 적어 주세요 (예: --exclude=*.tmp)": "请填写值 (例: --exclude=*.tmp)",
|
||||
"강하게 (0.4)": "强 (0.4)",
|
||||
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
|
||||
"같습니다 ✔": "相同 ✔",
|
||||
|
|
@ -696,6 +703,7 @@
|
|||
"개인 키의 암호가 맞지 않습니다": "私钥密码不正确",
|
||||
"거꾸로": "倒序",
|
||||
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
|
||||
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "已排除的 {0} 项也会被删除 (--delete-excluded)",
|
||||
"건너뛰기": "跳过",
|
||||
"건너뛴 링크 {0}개": "跳过的链接 {0} 个",
|
||||
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",
|
||||
|
|
@ -721,6 +729,7 @@
|
|||
"경로 입력 · 네트워크 서버": "输入路径 · 网络服务器",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
|
||||
"경로로 읽힙니다: {0}": "会被当作路径: {0}",
|
||||
"경로를 복사했습니다: {0}": "已复制路径: {0}",
|
||||
"계산 안 함": "不计算",
|
||||
"계속": "持续",
|
||||
|
|
@ -745,6 +754,7 @@
|
|||
"고치기": "编辑",
|
||||
"고친 옵션을 저장하지 않았습니다": "修改的选项尚未保存",
|
||||
"고화질": "高画质",
|
||||
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "请用直引号和连字符 (-) 重新输入",
|
||||
"골라서 시작": "选择后开始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
|
||||
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)",
|
||||
|
|
@ -814,6 +824,7 @@
|
|||
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
|
||||
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
|
||||
"기능 버튼 줄": "功能按钮栏",
|
||||
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "请填写日志或批处理文件的完整路径: {0}",
|
||||
"기본": "默认",
|
||||
"기본 DNS": "首选 DNS",
|
||||
"기본 앱으로 열기": "用默认应用打开",
|
||||
|
|
@ -1034,6 +1045,7 @@
|
|||
"대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}",
|
||||
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "需要确认是否删除仅在目标中的 {0} 个项目 (确认前不会删除)",
|
||||
"대상에서 {0}개를 지울까요?": "从目标中删除 {0} 个项目吗?",
|
||||
"대상에서 지울까요?": "要从目标中删除吗?",
|
||||
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)",
|
||||
"대상이 원본과 같거나 원본 안에 있습니다": "目标与源相同或位于源内",
|
||||
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "目标与源相同或位于源内 (每次同步都会改变源)。请选择其他文件夹。",
|
||||
|
|
@ -1123,6 +1135,8 @@
|
|||
"등록 중 일부가 실패했습니다.": "部分注册失败。",
|
||||
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
|
||||
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)",
|
||||
"따옴표가 닫히지 않았습니다: {0}": "引号未闭合: {0}",
|
||||
"따옴표를 닫아 주세요": "请闭合引号",
|
||||
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)",
|
||||
"라오어": "老挝语",
|
||||
"라이선스 원문 보기": "查看许可证原文",
|
||||
|
|
@ -1194,6 +1208,7 @@
|
|||
"먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "先通过 mDNS 询问 (会公布名称的 NAS),再仅尝试与本设备所在网络 (Wi-Fi · 有线) 中的设备建立 SMB (445) 连接并立即关闭。不会发送文件或账户信息。",
|
||||
"멈추고 옮기기": "停止并移动",
|
||||
"멈춤": "已停止",
|
||||
"멈춤 (시작 안 됨)": "停滞 (未开始)",
|
||||
"멈춤 (이번 실행 동안)": "暂停 (本次运行期间)",
|
||||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步",
|
||||
"멈춤 · {0}": "已停止 · {0}",
|
||||
|
|
@ -1229,6 +1244,8 @@
|
|||
"목록": "列表",
|
||||
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
|
||||
"목록 보기": "查看列表",
|
||||
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
|
||||
"목록 파일의 전체 경로를 적어 주세요": "请填写列表文件的完整路径",
|
||||
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
|
||||
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
|
||||
"목록에서 빼기": "从列表中移除",
|
||||
|
|
@ -1258,6 +1275,7 @@
|
|||
"묻기 (기본)": "询问 (默认)",
|
||||
"물어보지 않기": "不询问",
|
||||
"미리 보기": "快速预览",
|
||||
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "预览中没有要删除的项目 - 但由于上面的规则,实际上可能会被删除",
|
||||
"미리 볼 수 없습니다: {0}": "无法预览: {0}",
|
||||
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)",
|
||||
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "无法预览 (未知的文本编码)",
|
||||
|
|
@ -1553,6 +1571,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片",
|
||||
"서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。",
|
||||
"서버가 답하지 않습니다 ({0}초)": "服务器没有响应 ({0} 秒)",
|
||||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"",
|
||||
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
|
||||
|
|
@ -1881,6 +1900,8 @@
|
|||
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。",
|
||||
"옮길까요?": "移动吗?",
|
||||
"옵션": "选项",
|
||||
"옵션은 - 나 -- 로 시작합니다": "选项以 - 或 -- 开头",
|
||||
"옵션을 알아볼 수 없습니다: {0} - {1}": "无法识别的选项: {0} - {1}",
|
||||
"옵션을 저장했습니다": "已保存选项",
|
||||
"완료": "完成",
|
||||
"완료 시 자동 동영상 추가": "完成后自动添加视频",
|
||||
|
|
@ -1902,6 +1923,7 @@
|
|||
"우르두어": "乌尔都语",
|
||||
"우크라이나어": "乌克兰语",
|
||||
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。",
|
||||
"원격 서버 응답 기다리기": "等待远程服务器响应",
|
||||
"원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}",
|
||||
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
|
||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
|
||||
|
|
@ -2045,6 +2067,7 @@
|
|||
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
|
||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "这些选项中的部分过滤规则无法预览,因此不会自动删除",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
|
||||
"이 이동 버튼 지우기": "删除此移动按钮",
|
||||
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
|
||||
|
|
@ -2412,6 +2435,7 @@
|
|||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
|
||||
"지금 작업 취소: {0}": "已取消当前任务: {0}",
|
||||
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
|
||||
"지금 지우기 (확인)": "立即删除 (确认)",
|
||||
"지금 짝 추가": "添加当前一对",
|
||||
"지금 짝을 즐겨찾기에": "将当前一对加入收藏",
|
||||
"지금 폴더": "当前文件夹",
|
||||
|
|
@ -2424,14 +2448,17 @@
|
|||
"지금까지 찾은 {0}개 모두": "目前找到的全部 {0} 个",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
|
||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
|
||||
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
|
||||
"지난 작업 기록": "旧任务记录",
|
||||
"지문 확인하고 연결": "确认指纹并连接",
|
||||
"지우고 맞추기": "删除并同步",
|
||||
"지우고 초기화": "删除并重置",
|
||||
"지우기": "删除",
|
||||
"지우기 포함": "含删除",
|
||||
"지우기가 들어 있는 작업입니다": "此任务包含删除",
|
||||
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "此操作包含删除,无法比较时不能执行。请检查源和目标后按 [重新比较]。",
|
||||
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "此操作包含删除,需在显示删除列表后才能执行。",
|
||||
"지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.": "确认删除后 rsync 选项已更改,要删除的项目增加了。请再次确认。",
|
||||
"지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다": "要删除的文件夹中有 {0} 个从列表中隐藏的项 - 将一并删除",
|
||||
"지우면 휴지통으로": "删除时移到回收站",
|
||||
"지우지 못한 파일: {0}": "无法删除的文件: {0}",
|
||||
|
|
@ -2579,6 +2606,9 @@
|
|||
"큰 파일": "大文件",
|
||||
"큰 파일이 없습니다": "没有大文件",
|
||||
"큰 폴더": "大文件夹",
|
||||
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "含有双引号 (\") 的选项无法传给 Windows 上的 rsync: {0}",
|
||||
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "含有双引号 (\") 的参数无法原样传给 Windows 上的 rsync,因此未执行: {0}",
|
||||
"큰따옴표 (\") 를 빼 주세요": "请去掉双引号 (\")",
|
||||
"키 있음": "已有密钥",
|
||||
"타갈로그어": "他加禄语",
|
||||
"타밀어": "泰米尔语",
|
||||
|
|
@ -2702,6 +2732,7 @@
|
|||
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹",
|
||||
"표시 지우기": "清除标记",
|
||||
"표준": "标准",
|
||||
"표준 입력 (-) 은 쓸 수 없습니다": "不能使用标准输入 (-)",
|
||||
"푸는 중": "正在解压",
|
||||
"푸는 중: {0}": "正在解压: {0}",
|
||||
"풀 수 없는 이름 {0}개": "{0} 个无法解压的名称",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/core/languages.dart';
|
|||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/services/model_store.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음)
|
||||
void main() {
|
||||
|
|
@ -15,6 +16,7 @@ void main() {
|
|||
testWidgets('음성인식 중 취소', (tester) async {
|
||||
final video = Platform.environment['JJ_LONG_VIDEO'];
|
||||
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
|
||||
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
|
||||
final c = AppController(PlatformServices.create());
|
||||
await tester.runAsync(() async {
|
||||
await c.init();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ void main() {
|
|||
|
||||
testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async {
|
||||
final dir = testTempDir('jj_e2e_');
|
||||
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||
final video = p.join(dir.path, '영어 강의.mp4');
|
||||
final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav');
|
||||
final r = await Process.run('ffmpeg', [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:jj_mkvmaker/core/models.dart';
|
|||
import 'package:jj_mkvmaker/core/output_paths.dart';
|
||||
import 'package:jj_mkvmaker/services/model_store.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en).
|
||||
/// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이).
|
||||
|
|
@ -18,6 +19,7 @@ void main() {
|
|||
final video = Platform.environment['JJ_LONG_VIDEO'];
|
||||
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
|
||||
final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(',');
|
||||
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
|
||||
final c = AppController(PlatformServices.create());
|
||||
final sw = Stopwatch()..start();
|
||||
await tester.runAsync(() async {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ void main() {
|
|||
await req.response.close();
|
||||
});
|
||||
final url = 'http://127.0.0.1:${server.port}/page';
|
||||
final dataDir = testTempDir('jj_keep_').path;
|
||||
final dataDir = testTempDir('jj_keep_', heldByApp: true).path;
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ void main() {
|
|||
await req.response.close();
|
||||
});
|
||||
final url = 'http://127.0.0.1:${server.port}/';
|
||||
final dataDir = testTempDir('jj_webview_').path;
|
||||
final dataDir = testTempDir('jj_webview_', heldByApp: true).path;
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.webViewDataDir = dataDir;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ void main() {
|
|||
}
|
||||
CefRuntime.rememberStartState();
|
||||
expect(CefRuntime.readyThisRun, isTrue);
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_').path;
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_', heldByApp: true).path;
|
||||
Directory(data).createSync(recursive: true);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ void main() {
|
|||
for (var i = 0; i < 8; i++) {
|
||||
ComGuard.releaseForTest();
|
||||
}
|
||||
final dir = testTempDir('jj_com_').path;
|
||||
// Edge 가 앱이 끝날 때까지 사용자 데이터 폴더를 잡는다 (test_temp_check 가 찾음, 관리자 지시 10/20)
|
||||
final dir = testTempDir('jj_com_', heldByApp: true).path;
|
||||
final raw = await tester.runAsync<Object?>(() async {
|
||||
try {
|
||||
await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw'));
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async {
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_').path;
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_', heldByApp: true).path;
|
||||
Directory(data).createSync(recursive: true);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
|
|||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
|
@ -227,7 +228,8 @@ void main() {
|
|||
await tap(find.text('srcA').first);
|
||||
await tap(find.text('dstB').last);
|
||||
await tap(find.text('좌 → 우'));
|
||||
await tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
// 옵션 이름은 줄바꿈되지 않게 묶여 그려진다 (rsync_narrow dfed3fa) - 화면에 그려지는 글 그대로 찾는다
|
||||
await tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
|
||||
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
|
||||
await shot('rsync_move_confirm');
|
||||
await tap(find.widgetWithText(FilledButton, '이동'));
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ void main() {
|
|||
final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
|
||||
if (steps.existsSync()) steps.deleteSync();
|
||||
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true);
|
||||
// 관리자 지시 (10/20): 끝까지 돌면 단계 기록을 지운다 (앱이 도중에 꺼졌을 때만 남아 어디까지 갔는지 보인다)
|
||||
tearDownAll(() {
|
||||
if (steps.existsSync()) steps.deleteSync();
|
||||
});
|
||||
|
||||
testWidgets('DesktopShell', (tester) async {
|
||||
mark('start');
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
|
||||
/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다.
|
||||
/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown).
|
||||
Directory testTempDir(String prefix, {String? base}) {
|
||||
/// [heldByApp]: 앱이 끝날 때까지 놓지 않는 폴더 (Edge 사용자 데이터 - Windows 판 플러그인에는 환경 닫기가 없다).
|
||||
/// 이름 앞에 jj_held_ 를 붙이고, tool/test_temp_check.ps1 은 이것을 실패로 세지 않고 앱이 끝난 뒤 실행 폴더와 함께 지운다
|
||||
Directory testTempDir(String prefix, {String? base, bool heldByApp = false}) {
|
||||
final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true));
|
||||
final dir = root.createTempSync(prefix);
|
||||
final dir = root.createTempSync(heldByApp ? 'jj_held_$prefix' : prefix);
|
||||
addTearDown(() => deleteTestTemp(dir));
|
||||
if (base == null || base.isEmpty) expectInTaggedTemp(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): 앱 임시 폴더 ([DesktopStorageService.tempDirectory] = %TEMP%\jj_mkvmaker) 를 이 시험 폴더 아래로 돌린다
|
||||
/// (ai_e2e 가 남겨 test_temp_check 가 exit 2). 시험 끝에 기준을 되돌리고, 폴더는 [testTempDir] 의 정리가 함께 지운다
|
||||
/// (addTearDown 은 나중에 넣은 것부터 돌아 앱 기준을 먼저 되돌린다). 사용자 %TEMP%\jj_mkvmaker 는 건드리지 않는다
|
||||
void appTempInto(Directory dir) {
|
||||
final before = DesktopStorageService.tempRoot;
|
||||
DesktopStorageService.tempRoot = dir.path;
|
||||
addTearDown(() => DesktopStorageService.tempRoot = before);
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): tool/test_temp_check.ps1 이 TEMP · TMP 를 실행 폴더 (%TEMP%\t_jjrun_<표>) 로 돌리고 표를 JJ_TEST_TMP_TAG 로 넘긴다.
|
||||
/// 표가 있는데 임시 폴더가 그 밖에 생기면 (환경 변수가 시험 · 앱까지 오지 않음) 다른 세션 시험과 섞이므로 시험을 실패시킨다
|
||||
void expectInTaggedTemp(Directory dir) {
|
||||
final tag = Platform.environment['JJ_TEST_TMP_TAG'] ?? '';
|
||||
if (tag.isEmpty) return;
|
||||
expect(dir.absolute.path.toLowerCase(), contains('t_jjrun_$tag'.toLowerCase()),
|
||||
reason: 'TEMP 가 시험 실행 폴더로 오지 않음 (JJ_TEST_TMP_TAG=$tag)');
|
||||
}
|
||||
|
||||
/// 시험 폴더 지우기 - Windows 에서 앱 · WebView · 백신이 파일을 잠깐 잡고 있으면 몇 번 다시 해 본다 (기다림은 실제 시간: 가짜 시계 밖).
|
||||
/// 긴 경로는 \\?\ 로. 끝내 못 지우면 알리기만 하고 시험은 실패시키지 않는다 (tool/test_temp_check.ps1 이 남은 것을 잡는다)
|
||||
void deleteTestTemp(Directory dir) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_').path;
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_', heldByApp: true).path;
|
||||
Directory(data).createSync(recursive: true);
|
||||
ComponentStore.dataDirectory = data;
|
||||
ComponentStore.shared = ComponentStore(data);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ void main() {
|
|||
await c.init();
|
||||
await c.addVideos([video]);
|
||||
});
|
||||
c.settings.webViewDataDir = testTempDir('jj_webview_').path;
|
||||
c.settings.webViewDataDir = testTempDir('jj_webview_', heldByApp: true).path;
|
||||
final v = c.videos.single;
|
||||
// 같은 폴더의 movie.en.srt 가 자동으로 잡힘
|
||||
final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en));
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ void main() {
|
|||
|
||||
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
|
||||
final dir = testTempDir('jj_adj_e2e_');
|
||||
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||
final video = p.join(dir.path, '가로 영상.mp4');
|
||||
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
|
||||
final r = await Process.run(ffmpeg, [
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ void main() {
|
|||
await req.response.close();
|
||||
});
|
||||
final url = 'http://127.0.0.1:${server.port}/';
|
||||
final dataDir = testTempDir('jj_webview_').path;
|
||||
final dataDir = testTempDir('jj_webview_', heldByApp: true).path;
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
|
@ -24,6 +26,7 @@ void main() {
|
|||
late Directory tmp;
|
||||
late String left, right;
|
||||
late AppController c;
|
||||
final shotKey = GlobalKey();
|
||||
|
||||
setUp(() async {
|
||||
await i18n.apply('ko', save: false);
|
||||
|
|
@ -70,7 +73,11 @@ void main() {
|
|||
t.view.physicalSize = const Size(1600, 900);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.pumpWidget(MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)));
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||
home: ExplorerPage(c: c)));
|
||||
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
|
||||
expect(find.text('doc.txt'), findsOneWidget);
|
||||
}
|
||||
|
|
@ -104,6 +111,61 @@ void main() {
|
|||
|
||||
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
|
||||
|
||||
/// 관리자 지시 (10/20): 실패 때의 화면 캡처 (JJ_SHOT_DIR) 와 그때 열린 창 · 단추 · 글 목록
|
||||
Future<void> dump(WidgetTester t, String what) async {
|
||||
final texts = [for (final e in find.byType(Text).evaluate()) (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''];
|
||||
final buttons = [
|
||||
for (final e in find.byWidgetPredicate((w) => w is ButtonStyleButton || w is SnackBarAction).evaluate())
|
||||
'${e.widget.runtimeType}: ${[for (final x in find.descendant(of: find.byWidget(e.widget), matching: find.byType(Text)).evaluate()) (x.widget as Text).data].join(' ')}'
|
||||
];
|
||||
// ignore: avoid_print
|
||||
print('== $what 실패 때: 창 ${find.byType(Dialog).evaluate().length} · 스낵바 ${find.byType(SnackBar).evaluate().length}\n단추: $buttons\n글: $texts');
|
||||
final dir = Platform.environment['JJ_SHOT_DIR'];
|
||||
final b = shotKey.currentContext?.findRenderObject();
|
||||
if (dir == null || dir.isEmpty || b is! RenderRepaintBoundary) return;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
File(p.join(dir, 'b6_input_fail_${what.hashCode.toRadixString(16)}.png'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): [f] 가 보이는 것만이 아니라 정말 눌리는 자리에 올 때까지 기다린 뒤 누른다.
|
||||
/// 스낵바는 아래에서 올라오는 동안 (약 0.25초) 글은 이미 있지만 그 자리를 누르면 아무것도 맞지 않는다 - 예전에는 그때 눌러 [되돌리기] 가 빗나갔다.
|
||||
/// 끝내 눌리지 않으면 캡처 · 목록을 남기고 실패
|
||||
Future<void> tapReady(WidgetTester t, Finder f, String what, {int ms = 8000}) async {
|
||||
final sw = Stopwatch()..start();
|
||||
int? seen;
|
||||
bool within(Object? target, RenderObject box) {
|
||||
for (RenderObject? x = target is RenderObject ? target : null; x != null; x = x.parent) {
|
||||
if (identical(x, box)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < ms ~/ 50; i++) {
|
||||
final e = f.evaluate();
|
||||
if (e.length == 1) {
|
||||
seen ??= sw.elapsedMilliseconds;
|
||||
final box = e.single.renderObject;
|
||||
if (box is RenderBox && box.attached && box.hasSize) {
|
||||
final at = box.localToGlobal(box.size.center(Offset.zero));
|
||||
final hit = HitTestResult();
|
||||
t.binding.hitTestInView(hit, at, t.view.viewId);
|
||||
if (hit.path.any((x) => within(x.target, box))) {
|
||||
// ignore: avoid_print
|
||||
print('누를 수 있음: $what - 보인 뒤 ${sw.elapsedMilliseconds - seen}ms');
|
||||
await t.tapAt(at);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await t.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await dump(t, what);
|
||||
fail('$what 을 누를 수 없음 (보인 때: ${seen == null ? '안 보임' : '${seen}ms'})');
|
||||
}
|
||||
|
||||
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
|
||||
await open(t);
|
||||
final doc = t.getCenter(find.text('doc.txt'));
|
||||
|
|
@ -141,17 +203,15 @@ void main() {
|
|||
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
|
||||
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
|
||||
await t.tap(find.widgetWithText(OutlinedButton, '이동'));
|
||||
await tapReady(t, find.widgetWithText(OutlinedButton, '이동'), '묻기 창 [이동]');
|
||||
// 지금의 이동 확인 창
|
||||
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
||||
await t.tap(find.widgetWithText(FilledButton, '이동'));
|
||||
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
|
||||
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
|
||||
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
|
||||
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
|
||||
await t.tap(find.text('되돌리기'));
|
||||
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
||||
await t.tap(find.widgetWithText(FilledButton, '이동'));
|
||||
await tapReady(t, find.text('되돌리기'), '[되돌리기]');
|
||||
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
|
||||
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
|
||||
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
|
||||
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import 'dart:async';
|
||||
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: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/live_sync.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/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 관리자 3a 조건 (10/20): 실시간 동기화의 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것을 따로 묶은 모양을
|
||||
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 시험 폴더 안에서만 (확인 창은 [나중에] - 지우지 않음).
|
||||
/// 실행: flutter test integration_test\windows_live_excl_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_live_excl');
|
||||
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('지울지 묻는 창 $lang ${w}x$h', (t) async {
|
||||
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
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 = testTempDir('jj_liveexcl_shot_');
|
||||
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel) in [
|
||||
(src, 'a.txt'),
|
||||
(dst, 'only.txt'),
|
||||
(dst, '오래된 메모.txt'),
|
||||
(dst, 'skip.tmp'),
|
||||
(dst, 'cache/x.tmp'),
|
||||
(dst, 'build/obj/very_long_intermediate_file_name.tmp'),
|
||||
]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
}
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||
// 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음)
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp');
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
await t.runAsync(() => live.syncNow(pair));
|
||||
expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3));
|
||||
expect(live.reasked, contains(LiveSync.keyOf(pair)));
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
unawaited(confirmLiveDeletes(navKey.currentContext!, live, pair));
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'live_excl_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
navKey.currentState!.pop();
|
||||
await t.pump();
|
||||
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '[나중에] - 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
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: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/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart' show toCygwinPath;
|
||||
import 'package:jj_mkvmaker/l10n/tr.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/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 (merge 파일 - 안에 "H a.txt") 이 있는 지우기 포함 쌍 - 모니터링 카드의 빨간 줄
|
||||
/// "자동으로 지우지 않습니다" · [지금 지우기 (확인)] 창 (FilterLimitNote · 체크) 을 412 · 960, ko · en · ja 로 찍는다.
|
||||
/// 시험 폴더 안에서만 (창은 [나중에] - 지우지 않음).
|
||||
/// 실행: flutter test integration_test\windows_lsync_p0_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_lsync_p0');
|
||||
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('미리 볼 수 없는 규칙의 쌍 $lang ${w}x$h', (t) async {
|
||||
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
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 = testTempDir('jj_lsyncp0_shot_');
|
||||
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel) in [(src, 'a.txt'), (src, 'b.txt'), (dst, 'a.txt'), (dst, 'old.log'), (dst, 'extra.txt')]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('$dir/$rel');
|
||||
}
|
||||
File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"'
|
||||
..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
|
||||
final live = LiveSync(c)..start();
|
||||
addTearDown(live.dispose);
|
||||
final pair = c.settings.liveSyncPairs.single;
|
||||
await t.runAsync(() => live.syncNow(pair));
|
||||
expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty);
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||
home: MonitorPage(c: c),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 80)));
|
||||
await t.pump(const Duration(milliseconds: 80));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'lsync_p0_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await t.tap(find.text(tr('lsync (실시간 동기화)')));
|
||||
await settle();
|
||||
expect(find.text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다')), findsOneWidget);
|
||||
await shot('1_card');
|
||||
await t.tap(find.text(tr('지금 지우기 (확인)')));
|
||||
await settle(12);
|
||||
expect(find.textContaining(tr('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')), findsOneWidget);
|
||||
await shot('2_now');
|
||||
await t.tap(find.text(tr('나중에')));
|
||||
await settle();
|
||||
expect(File(p.join(dst, 'old.log')).existsSync() && File(p.join(dst, 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
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:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/download_manager.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/download_detect.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/services/downloader.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 이름을 바꾸지 않는 가짜 백엔드 (아무것도 받지 않음 - 줄의 상태는 시험이 정한다)
|
||||
class _Quiet implements DownloadBackend {
|
||||
@override
|
||||
final DownloadKind kind;
|
||||
_Quiet(this.kind);
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
t
|
||||
..state = DownloadState.downloading
|
||||
..progress = null;
|
||||
changed();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(DownloadTask t) async => t.state = DownloadState.paused;
|
||||
@override
|
||||
Future<void> cancel(DownloadTask t) async => t.state = DownloadState.cancelled;
|
||||
@override
|
||||
Future<void> shutdown() async {}
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20, 28 확인): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈춘 줄의 막대는 움직이지 않고 주황 "멈춤".
|
||||
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다 (가짜 백엔드 - 아무것도 받지 않음).
|
||||
/// 실행: flutter test integration_test\windows_magnet_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_magnet');
|
||||
const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef0123456';
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, hh) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('마그넷 줄 $lang ${w}x$hh', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final d = DownloadManager(
|
||||
backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)],
|
||||
settings: () => AppSettings()..downloadRoot = r'D:\dl',
|
||||
readClipboard: () async => null,
|
||||
);
|
||||
// ① 메타데이터를 받는 중 (dn 이름 · 움직이는 "준비 중…") ② 멈춤 (이유 후보 · [다시 시도]) ③ 이름이 없는 마그넷
|
||||
d.add('${h}1&dn=${Uri.encodeQueryComponent('[시험] 아주 긴 이름의 토렌트 묶음 - Big Buck Bunny 1080p (2008) 한글 자막 포함')}&tr=udp%3A%2F%2Ft.example%3A80');
|
||||
d.add('${h}2&dn=Ubuntu+24.04.1+Desktop+amd64&tr=udp%3A%2F%2Ft.example%3A80');
|
||||
d.add('${h}3');
|
||||
final stalled = d.tasks.firstWhere((x) => x.title.startsWith('Ubuntu'));
|
||||
stalled.extra
|
||||
..['stalled'] = true
|
||||
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 75000
|
||||
..['stallReasons'] = ['dns', 'firewall'];
|
||||
d.useSystemDns = () async {};
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)),
|
||||
));
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
expect(find.textContaining('[시험] 아주 긴 이름'), findsOneWidget);
|
||||
expect(find.textContaining('Ubuntu 24.04.1 Desktop amd64'), findsOneWidget);
|
||||
expect(find.text(tr('멈춤 (시작 안 됨)')), findsOneWidget);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'magnet_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
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:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/20, 28 지적): 알아볼 수 없는 옵션 안내 - 끝 문구가 문제 종류에 맞는지, 412 에서 넘치지 않는지.
|
||||
/// 확인 창의 빨간 글 (OptionProblemNote) 과 설정 칸 아래 글 (errorText, 3줄까지) 을 크기 412 · 960, 언어 ko · en · ja 로 찍는다.
|
||||
/// 실행: flutter test integration_test\windows_option_problem_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_optproblem');
|
||||
const cases = [
|
||||
'-a --delete --exclude="a.tmp --exclude=keep.txt', // 닫히지 않은 따옴표
|
||||
'-a delete', // 경로로 읽힘
|
||||
'-a --delete --exclude', // 값 없음
|
||||
'-a ---delete «x»', // 이상한 글자
|
||||
'-av delete --exclude', // 여러 종류
|
||||
];
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, hh) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('옵션 안내 $lang ${w}x$hh', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
for (final o in cases) ...[
|
||||
Text(o, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||
OptionProblemNote(optionProblems(o)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 설정 칸 (copy_sync_settings 의 rsync 옵션 칸과 같은 폭 · 글 줄 수)
|
||||
SizedBox(
|
||||
width: 380,
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: cases.last),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: optionProblemText(optionProblems(cases.last)),
|
||||
errorMaxLines: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
expect(find.byType(OptionProblemNote), findsNWidgets(cases.length));
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'optproblem_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
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: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/l10n/tr.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/settings_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 판단 (10/20): 환경 설정 > 파일 탐색기의 "원격 서버 응답 기다리기" (30 · 60 · 120 · 300초) 를
|
||||
/// 412 · 960, ko · en · ja 로 찍는다 (설정은 바꾸지 않음 - 시험 안의 설정만).
|
||||
/// 실행: flutter test integration_test\windows_remote_idle_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_remote_idle');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('원격 서버 응답 기다리기 $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 c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final key = GlobalKey();
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: RepaintBoundary(key: key, child: SettingsPage(c: c)),
|
||||
));
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
final tile = find.text(tr('원격 서버 응답 기다리기'));
|
||||
await t.scrollUntilVisible(tile, 300,
|
||||
scrollable: find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down).first, maxScrolls: 400);
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
await t.ensureVisible(tile);
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
expect(find.text(trf('{0}초 (기본)', [60])), findsOneWidget);
|
||||
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'remote_idle_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
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: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/l10n/tr.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;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 그 창의 지금 폴더가 보이는지 · 확인 창의 체크 제목
|
||||
/// "(--remove-source-files)" 가 "(--" 에서 갈리지 않는지를 412 · 960, ko · en · ja 로 찍는다 (시험 폴더 안에서만, [취소]).
|
||||
/// 실행: flutter test integration_test\windows_rsync_narrow_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_rsync_narrow');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('좁은 Rsync 화면 $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 = testTempDir('jj_rsnarrow_shot_');
|
||||
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳
|
||||
for (var i = 0; i < 60; i++) {
|
||||
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
|
||||
}
|
||||
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
|
||||
File(p.join(l, 'sub', 'a.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('a');
|
||||
Directory(p.join(r, 'rsub')).createSync(recursive: true);
|
||||
for (var i = 0; i < 20; i++) {
|
||||
Directory(p.join(tmp.path, 'zz${i.toString().padLeft(2, '0')}')).createSync();
|
||||
}
|
||||
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
|
||||
addTearDown(() => ExplorerPage.debugVolumes = null);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..explorerOrientation = 'side'
|
||||
..rsyncPaths = [l, r];
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||
home: ExplorerPage(c: c, rsync: true),
|
||||
));
|
||||
Future<void> settle([int n = 10]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'rsync_narrow_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle(20);
|
||||
final narrow = find.text('${tr('오른쪽')} · R').evaluate().isNotEmpty;
|
||||
await t.tap(find.text('sub').hitTestable());
|
||||
await settle();
|
||||
if (narrow) {
|
||||
await t.tap(find.text('${tr('오른쪽')} · R'));
|
||||
await settle(15);
|
||||
expect(find.text('rsub').hitTestable(), findsOneWidget, reason: '[오른쪽] 탭을 처음 보면 지금 폴더');
|
||||
await shot('1_right_tab');
|
||||
}
|
||||
await t.tap(find.text('rsub').hitTestable());
|
||||
await settle();
|
||||
await t.tap(find.text(tr('좌 → 우')).first);
|
||||
await settle(25);
|
||||
await shot('2_confirm');
|
||||
await t.tap(find.text(tr('취소')).last);
|
||||
await settle();
|
||||
expect(File(p.join(l, 'sub', 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 실행하지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ void main() {
|
|||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_xpshot_');
|
||||
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||
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']) {
|
||||
|
|
@ -74,6 +75,13 @@ void main() {
|
|||
}
|
||||
|
||||
await settle(20);
|
||||
// 관리자 지시 (10/20): 목록은 따로 읽혀 늦게 올 수 있다 (회귀에서 en 412 가 1.2초 안에 못 받아 실패) - 정해진 시간이 아니라 그 줄이 올 때까지 (최대 약 10초)
|
||||
final load = Stopwatch()..start();
|
||||
for (var i = 0; i < 160 && find.text('Show S01').evaluate().isEmpty; i++) {
|
||||
await settle(1);
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('목록 받음: $lang $w - 1.2초 뒤 ${load.elapsedMilliseconds}ms 더');
|
||||
// Show S01 펼치기 · 즐겨찾기 펼치기
|
||||
await t.tap(find.text('Show S01').first);
|
||||
await settle();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ import '../services/model_store.dart';
|
|||
import '../services/platform_services.dart';
|
||||
import '../services/system_usage.dart';
|
||||
import 'settings.dart';
|
||||
import 'cleanup.dart' show removeUpdateLeftovers;
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/remote_idle.dart';
|
||||
|
||||
extension<T> on Set<T> {
|
||||
Set<T> ifEmpty(Set<T> other) => isEmpty ? other : this;
|
||||
|
|
@ -158,6 +160,8 @@ class AppController extends ChangeNotifier {
|
|||
SftpRegistry.configure(settings.sftpServers);
|
||||
// 88: Android SMB 서버 (smb:// 경로)
|
||||
SmbRegistry.requireSecure = settings.smbRequireSecure;
|
||||
// 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기
|
||||
RemoteIdle.limit = Duration(seconds: settings.remoteIdleSeconds);
|
||||
SmbRegistry.configure(settings.smbServers);
|
||||
FtpRegistry.configure(settings.ftpServers);
|
||||
outputRootOverride =
|
||||
|
|
@ -205,6 +209,10 @@ class AppController extends ChangeNotifier {
|
|||
);
|
||||
_applySettings();
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
final gone = removeUpdateLeftovers(Directory.systemTemp.path);
|
||||
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')]));
|
||||
}
|
||||
ffmpegVersion = await _tool.version();
|
||||
if (ffmpegVersion == null) {
|
||||
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,29 @@ import '../l10n/tr.dart';
|
|||
import 'app_controller.dart';
|
||||
import 'download_manager.dart';
|
||||
|
||||
/// 관리자 지시 (10/20): Windows 업데이트가 %TEMP% 에 남긴 것 - 풀어 둔 새 판 (jj_mkvmaker_update_<판>, 수백 MB) ·
|
||||
/// 교체 스크립트 (jj_mkvmaker_update.ps1) · 기록 (jj_mkvmaker_update.log). 예전에는 [정리] 를 누르기 전까지 남았다.
|
||||
/// 앱이 켜질 때 부른다. 교체 스크립트가 아직 끝나는 중일 수 있어 [minAge] 보다 오래된 것만 지운다. 지운 이름을 돌려준다
|
||||
List<String> removeUpdateLeftovers(String sysTmp, {Duration minAge = const Duration(minutes: 2), DateTime? now}) {
|
||||
final mine = RegExp(r'^jj_mkvmaker_update(_.+|\.ps1|\.log)$');
|
||||
final before = (now ?? DateTime.now()).subtract(minAge);
|
||||
final removed = <String>[];
|
||||
try {
|
||||
for (final e in Directory(sysTmp).listSync(followLinks: false)) {
|
||||
final name = p.basename(e.path);
|
||||
if (!mine.hasMatch(name)) continue;
|
||||
try {
|
||||
if (!e.statSync().modified.isBefore(before)) continue;
|
||||
e.deleteSync(recursive: true);
|
||||
removed.add(name);
|
||||
} catch (_) {
|
||||
// 잡혀 있으면 (기록을 메모장으로 열어 둔 것 등) 다음에
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// 정리할 파일 · 폴더 하나
|
||||
class CleanupItem {
|
||||
final String path;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../platform/windows/rsync_installer.dart';
|
|||
import 'app_controller.dart';
|
||||
import 'settings.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
import '../core/rsync_launch.dart';
|
||||
|
||||
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
|
||||
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
|
||||
|
|
@ -154,8 +155,115 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
|
||||
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
|
||||
|
||||
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter);
|
||||
/// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions])
|
||||
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : '';
|
||||
|
||||
/// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두).
|
||||
/// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작
|
||||
/// (--delete-excluded 를 따르지 않음) 과 견준다 - 업데이트 뒤 --delete-excluded 로 새로 지워질 것이 있으면 묻는다
|
||||
Future<bool> _deletesGrew(LiveSyncPair x) async {
|
||||
final old = x.confirmedOptions;
|
||||
final now = _filterOf(x);
|
||||
final before = old == null
|
||||
? RsyncFilter(now.rules, unsupported: now.unsupported)
|
||||
: x.method == 'rsync'
|
||||
? RsyncFilter.parse(old)
|
||||
: RsyncFilter.none;
|
||||
List<String> gone(List<String> d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)];
|
||||
final a = gone(await diff(x, limit: 100000, filter: now));
|
||||
if (a.isEmpty) return false;
|
||||
final b = gone(await diff(x, limit: 100000, filter: before));
|
||||
return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/')));
|
||||
}
|
||||
|
||||
/// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다)
|
||||
final reasked = <String>{};
|
||||
|
||||
/// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등)
|
||||
/// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움).
|
||||
/// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들
|
||||
final noAutoDelete = <String, List<String>>{};
|
||||
final _deleteOnce = <String>{};
|
||||
|
||||
/// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만)
|
||||
List<String> unsupportedOf(LiveSyncPair x) => x.delete ? _filterOf(x).unsupported : const [];
|
||||
|
||||
/// 미리 볼 수 없는 규칙이 있는 쌍의 "맞출 것" 은 지우기 없이 센다 (저절로 지우지 않으므로 − 줄이 늘 남지 않게)
|
||||
LiveSyncPair _pendingPair(LiveSyncPair x) => unsupportedOf(x).isNotEmpty ? x.copyWith(delete: false) : x;
|
||||
|
||||
/// [지금 지우기] 창에 보일 지울 것 (미리 본 것 - 위 규칙 때문에 실제와 다를 수 있음) · 그 가운데 --delete-excluded 로 지워지는 것
|
||||
Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
|
||||
final excl = <String>[];
|
||||
final del = [
|
||||
for (final d in await diff(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl))
|
||||
if (d.startsWith('− ')) d.substring(2)
|
||||
];
|
||||
return (del, excl);
|
||||
}
|
||||
|
||||
/// 확인 창 (FilterLimitNote 와 체크) 을 거친 뒤 이번 한 번만 지우기 포함으로 맞춘다. 다음 자동 맞추기는 다시 보내기만
|
||||
Future<void> deleteNow(LiveSyncPair x) async {
|
||||
_deleteOnce.add(keyOf(x));
|
||||
await syncNow(x);
|
||||
}
|
||||
|
||||
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
|
||||
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
|
||||
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
|
||||
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다.
|
||||
/// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음)
|
||||
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
|
||||
[List<String>? excluded]) async {
|
||||
if (out.length >= limit) return;
|
||||
// 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다
|
||||
if (filter.protects(r, isDir: isDir)) return;
|
||||
Future<void> mark() async {
|
||||
// --delete-excluded 때문에 지워지는 것: 보내는 쪽에서 거른 것 · 그런 것이 든 폴더
|
||||
if (excluded != null &&
|
||||
filter.deleteExcluded &&
|
||||
(filter.excluded(r, isDir: isDir) || (isDir && await _hasExcluded(path, r, filter, sender: true)))) {
|
||||
excluded.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDir || !filter.protectsAny || !await _hasExcluded(path, r, filter)) {
|
||||
out.add('− $r');
|
||||
await mark();
|
||||
return;
|
||||
}
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(path);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final e in items) {
|
||||
final name = vBasename(e.path);
|
||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(e.path, '$r/$name', e.isDir, filter, out, limit, excluded);
|
||||
}
|
||||
}
|
||||
|
||||
/// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지
|
||||
static Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter, {bool sender = false}) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
for (final e in items) {
|
||||
final r = '$rel/${vBasename(e.path)}';
|
||||
if (sender ? filter.excluded(r, isDir: e.isDir) : filter.protects(r, isDir: e.isDir)) return true;
|
||||
if (e.isDir && await _hasExcluded(e.path, r, filter, sender: sender)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone])
|
||||
static Future<List<String>> diff(LiveSyncPair x,
|
||||
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded);
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -170,8 +278,11 @@ class LiveSync extends ChangeNotifier {
|
|||
if (out.length >= limit) return;
|
||||
final name = p.basename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 10/20 (15, 28 의 장면으로 찾음): rsync 쌍은 옵션의 거르기를 따른다 - 예전에는 rsync 가 보내지 않는 것을
|
||||
// 맞출 것으로 늘 세고, 지우지 않는 것 (받는 쪽의 거른 것) 을 지울지 물었다
|
||||
if (filter.excluded(r, isDir: e is Directory)) continue;
|
||||
names.add(name);
|
||||
final t = p.join(dst, name);
|
||||
if (e is Directory) {
|
||||
await walk(e.path, t, r);
|
||||
|
|
@ -189,7 +300,8 @@ class LiveSync extends ChangeNotifier {
|
|||
if (x.delete && await Directory(dst).exists()) {
|
||||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||||
final name = p.basename(e.path);
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit, excluded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +311,8 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
||||
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
static Future<List<String>> _diffV(LiveSyncPair x,
|
||||
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -238,10 +351,7 @@ class LiveSync extends ChangeNotifier {
|
|||
for (final t in there.entries) {
|
||||
final name = t.key;
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 거른 것은 지우지 않는다 (--delete-excluded 면 지움). 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만 (아래는 폴더 하나로 셈)
|
||||
if (filter.excluded(r, isDir: t.value.isDir) && !filter.deleteExcluded) continue;
|
||||
out.add('− $r');
|
||||
await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -261,8 +371,8 @@ class LiveSync extends ChangeNotifier {
|
|||
problems[k] = SourceUnreadableException(x.source, cause: e);
|
||||
}
|
||||
try {
|
||||
pending[k] = await diff(x, filter: _filterOf(x));
|
||||
await _countOnlyTarget(x);
|
||||
pending[k] = await diff(_pendingPair(x), filter: _filterOf(x));
|
||||
await _countOnlyTarget(_pendingPair(x));
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
@ -289,6 +399,10 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
|
||||
final toDelete = <String, List<String>>{};
|
||||
|
||||
/// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다
|
||||
/// (관리자 3a 조건 10/20: 사용자가 알고 누르게)
|
||||
final toDeleteExcluded = <String, List<String>>{};
|
||||
|
||||
/// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도
|
||||
final problems = <String, SourceUnreadableException>{};
|
||||
|
||||
|
|
@ -311,8 +425,10 @@ class LiveSync extends ChangeNotifier {
|
|||
|
||||
/// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다
|
||||
Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async {
|
||||
final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false);
|
||||
final now = delete ? x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x)) : x.copyWith(delete: false);
|
||||
toDelete.remove(keyOf(x));
|
||||
toDeleteExcluded.remove(keyOf(x));
|
||||
reasked.remove(keyOf(x));
|
||||
await _setPair(x, now);
|
||||
await syncNow(now);
|
||||
}
|
||||
|
|
@ -395,9 +511,15 @@ class LiveSync extends ChangeNotifier {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
|
||||
List<OptionProblem> unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x));
|
||||
|
||||
Future<void> syncNow(LiveSyncPair x) async {
|
||||
final k = keyOf(x);
|
||||
final nest = nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
|
||||
final bad = unreadableOf(x);
|
||||
final nest = bad.isNotEmpty
|
||||
? optionProblemMessage(bad)
|
||||
: nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
|
||||
if (nest != null) {
|
||||
status[k] = (DateTime.now(), nest);
|
||||
notifyListeners();
|
||||
|
|
@ -413,17 +535,46 @@ class LiveSync extends ChangeNotifier {
|
|||
final asked = x;
|
||||
try {
|
||||
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
|
||||
if (x.delete && !x.deleteConfirmed) {
|
||||
final del = [for (final d in await diff(x, limit: 100000, filter: _filterOf(x))) if (d.startsWith('− ')) d.substring(2)];
|
||||
final once = _deleteOnce.remove(k);
|
||||
final limits = unsupportedOf(x);
|
||||
if (limits.isEmpty) {
|
||||
noAutoDelete.remove(k);
|
||||
} else {
|
||||
noAutoDelete[k] = limits;
|
||||
}
|
||||
// 미리 볼 수 없는 규칙이 있으면 묻지도 않는다 (묻는 목록이 실제와 달라 - 28: 묻는 목록 밖의 a.txt 가 지워짐 · P 는 지킨 것을 끝없이 물음)
|
||||
var ask = limits.isEmpty && x.delete && !x.deleteConfirmed;
|
||||
if (limits.isEmpty && x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
|
||||
if (await _deletesGrew(x)) {
|
||||
ask = true;
|
||||
reasked.add(k);
|
||||
} else {
|
||||
final now = x.copyWith(confirmedOptions: _optionsOf(x));
|
||||
await _setPair(x, now);
|
||||
x = now;
|
||||
}
|
||||
}
|
||||
if (ask) {
|
||||
final excl = <String>[];
|
||||
final del = [
|
||||
for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2)
|
||||
];
|
||||
if (del.isEmpty) {
|
||||
await _setPair(x, x.copyWith(deleteConfirmed: true));
|
||||
final now = x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x));
|
||||
await _setPair(x, now);
|
||||
x = now;
|
||||
reasked.remove(k);
|
||||
} else {
|
||||
toDelete[k] = del;
|
||||
toDeleteExcluded[k] = excl;
|
||||
x = x.copyWith(delete: false);
|
||||
}
|
||||
} else {
|
||||
toDelete.remove(k);
|
||||
toDeleteExcluded.remove(k);
|
||||
reasked.remove(k);
|
||||
}
|
||||
if (limits.isNotEmpty && !once) x = x.copyWith(delete: false); // 보내기만
|
||||
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
|
||||
final allowEmpty = _allowEmpty.remove(k);
|
||||
if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty);
|
||||
|
|
@ -443,8 +594,8 @@ class LiveSync extends ChangeNotifier {
|
|||
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
|
||||
problems.remove(k);
|
||||
emptyDeletes.remove(k);
|
||||
pending[k] = await diff(asked, filter: _filterOf(asked));
|
||||
await _countOnlyTarget(asked);
|
||||
pending[k] = await diff(_pendingPair(asked), filter: _filterOf(asked));
|
||||
await _countOnlyTarget(_pendingPair(asked));
|
||||
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
|
||||
} on SourceUnreadableException catch (e) {
|
||||
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에
|
||||
|
|
@ -480,7 +631,15 @@ class LiveSync extends ChangeNotifier {
|
|||
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
|
||||
final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
|
||||
await Directory(x.target).create(recursive: true);
|
||||
final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
// P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
|
||||
final launch = await prepareRsyncLaunch(args);
|
||||
final ProcessResult r;
|
||||
try {
|
||||
r = await Process.run(exe, launch.args,
|
||||
workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
} finally {
|
||||
launch.dispose();
|
||||
}
|
||||
if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode);
|
||||
return RsyncOutput().feed('${r.stdout}\n').length;
|
||||
}
|
||||
|
|
@ -489,14 +648,20 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
|
||||
static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [
|
||||
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
|
||||
...splitOptions(s.rsyncOptions)
|
||||
.where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)),
|
||||
// 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
|
||||
...rsyncFileOptionArgs([
|
||||
for (final o in splitOptions(s.rsyncOptions))
|
||||
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
|
||||
], windows: windows),
|
||||
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
|
||||
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
|
||||
if (x.delete) '--delete',
|
||||
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
|
||||
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
|
||||
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
|
||||
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
|
||||
'${toCygwinPath(x.source, windows: windows)}/',
|
||||
'${toCygwinPath(x.target, windows: windows)}/',
|
||||
'${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20)
|
||||
];
|
||||
|
||||
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import '../services/image_ai.dart' show AiService;
|
|||
import '../services/app_shell.dart' show appIconOf;
|
||||
import '../services/secret_store.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/remote_idle.dart';
|
||||
|
||||
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
|
||||
class MoveTarget {
|
||||
|
|
@ -79,21 +80,28 @@ class LiveSyncPair {
|
|||
/// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42).
|
||||
/// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다.
|
||||
final bool deleteConfirmed;
|
||||
|
||||
/// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다
|
||||
/// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍
|
||||
final String? confirmedOptions;
|
||||
const LiveSyncPair(this.source, this.target,
|
||||
{this.method = 'builtin',
|
||||
this.delete = false,
|
||||
this.enabled = true,
|
||||
this.schedule = const [],
|
||||
this.deleteConfirmed = false});
|
||||
this.deleteConfirmed = false,
|
||||
this.confirmedOptions});
|
||||
|
||||
/// [delete] 를 새로 켜면 확인은 다시 받는다
|
||||
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed}) =>
|
||||
LiveSyncPair copyWith(
|
||||
{String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed, String? confirmedOptions}) =>
|
||||
LiveSyncPair(source, target,
|
||||
method: method ?? this.method,
|
||||
delete: delete ?? this.delete,
|
||||
enabled: enabled ?? this.enabled,
|
||||
schedule: schedule ?? this.schedule,
|
||||
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed));
|
||||
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed),
|
||||
confirmedOptions: confirmedOptions ?? this.confirmedOptions);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'source': source,
|
||||
|
|
@ -103,6 +111,7 @@ class LiveSyncPair {
|
|||
'enabled': enabled,
|
||||
'schedule': schedule,
|
||||
'deleteConfirmed': deleteConfirmed,
|
||||
if (confirmedOptions != null) 'confirmedOptions': confirmedOptions,
|
||||
};
|
||||
|
||||
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
|
||||
|
|
@ -114,6 +123,7 @@ class LiveSyncPair {
|
|||
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
|
||||
// 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로
|
||||
deleteConfirmed: j['deleteConfirmed'] as bool? ?? true,
|
||||
confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -612,6 +622,10 @@ class AppSettings {
|
|||
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
||||
List<FtpServer> ftpServers = [];
|
||||
|
||||
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초).
|
||||
/// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60
|
||||
int remoteIdleSeconds = RemoteIdle.defaultSeconds;
|
||||
|
||||
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
|
||||
|
||||
/// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스)
|
||||
|
|
@ -908,6 +922,7 @@ class AppSettings {
|
|||
'sftpServers': [for (final x in sftpServers) x.toJson()],
|
||||
'smbServers': [for (final x in smbServers) x.toJson()],
|
||||
'smbRequireSecure': smbRequireSecure,
|
||||
'remoteIdleSeconds': remoteIdleSeconds,
|
||||
'ftpServers': [for (final x in ftpServers) x.toJson()],
|
||||
'rsyncOptions': rsyncOptions,
|
||||
'robocopyOptions': robocopyOptions,
|
||||
|
|
@ -1169,6 +1184,7 @@ class AppSettings {
|
|||
if (x is Map) SftpServer.fromJson(x),
|
||||
]
|
||||
..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false
|
||||
..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds
|
||||
..smbServers = [
|
||||
for (final x in (j['smbServers'] as List?) ?? const [])
|
||||
if (x is Map) SmbServer.fromJson(x),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../core/sync_tools.dart';
|
|||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
import '../core/rsync_launch.dart';
|
||||
|
||||
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
|
||||
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
|
||||
|
|
@ -268,6 +269,11 @@ class TransferJob extends ChangeNotifier {
|
|||
|
||||
Future<void> run() async {
|
||||
try {
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
|
||||
if (method == CopyMethod.rsync) {
|
||||
final bad = optionProblems(options);
|
||||
if (bad.isNotEmpty) throw StateError(optionProblemMessage(bad));
|
||||
}
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
final s = sources[i];
|
||||
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
|
||||
|
|
@ -396,8 +402,20 @@ class TransferJob extends ChangeNotifier {
|
|||
if (log.length > 20) log.removeAt(0);
|
||||
}
|
||||
|
||||
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async {
|
||||
_proc = await Process.start(exe, args, runInShell: false);
|
||||
/// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]).
|
||||
/// robocopy 는 Windows 프로그램이라 그대로
|
||||
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async {
|
||||
final launch = rsync ? await prepareRsyncLaunch(args) : null;
|
||||
try {
|
||||
return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment);
|
||||
} finally {
|
||||
launch?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _startRaw(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut,
|
||||
{String? workingDirectory, Map<String, String>? environment}) async {
|
||||
_proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment);
|
||||
final a = _proc!.stdout.transform(enc.decoder).listen(onOut);
|
||||
final b = _proc!.stderr.transform(enc.decoder).listen((t) {
|
||||
for (final l in t.split(RegExp(r'[\r\n]'))) {
|
||||
|
|
@ -454,6 +472,7 @@ class TransferJob extends ChangeNotifier {
|
|||
}
|
||||
_tick();
|
||||
},
|
||||
rsync: true,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,26 @@ final _youtube = RegExp(
|
|||
final _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false);
|
||||
final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\s<>"]*)?', caseSensitive: false);
|
||||
|
||||
/// 마그넷 주소의 이름 (dn=…, 주소 글 풀기 · + 는 빈칸). 마그넷이 아니거나 이름이 없으면 null.
|
||||
/// 관리자 지시 (10/20, 28 확인): 메타데이터를 받기 전에도 줄 제목에 이 이름을 보인다 (마그넷 주소 전체 대신)
|
||||
String? magnetName(String url) {
|
||||
if (!url.toLowerCase().startsWith('magnet:?')) return null;
|
||||
for (final part in url.substring(8).split('&')) {
|
||||
final i = part.indexOf('=');
|
||||
if (i <= 0 || part.substring(0, i).toLowerCase() != 'dn') continue;
|
||||
String name;
|
||||
try {
|
||||
name = Uri.decodeQueryComponent(part.substring(i + 1));
|
||||
} catch (_) {
|
||||
name = part.substring(i + 1); // 잘못 적힌 % 는 그대로
|
||||
}
|
||||
// 줄바꿈 · 제어 문자는 빈칸으로 (한 줄 제목)
|
||||
name = name.replaceAll(RegExp(r'[\x00-\x1f\x7f]+'), ' ').trim();
|
||||
if (name.isNotEmpty) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로)
|
||||
List<DetectedLink> detectLinks(String text) {
|
||||
final found = <int, DetectedLink>{};
|
||||
|
|
|
|||
|
|
@ -935,7 +935,7 @@ class FileOps {
|
|||
}
|
||||
for (final x in items) {
|
||||
final r = '$rel/${vBasename(x.path)}';
|
||||
if (filter.excluded(r, isDir: x.isDir)) return true;
|
||||
if (filter.protects(r, isDir: x.isDir)) return true;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -947,7 +947,7 @@ class FileOps {
|
|||
final name = vBasename(x.path);
|
||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.excluded(r, isDir: x.isDir)) continue;
|
||||
if (filter.protects(r, isDir: x.isDir)) continue;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
|
|
@ -1011,11 +1011,9 @@ class FileOps {
|
|||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
_check();
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
if (filter.excluded(r, isDir: t.value.isDir)) {
|
||||
if (filter.deleteExcluded) await vDelete(t.value.path);
|
||||
continue;
|
||||
}
|
||||
if (t.value.isDir && !filter.isEmpty && !filter.deleteExcluded && await hasExcluded(t.value.path, r)) {
|
||||
// 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움)
|
||||
if (filter.protects(r, isDir: t.value.isDir)) continue;
|
||||
if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) {
|
||||
await deleteInside(t.value.path, r);
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'dns_resolver.dart';
|
|||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고).
|
||||
|
|
@ -380,7 +381,7 @@ class FtpRemote implements RemoteClient {
|
|||
}
|
||||
|
||||
Object _explain(Object e) {
|
||||
if (e is FtpException || e is DavLockedException) return e;
|
||||
if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e;
|
||||
if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable);
|
||||
if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable);
|
||||
if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other);
|
||||
|
|
@ -578,7 +579,8 @@ class FtpRemote implements RemoteClient {
|
|||
Stream<List<int>> read() async* {
|
||||
var left = length;
|
||||
try {
|
||||
await for (var chunk in data) {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게)
|
||||
await for (var chunk in idleRead(data, onTimeout: data.destroy)) {
|
||||
if (left != null) {
|
||||
if (chunk.length >= left) {
|
||||
yield chunk.sublist(0, left);
|
||||
|
|
@ -614,8 +616,11 @@ class FtpRemote implements RemoteClient {
|
|||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
await sock.addStream(data);
|
||||
await sock.flush();
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음)
|
||||
await idleWrite(data, (x) async {
|
||||
await sock.addStream(x);
|
||||
await sock.flush();
|
||||
}, onTimeout: sock.destroy);
|
||||
// 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다
|
||||
// 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어
|
||||
// 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import 'dart:async';
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 의 받기 · 올리기 자료 연결에서 조각 사이에 정한 시간 동안
|
||||
/// 아무것도 오가지 않으면 실패한다. 예전에는 서버가 중간에 멈추면 복사 · 동기화가 영원히 멈춰 있었다 (명령의 답에만 시간 제한).
|
||||
/// 정한 시간은 설정 "원격 서버 응답 기다리기" (처음 60초 · 30 · 60 · 120 · 300초) - 앱이 [limit] 에 넣는다
|
||||
class RemoteIdle {
|
||||
static const choices = [30, 60, 120, 300];
|
||||
static const defaultSeconds = 60;
|
||||
static Duration limit = const Duration(seconds: defaultSeconds);
|
||||
}
|
||||
|
||||
/// 조각 사이에 정한 시간이 지나도 서버가 아무것도 주고받지 않음
|
||||
class RemoteIdleTimeout implements Exception {
|
||||
final int seconds;
|
||||
const RemoteIdleTimeout(this.seconds);
|
||||
@override
|
||||
String toString() => trf('서버가 답하지 않습니다 ({0}초)', [seconds]);
|
||||
}
|
||||
|
||||
/// 받기: 조각 사이가 [limit] (없으면 [RemoteIdle.limit]) 를 넘으면 [RemoteIdleTimeout] 으로 끝나는 스트림.
|
||||
/// 그때 [onTimeout] 으로 그 연결을 끊고 원래 스트림의 구독도 끊는다 (늦게 오는 조각 · 오류를 받지 않음).
|
||||
/// 받는 쪽이 멈춰 (pause) 있는 동안은 세지 않는다
|
||||
Stream<T> idleRead<T>(Stream<T> s, {Duration? limit, void Function()? onTimeout}) {
|
||||
final d = limit ?? RemoteIdle.limit;
|
||||
late StreamController<T> out;
|
||||
StreamSubscription<T>? sub;
|
||||
Timer? t;
|
||||
void arm() {
|
||||
t?.cancel();
|
||||
t = Timer(d, () {
|
||||
final x = sub;
|
||||
sub = null;
|
||||
// 오류를 먼저 넣고 닫은 뒤에 연결을 끊는다 - 먼저 끊으면 원래 스트림의 "끝" 이 먼저 와서 정상으로 끝난 것처럼 보였다
|
||||
if (!out.isClosed) {
|
||||
out.addError(RemoteIdleTimeout(d.inSeconds));
|
||||
unawaited(out.close());
|
||||
}
|
||||
onTimeout?.call();
|
||||
unawaited(Future(() => x?.cancel()).catchError((_) {}));
|
||||
});
|
||||
}
|
||||
|
||||
out = StreamController<T>(
|
||||
onListen: () {
|
||||
arm();
|
||||
sub = s.listen((v) {
|
||||
if (out.isClosed) return;
|
||||
arm();
|
||||
out.add(v);
|
||||
}, onError: (Object e, StackTrace st) {
|
||||
if (out.isClosed) return;
|
||||
arm();
|
||||
out.addError(e, st);
|
||||
}, onDone: () {
|
||||
t?.cancel();
|
||||
if (!out.isClosed) unawaited(out.close());
|
||||
});
|
||||
},
|
||||
onPause: () {
|
||||
t?.cancel();
|
||||
sub?.pause();
|
||||
},
|
||||
onResume: () {
|
||||
sub?.resume();
|
||||
arm();
|
||||
},
|
||||
onCancel: () {
|
||||
t?.cancel();
|
||||
final x = sub;
|
||||
sub = null;
|
||||
return x?.cancel();
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
/// 올리기: [send] 가 [data] 에서 [limit] 동안 한 조각도 더 가져가지 않으면 (서버가 받지 않아 보내기가 막힘) 또는 다 가져간 뒤
|
||||
/// [limit] 안에 끝나지 않으면 (서버의 마지막 답이 없음) [RemoteIdleTimeout]. 그때 [onTimeout] 으로 그 연결을 끊는다
|
||||
Future<T> idleWrite<T>(Stream<List<int>> data, Future<T> Function(Stream<List<int>> s) send,
|
||||
{Duration? limit, void Function()? onTimeout}) {
|
||||
final d = limit ?? RemoteIdle.limit;
|
||||
final fail = Completer<T>();
|
||||
Timer? t;
|
||||
void arm() {
|
||||
t?.cancel();
|
||||
t = Timer(d, () {
|
||||
if (fail.isCompleted) return;
|
||||
fail.completeError(RemoteIdleTimeout(d.inSeconds));
|
||||
onTimeout?.call();
|
||||
});
|
||||
}
|
||||
|
||||
arm();
|
||||
final work = send(data.map((x) {
|
||||
arm();
|
||||
return x;
|
||||
}));
|
||||
work.then((v) {
|
||||
t?.cancel();
|
||||
if (!fail.isCompleted) fail.complete(v);
|
||||
}, onError: (Object e, StackTrace st) {
|
||||
t?.cancel();
|
||||
if (!fail.isCompleted) fail.completeError(e, st);
|
||||
});
|
||||
return fail.future;
|
||||
}
|
||||
|
||||
/// 한 번의 요청 (SMB 조각 읽기 · 쓰기, WebDAV 응답 기다리기) 에 같은 시간 제한
|
||||
Future<T> idleCall<T>(Future<T> f, {Duration? limit, void Function()? onTimeout}) {
|
||||
final d = limit ?? RemoteIdle.limit;
|
||||
// 시간이 지나 연결을 끊은 뒤 원래 요청이 늦게 실패해도 (연결 닫힘 등) 처리되지 않은 오류로 남지 않게
|
||||
f.ignore();
|
||||
return f.timeout(d, onTimeout: () {
|
||||
onTimeout?.call();
|
||||
throw RemoteIdleTimeout(d.inSeconds);
|
||||
});
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ import 'sync_tools.dart' show splitOptions;
|
|||
/// - `이름/***` 은 그 폴더와 안의 모든 것.
|
||||
/// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다).
|
||||
/// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다).
|
||||
/// - 관리자 지시 (10/20): 규칙마다 걸리는 쪽 - 그냥 `+` · `-` 는 양쪽, `H` (hide) = `-,s` · `S` (show) = `+,s` 는 보내는 쪽
|
||||
/// (보낼 것 고르기) 에만, `P` (protect) = `-,r` · `R` (risk) = `+,r` 은 받는 쪽 (지울 것 고르기) 에만. 쪽마다 그 쪽에 걸리는
|
||||
/// 규칙만 위에서부터 보고 처음 맞는 것이 이긴다. --delete-excluded 면 쪽을 적지 않은 규칙은 보내는 쪽에만 (rsync 와 같음).
|
||||
/// 보내는 쪽에서 거른 것 (보내지 않음) 은 받는 쪽에서 지울 것이 되고, 받는 쪽 규칙에 걸린 것만 지킨다.
|
||||
class RsyncFilter {
|
||||
final List<RsyncFilterRule> rules;
|
||||
|
||||
|
|
@ -30,6 +34,13 @@ class RsyncFilter {
|
|||
|
||||
bool get isEmpty => rules.isEmpty && !deleteExcluded;
|
||||
|
||||
/// 받는 쪽에서 지키는 규칙이 하나라도 있는지 (없으면 받는 쪽에만 있는 것은 모두 지운다 - 폴더를 뒤질 필요 없음)
|
||||
bool get protectsAny => rules.any((r) => !r.include && _onReceiver(r));
|
||||
|
||||
bool _onSender(RsyncFilterRule r) => r.side != RuleSide.receiver;
|
||||
bool _onReceiver(RsyncFilterRule r) =>
|
||||
r.side == RuleSide.receiver || r.side == RuleSide.bothExplicit || (r.side == RuleSide.both && !deleteExcluded);
|
||||
|
||||
/// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈)
|
||||
factory RsyncFilter.parse(String options, {List<String>? Function(String path)? readLines}) {
|
||||
final read = readLines ?? _readLines;
|
||||
|
|
@ -63,15 +74,34 @@ class RsyncFilter {
|
|||
}
|
||||
|
||||
void filterRule(String r) {
|
||||
// 15 검토 F1: 고치개 (",s" 보내는 쪽만 · ",r" 받는 쪽만 · ",!" 반대 · ",p" 등) 가 붙은 규칙은 뜻이 달라
|
||||
// (예: "-,s a.txt" 는 받는 쪽 a.txt 를 지키지 않아 --delete 로 지워진다) 미리 볼 수 없다 - 적용하지 않고 알린다.
|
||||
// H · S · P · R · merge 도 같다
|
||||
final m = RegExp(r'^(\+|-|include|exclude)\s+(.+)$').firstMatch(r.trim());
|
||||
// 관리자 지시 (10/20): H · S · P · R 과 고치개 ",s" (보내는 쪽만) · ",r" (받는 쪽만) 를 rsync 와 같게 따른다
|
||||
// (예: "-,s a.txt" · "H a.txt" 는 보내지 않고, 받는 쪽 a.txt 는 지키지 않아 --delete 로 지워진다 - 미리 보기에도 그렇게).
|
||||
// 그 밖의 고치개 (",!" 반대 · ",p" · ",x" · ",e" 등) · merge · "!" 는 미리 볼 수 없다 - 적용하지 않고 알린다
|
||||
final m = RegExp(r'^(\+|-|include|exclude|H|S|P|R|hide|show|protect|risk)(?:,([a-zA-Z!/]*))?\s+(.+)$').firstMatch(r.trim());
|
||||
if (m == null) {
|
||||
unsupported.add('--filter=$r');
|
||||
return;
|
||||
}
|
||||
rules.add(RsyncFilterRule(m.group(2)!, include: m.group(1) == '+' || m.group(1) == 'include'));
|
||||
final name = m.group(1)!, mods = m.group(2) ?? '';
|
||||
final short = const {'include': '+', 'exclude': '-', 'hide': 'H', 'show': 'S', 'protect': 'P', 'risk': 'R'}[name] ?? name;
|
||||
// 쪽 (s · r) 말고 다른 고치개는 미리 볼 수 없다. H · S · P · R 에는 쪽 고치개도 받지 않는다 (이미 쪽이 정해짐)
|
||||
if (mods.contains(RegExp(r'[^sr]')) || ('HSPR'.contains(short) && mods.isNotEmpty)) {
|
||||
unsupported.add('--filter=$r');
|
||||
return;
|
||||
}
|
||||
final include = short == '+' || short == 'S' || short == 'R';
|
||||
final side = switch (short) {
|
||||
'H' || 'S' => RuleSide.sender,
|
||||
'P' || 'R' => RuleSide.receiver,
|
||||
_ => mods.contains('s') && !mods.contains('r')
|
||||
? RuleSide.sender
|
||||
: mods.contains('r') && !mods.contains('s')
|
||||
? RuleSide.receiver
|
||||
: mods.isEmpty
|
||||
? RuleSide.both
|
||||
: RuleSide.bothExplicit,
|
||||
};
|
||||
rules.add(RsyncFilterRule(m.group(3)!, include: include, side: side));
|
||||
}
|
||||
|
||||
for (var i = 0; i < t.length; i++) {
|
||||
|
|
@ -132,22 +162,35 @@ class RsyncFilter {
|
|||
}
|
||||
}
|
||||
|
||||
/// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 걸러지는지
|
||||
/// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 보내는 쪽에서 걸러지는지 (보내지 않음 · 그 폴더 안으로 내려가지 않음)
|
||||
bool excluded(String rel, {required bool isDir}) {
|
||||
for (final r in rules) {
|
||||
if (r.matches(rel, isDir: isDir)) return !r.include;
|
||||
if (_onSender(r) && r.matches(rel, isDir: isDir)) return !r.include;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 받는 쪽의 [rel] 을 지우기 (--delete) 에서 지키는지 - 받는 쪽에 걸리는 규칙 가운데 처음 맞는 것이 빼기 (- · P) 면 지킨다.
|
||||
/// 보내는 쪽에만 거른 것 (H · -,s · --delete-excluded 의 그냥 규칙) 은 지키지 않는다
|
||||
bool protects(String rel, {required bool isDir}) {
|
||||
for (final r in rules) {
|
||||
if (_onReceiver(r) && r.matches(rel, isDir: isDir)) return !r.include;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 규칙이 걸리는 쪽. [both] = 쪽을 적지 않은 규칙 (--delete-excluded 면 보내는 쪽만), [bothExplicit] = ",sr" 로 양쪽을 적음
|
||||
enum RuleSide { both, bothExplicit, sender, receiver }
|
||||
|
||||
class RsyncFilterRule {
|
||||
final String pattern;
|
||||
final bool include;
|
||||
final RuleSide side;
|
||||
late final bool _dirOnly;
|
||||
late final RegExp _re;
|
||||
|
||||
RsyncFilterRule(this.pattern, {required this.include}) {
|
||||
RsyncFilterRule(this.pattern, {required this.include, this.side = RuleSide.both}) {
|
||||
var p = pattern;
|
||||
var all = false; // "이름/***" = 그 폴더와 안의 모든 것
|
||||
if (p.endsWith('/***')) {
|
||||
|
|
@ -160,7 +203,9 @@ class RsyncFilterRule {
|
|||
}
|
||||
final anchored = p.startsWith('/');
|
||||
if (anchored) p = p.substring(1);
|
||||
final whole = anchored || p.contains('/') || p.contains('**');
|
||||
// "이름/***" 은 원래 패턴에 / 가 있으므로 경로로 맞춘다 (28 확인 10/20: 이름으로만 맞추면 받는 쪽에만 있는 keep/sub 같은
|
||||
// 안쪽 폴더를 "P keep/***" 가 지키지 못해 미리 보기가 더 지운다고 보였다 - 거른 폴더 안으로 내려가지 않는 보내는 쪽에서는 드러나지 않음)
|
||||
final whole = anchored || all || p.contains('/') || p.contains('**');
|
||||
// rsync 의 wildmatch 는 바이트로 맞춘다 - 패턴 · 이름을 UTF-8 바이트 (한 글자 = 한 바이트) 로 바꿔 맞춘다
|
||||
final body = _glob(_bytes(p)) + (all ? '(?:/.*)?' : '');
|
||||
_re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
|
||||
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
|
||||
/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로).
|
||||
/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 -
|
||||
/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다).
|
||||
/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다.
|
||||
/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다).
|
||||
class RsyncLaunch {
|
||||
final List<String> args;
|
||||
final String? workingDirectory;
|
||||
final Map<String, String>? environment;
|
||||
final Directory? _dir;
|
||||
RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir);
|
||||
|
||||
/// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤)
|
||||
void dispose() {
|
||||
try {
|
||||
_dir?.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦)
|
||||
List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.contains('"')) a];
|
||||
|
||||
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
|
||||
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
|
||||
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
|
||||
final bad = msysUnsafeArgs(args);
|
||||
if (bad.isNotEmpty) {
|
||||
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));
|
||||
}
|
||||
final dir = await Directory.systemTemp.createTemp('jj_rsargs_');
|
||||
await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8);
|
||||
final msys = Platform.environment['MSYS'];
|
||||
return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import 'dns_resolver.dart';
|
|||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU).
|
||||
|
|
@ -266,8 +267,13 @@ class SftpRemote implements RemoteClient {
|
|||
return x;
|
||||
}
|
||||
|
||||
/// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 -
|
||||
/// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다"
|
||||
Future<T> _cmd<T>(Future<T> f) => idleCall(f, onTimeout: close);
|
||||
|
||||
/// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다
|
||||
Never _fail(Object e, String rel) {
|
||||
if (e is RemoteIdleTimeout) throw e;
|
||||
if (e is SftpStatusError) {
|
||||
switch (e.code) {
|
||||
case SftpStatusCode.noSuchFile:
|
||||
|
|
@ -298,13 +304,13 @@ class SftpRemote implements RemoteClient {
|
|||
final c = await _open();
|
||||
try {
|
||||
final out = <DavItem>[];
|
||||
for (final n in await c.sftp.listdir(_remote(c, rel))) {
|
||||
for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) {
|
||||
if (n.filename == '.' || n.filename == '..') continue;
|
||||
var a = n.attr;
|
||||
// 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로
|
||||
if (a.type == SftpFileType.symbolicLink) {
|
||||
try {
|
||||
a = await c.sftp.stat(_remote(c, _child(rel, n.filename)));
|
||||
a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename))));
|
||||
} catch (_) {}
|
||||
}
|
||||
out.add(_item(_child(rel, n.filename), a));
|
||||
|
|
@ -319,7 +325,7 @@ class SftpRemote implements RemoteClient {
|
|||
Future<DavItem?> stat(String rel) async {
|
||||
final c = await _open();
|
||||
try {
|
||||
return _item(rel, await c.sftp.stat(_remote(c, rel)));
|
||||
return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel))));
|
||||
} on SftpStatusError catch (e) {
|
||||
if (e.code == SftpStatusCode.noSuchFile) return null;
|
||||
_fail(e, rel);
|
||||
|
|
@ -339,7 +345,7 @@ class SftpRemote implements RemoteClient {
|
|||
await mkdirs(_parent(rel));
|
||||
final c = await _open();
|
||||
try {
|
||||
await c.sftp.mkdir(_remote(c, rel));
|
||||
await _cmd(c.sftp.mkdir(_remote(c, rel)));
|
||||
} catch (e) {
|
||||
// 그 사이에 다른 곳이 만들었으면 그대로
|
||||
if ((await stat(rel))?.isDir == true) return;
|
||||
|
|
@ -357,9 +363,9 @@ class SftpRemote implements RemoteClient {
|
|||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await c.sftp.rmdir(_remote(c, rel));
|
||||
await _cmd(c.sftp.rmdir(_remote(c, rel)));
|
||||
} else {
|
||||
await c.sftp.remove(_remote(c, rel));
|
||||
await _cmd(c.sftp.remove(_remote(c, rel)));
|
||||
}
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
|
|
@ -374,14 +380,14 @@ class SftpRemote implements RemoteClient {
|
|||
final t = await stat(to);
|
||||
if (t != null && !t.isDir) {
|
||||
try {
|
||||
await c.sftp.remove(_remote(c, to));
|
||||
await _cmd(c.sftp.remove(_remote(c, to)));
|
||||
} catch (e) {
|
||||
_fail(e, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await c.sftp.rename(_remote(c, from), _remote(c, to));
|
||||
await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to)));
|
||||
} catch (e) {
|
||||
_fail(e, from);
|
||||
}
|
||||
|
|
@ -395,7 +401,7 @@ class SftpRemote implements RemoteClient {
|
|||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await c.sftp.open(_remote(c, rel));
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel)));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
|
|
@ -403,14 +409,15 @@ class SftpRemote implements RemoteClient {
|
|||
StreamSubscription<Uint8List>? sub;
|
||||
out = StreamController<List<int>>(
|
||||
onListen: () {
|
||||
sub = f.read(offset: offset, length: length).listen(out.add, onError: (Object e) {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다
|
||||
sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) {
|
||||
try {
|
||||
_fail(e, rel);
|
||||
} catch (x) {
|
||||
out.addError(x);
|
||||
}
|
||||
}, onDone: () async {
|
||||
await f.close().catchError((_) {});
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
await out.close();
|
||||
});
|
||||
},
|
||||
|
|
@ -418,7 +425,7 @@ class SftpRemote implements RemoteClient {
|
|||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
await sub?.cancel();
|
||||
await f.close().catchError((_) {});
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
|
|
@ -429,17 +436,18 @@ class SftpRemote implements RemoteClient {
|
|||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await c.sftp.open(_remote(c, rel),
|
||||
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write);
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel),
|
||||
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
try {
|
||||
await f.write(data.map((x) => x is Uint8List ? x : Uint8List.fromList(x))).done;
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음)
|
||||
await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close);
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
} finally {
|
||||
await f.close().catchError((_) {});
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -447,7 +455,7 @@ class SftpRemote implements RemoteClient {
|
|||
Future<(int?, int?)> quota() async {
|
||||
final c = await _open();
|
||||
try {
|
||||
final v = await c.sftp.statvfs(c.root);
|
||||
final v = await _cmd(c.sftp.statvfs(c.root));
|
||||
final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize;
|
||||
return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit);
|
||||
} catch (_) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../l10n/tr.dart';
|
|||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1).
|
||||
/// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
|
|
@ -245,8 +246,9 @@ class SmbRemote implements RemoteClient {
|
|||
while (left == null || left > 0) {
|
||||
final Uint8List? data;
|
||||
try {
|
||||
data = await channel.invokeMethod<Uint8List>(
|
||||
'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)});
|
||||
// 관리자 판단 (10/20): 한 조각을 정한 시간 안에 받지 못하면 실패 (NAS 가 멈춰도 영원히 기다리지 않게)
|
||||
data = await idleCall(channel.invokeMethod<Uint8List>(
|
||||
'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}));
|
||||
} on PlatformException catch (e) {
|
||||
throw _explain(e, rel);
|
||||
}
|
||||
|
|
@ -276,8 +278,8 @@ class SmbRemote implements RemoteClient {
|
|||
while (bytes.length - at >= chunk || (all && at < bytes.length)) {
|
||||
final end = (at + chunk).clamp(0, bytes.length);
|
||||
try {
|
||||
await channel.invokeMethod<void>(
|
||||
'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)});
|
||||
await idleCall(channel.invokeMethod<void>(
|
||||
'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}));
|
||||
} on PlatformException catch (e) {
|
||||
throw _explain(e, rel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
final name = vBasename(x.path);
|
||||
if (isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.excluded(r, isDir: x.isDir)) continue;
|
||||
if (filter.protects(r, isDir: x.isDir)) continue;
|
||||
if (x.isDir && await _hasExcluded(x.path, r, filter)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
|
|
@ -211,16 +211,12 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음)
|
||||
if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue;
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
if (filter.excluded(r, isDir: e.isDir)) {
|
||||
// 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다)
|
||||
if (delete && filter.deleteExcluded) {
|
||||
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: e.isDir, size: e.size));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 177 (28 확인): 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고
|
||||
// 받는 쪽 규칙 (- · P · R · ,r) 에 걸린 것은 rsync 가 지우지 않는다. 관리자 지시 (10/20): 보내는 쪽에서만 거른 것
|
||||
// (H · -,s · --delete-excluded 의 그냥 규칙) 은 보내지 않았으므로 --delete 면 지운다. 지우지 않을 때는 거른 것을 세지 않음 (예전과 같음)
|
||||
if (filter.protects(r, isDir: e.isDir) || (!delete && filter.excluded(r, isDir: e.isDir))) continue;
|
||||
// 177 (28 확인): 받는 쪽에만 있는 폴더 안에 지키는 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고
|
||||
// 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만
|
||||
if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) {
|
||||
if (delete && e.isDir && filter.protectsAny && await _hasExcluded(e.path, r, filter)) {
|
||||
await deleteInside(e.path, r);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -233,7 +229,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
return out;
|
||||
}
|
||||
|
||||
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다)
|
||||
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 받는 쪽에서 지키는 것이 있는지 (지키는 폴더 안으로는 들어가지 않는다)
|
||||
Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
|
|
@ -245,7 +241,7 @@ Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
|
|||
final name = vBasename(x.path);
|
||||
if (isPartialFile(name) || isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.excluded(r, isDir: x.isDir)) return true;
|
||||
if (filter.protects(r, isDir: x.isDir)) return true;
|
||||
if (x.isDir && await _hasExcluded(x.path, r, filter)) return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'file_ops.dart' show appTempRsyncExcludes;
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
|
||||
///
|
||||
|
|
@ -21,36 +24,400 @@ enum CopyMethod {
|
|||
const defaultRsyncOptions = '-avPog';
|
||||
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
||||
|
||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로)
|
||||
List<String> splitOptions(String s) {
|
||||
final out = <String>[];
|
||||
/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
|
||||
/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
|
||||
/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
|
||||
/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로
|
||||
String cleanOptionText(String s) => s
|
||||
.replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ')
|
||||
.replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), '');
|
||||
|
||||
/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것
|
||||
const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D';
|
||||
|
||||
/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다)
|
||||
const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'};
|
||||
|
||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText].
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는
|
||||
/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다.
|
||||
/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다
|
||||
/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다.
|
||||
/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다.
|
||||
/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고
|
||||
/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다.
|
||||
/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -.
|
||||
/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
|
||||
List<String> splitOptions(String s) => _parseOptions(s).words;
|
||||
|
||||
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
|
||||
const _rsyncLong = {
|
||||
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',
|
||||
'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress',
|
||||
'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest',
|
||||
'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del',
|
||||
'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded',
|
||||
'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing',
|
||||
'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help',
|
||||
'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing',
|
||||
'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6',
|
||||
'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc',
|
||||
'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs',
|
||||
'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times',
|
||||
'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial',
|
||||
'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet',
|
||||
'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links',
|
||||
'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at',
|
||||
'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version',
|
||||
'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt',
|
||||
};
|
||||
|
||||
/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다)
|
||||
typedef _Word = ({String text, bool quoted});
|
||||
|
||||
/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부)
|
||||
({List<String> words, List<String> unclosed}) _parseOptions(String s) {
|
||||
final chars = cleanOptionText(s).split('');
|
||||
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
|
||||
final unclosed = <String>[];
|
||||
while (true) {
|
||||
final (raw, open) = _splitRaw(chars, literal);
|
||||
if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed);
|
||||
literal.add(open);
|
||||
final rest = chars.sublist(open).join().trim();
|
||||
unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest);
|
||||
}
|
||||
}
|
||||
|
||||
/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값)
|
||||
(List<_Word>, int?) _splitRaw(List<String> chars, Set<int> literal) {
|
||||
final out = <_Word>[];
|
||||
final cur = StringBuffer();
|
||||
String? quote;
|
||||
String? quote; // 닫는 곧은 따옴표 (" 또는 ')
|
||||
var curly = false; // 굽은 따옴표로 열었는지
|
||||
var openAt = -1;
|
||||
var has = false;
|
||||
for (final ch in s.split('')) {
|
||||
var quoted = false;
|
||||
bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]);
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
final ch = chars[i];
|
||||
if (quote != null) {
|
||||
if (ch == quote) {
|
||||
// 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다
|
||||
final closes = ch == quote && !curly ||
|
||||
curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1));
|
||||
if (closes) {
|
||||
quote = null;
|
||||
curly = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
} else if (literal.contains(i)) {
|
||||
cur.write(ch);
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = ch;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = _curlyQuote[ch];
|
||||
curly = true;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (ch.trim().isEmpty) {
|
||||
if (cur.isNotEmpty || has) out.add(cur.toString());
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
cur.clear();
|
||||
has = false;
|
||||
quoted = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
}
|
||||
if (cur.isNotEmpty || has) out.add(cur.toString());
|
||||
if (quote != null) return (out, openAt);
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
return (out, null);
|
||||
}
|
||||
|
||||
bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i]));
|
||||
|
||||
/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로
|
||||
String _fixRuleHead(String v) {
|
||||
if (v.isEmpty) return v;
|
||||
if (_dashLike.contains(v[0])) return '-${v.substring(1)}';
|
||||
if (v[0] == '\uFF0B') return '+${v.substring(1)}';
|
||||
return v;
|
||||
}
|
||||
|
||||
/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로)
|
||||
List<String> _normalizeWords(List<_Word> raw) {
|
||||
final out = <String>[];
|
||||
var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로)
|
||||
for (final w in raw) {
|
||||
var t = w.text;
|
||||
if (value.isNotEmpty) {
|
||||
out.add(value == 'filter' ? _fixRuleHead(t) : t);
|
||||
value = '';
|
||||
continue;
|
||||
}
|
||||
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
|
||||
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
|
||||
final n = _dashAt(t, 1) ? 2 : 1;
|
||||
final rest = t.substring(n);
|
||||
final name = rest.split('=').first;
|
||||
final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3)));
|
||||
t = '${long ? '--' : '-'}$rest';
|
||||
}
|
||||
if (t.startsWith('--filter=')) {
|
||||
t = '--filter=${_fixRuleHead(t.substring(9))}';
|
||||
} else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}';
|
||||
}
|
||||
out.add(t);
|
||||
if (t.startsWith('--')) {
|
||||
if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other';
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게
|
||||
const _rsyncLongWithValue = {
|
||||
'--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix',
|
||||
'--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit',
|
||||
'--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir',
|
||||
'--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv',
|
||||
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
|
||||
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
|
||||
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
|
||||
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
|
||||
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
|
||||
};
|
||||
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
|
||||
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
|
||||
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
|
||||
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
|
||||
|
||||
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
|
||||
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin }
|
||||
|
||||
/// [unreadableOptions] 의 한 줄과 그 종류
|
||||
class OptionProblem {
|
||||
final OptionProblemKind kind;
|
||||
final String text;
|
||||
|
||||
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
|
||||
final String token;
|
||||
const OptionProblem(this.kind, this.text, [this.token = '']);
|
||||
|
||||
@override
|
||||
String toString() => text;
|
||||
}
|
||||
|
||||
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
|
||||
String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final kinds = {for (final x in problems) x.kind};
|
||||
return [
|
||||
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
|
||||
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
|
||||
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
|
||||
// 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
|
||||
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
|
||||
if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
|
||||
String optionProblemMessage(List<OptionProblem> problems) =>
|
||||
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
|
||||
|
||||
/// [unreadableOptions] 의 몸통 (종류와 함께)
|
||||
List<OptionProblem> optionProblems(String options, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final parsed = _parseOptions(options);
|
||||
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
|
||||
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
|
||||
var value = false;
|
||||
var last = '';
|
||||
for (final t in parsed.words) {
|
||||
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
|
||||
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
|
||||
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
|
||||
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (value) {
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('---')) {
|
||||
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
|
||||
bad.add(OptionProblem(OptionProblemKind.path, t, t));
|
||||
} else if (t.startsWith('--')) {
|
||||
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
|
||||
last = t;
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
// 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙)
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
value = k == t.length - 1;
|
||||
last = t;
|
||||
} else {
|
||||
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
|
||||
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
|
||||
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
|
||||
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
|
||||
}
|
||||
}
|
||||
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
|
||||
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
|
||||
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
|
||||
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
|
||||
for (final f in listFileValues(parsed.words)) {
|
||||
// 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다
|
||||
if (f == '-') {
|
||||
bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f));
|
||||
} else if (!_absolutePath(f, windows: windows)) {
|
||||
bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
}
|
||||
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
|
||||
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
|
||||
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
|
||||
if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
|
||||
List<String> _optionValues(List<String> words, Set<String> names) => [
|
||||
for (var i = 0; i < words.length; i++)
|
||||
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
|
||||
words[i].substring(words[i].indexOf('=') + 1)
|
||||
else if (names.contains(words[i]) && i + 1 < words.length)
|
||||
words[i + 1],
|
||||
];
|
||||
|
||||
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
|
||||
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
|
||||
List<String> listFileValues(List<String> words) {
|
||||
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
|
||||
final out = <String>[];
|
||||
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) {
|
||||
out.add(w.substring(eq + 1));
|
||||
} else if (fileOpts.contains(w) && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
final f = mergeFile(w.substring(9));
|
||||
if (f != null) out.add(f);
|
||||
} else if (w == '--filter' && next != null) {
|
||||
final f = mergeFile(next);
|
||||
if (f != null) out.add(f);
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k < 0 || w[k] != 'f') continue;
|
||||
final rule = k == w.length - 1 ? next : w.substring(k + 1);
|
||||
if (k == w.length - 1) i++;
|
||||
final f = rule == null ? null : mergeFile(rule);
|
||||
if (f != null) out.add(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다.
|
||||
/// Android: / 로 시작
|
||||
bool _absolutePath(String f, {required bool windows}) =>
|
||||
windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');
|
||||
|
||||
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
|
||||
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
|
||||
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
|
||||
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
|
||||
if (!windows) return words;
|
||||
const opts = {
|
||||
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
|
||||
};
|
||||
// 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로,
|
||||
// 드라이브는 /cygdrive/… 로
|
||||
String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v)
|
||||
? toCygwinPath(v)
|
||||
: RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v)
|
||||
? v.replaceAll('/', '\\')
|
||||
: v;
|
||||
String rule(String r) {
|
||||
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
|
||||
return m == null ? r : '${m[1]}${conv(m[2]!)}';
|
||||
}
|
||||
|
||||
final out = <String>[];
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
|
||||
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
|
||||
} else if (opts.contains(w) && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(conv(next));
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
out.add('--filter=${rule(w.substring(9))}');
|
||||
} else if (w == '--filter' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
|
||||
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
|
||||
} else if (k >= 0 && w[k] == 'f' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else {
|
||||
out.add(w);
|
||||
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
|
||||
if (k == w.length - 1 && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.add(w);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
|
||||
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
|
||||
/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다
|
||||
/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로
|
||||
String rsyncDestPath(String path, {bool windows = true}) {
|
||||
if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\');
|
||||
return toCygwinPath(path, windows: windows);
|
||||
}
|
||||
|
||||
String toCygwinPath(String path, {bool windows = true}) {
|
||||
if (!windows) return path;
|
||||
final s = path.replaceAll('\\', '/');
|
||||
|
|
@ -78,7 +445,7 @@ List<String> rsyncArgs({
|
|||
bool contents = false,
|
||||
}) {
|
||||
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
|
||||
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
||||
final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
|
||||
return [
|
||||
...opts,
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
|
||||
|
|
@ -88,7 +455,7 @@ List<String> rsyncArgs({
|
|||
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
|
||||
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
|
||||
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
|
||||
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
'${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'sftp.dart';
|
|||
import 'smb.dart';
|
||||
import 'webdav.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 로컬 경로와 원격 경로 (WebDAV `dav://<서버 id>/<경로>` · 88 SFTP `sftp://<서버 id>/<경로>`) 를 같이 다루는 도구.
|
||||
/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, 원격만 여기서 서버 종류에 맞는 클라이언트 ([RemoteClient]) 로 처리한다.
|
||||
|
|
@ -235,8 +236,9 @@ Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime?
|
|||
try {
|
||||
await d.client.write(tmp.rel, data, length: length);
|
||||
} catch (_) {
|
||||
// 받다 만 .jjpart 를 지운다 - 서버가 멈췄으면 지우기도 답이 없을 수 있어 정한 시간까지만 (못 지우면 다음 정리 (71) 에서)
|
||||
try {
|
||||
await d.client.delete(tmp.rel);
|
||||
await idleCall(d.client.delete(tmp.rel));
|
||||
} catch (_) {}
|
||||
rethrow;
|
||||
}
|
||||
|
|
@ -248,15 +250,18 @@ Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime?
|
|||
try {
|
||||
await out.addStream(data);
|
||||
} catch (_) {
|
||||
// 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로)
|
||||
await out.close();
|
||||
// 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로).
|
||||
// 10/20 (15, 멈추는 서버 시험으로 찾음): 예전에는 finally 에서 한 번 더 닫다가 난 "File closed" 가 진짜 이유
|
||||
// (서버가 답하지 않습니다 · 연결 끊김 등) 를 덮어썼다 - 닫기 · 지우기의 오류는 삼키고 원래 오류를 그대로
|
||||
try {
|
||||
await out.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await File(tmp).delete();
|
||||
} catch (_) {}
|
||||
rethrow;
|
||||
} finally {
|
||||
await out.close();
|
||||
}
|
||||
await out.close();
|
||||
if (await File(path).exists()) await File(path).delete();
|
||||
await File(tmp).rename(path);
|
||||
if (modified != null) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:xml/xml.dart';
|
|||
import '../l10n/tr.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
class DavServer {
|
||||
|
|
@ -115,6 +116,10 @@ class DavClient implements RemoteClient {
|
|||
? null
|
||||
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
|
||||
|
||||
/// 관리자 판단 (10/20): 답 (머리) 을 정한 시간 ([RemoteIdle]) 까지만 기다린다 - 서버가 멈추면 그 연결을 끊고 "서버가 답하지 않습니다".
|
||||
/// 답의 몸은 [idleRead] 로 (조각 사이)
|
||||
Future<HttpClientResponse> _send(HttpClientRequest req) => idleCall(req.close(), onTimeout: req.abort);
|
||||
|
||||
Future<HttpClientRequest> _open(String method, Uri uri) async {
|
||||
// 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다)
|
||||
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
|
|
@ -164,8 +169,8 @@ class DavClient implements RemoteClient {
|
|||
final bytes = utf8.encode(body);
|
||||
req.contentLength = bytes.length;
|
||||
req.add(bytes);
|
||||
final res = await req.close();
|
||||
final text = await res.transform(utf8.decoder).join();
|
||||
final res = await _send(req);
|
||||
final text = await idleRead(res).transform(utf8.decoder).join();
|
||||
if (res.statusCode == 404) throw DavException(trf('없는 경로: {0}', [rel]), status: 404);
|
||||
if (res.statusCode != 207 && res.statusCode != 200) _fail(tr('목록'), res, text);
|
||||
return text;
|
||||
|
|
@ -231,8 +236,8 @@ class DavClient implements RemoteClient {
|
|||
|
||||
/// 폴더 만들기 (이미 있으면 그대로)
|
||||
Future<void> mkdir(String rel) async {
|
||||
final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close();
|
||||
await res.drain<void>();
|
||||
final res = await _send(await _open('MKCOL', uriOf(rel, dir: true)));
|
||||
await idleRead(res).drain<void>();
|
||||
if (res.statusCode == 201 || res.statusCode == 200) return;
|
||||
if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음
|
||||
_fail(tr('폴더 만들기'), res);
|
||||
|
|
@ -252,8 +257,8 @@ class DavClient implements RemoteClient {
|
|||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close();
|
||||
await res.drain<void>();
|
||||
final res = await _send(await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false)));
|
||||
await idleRead(res).drain<void>();
|
||||
if (res.statusCode == 404) return;
|
||||
if (res.statusCode >= 300) _fail(tr('지우기'), res);
|
||||
}
|
||||
|
|
@ -265,8 +270,8 @@ class DavClient implements RemoteClient {
|
|||
..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString())
|
||||
..set('Overwrite', overwrite ? 'T' : 'F');
|
||||
if (st?.isDir ?? false) req.headers.set('Depth', 'infinity');
|
||||
final res = await req.close();
|
||||
await res.drain<void>();
|
||||
final res = await _send(req);
|
||||
await idleRead(res).drain<void>();
|
||||
if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res);
|
||||
}
|
||||
|
||||
|
|
@ -277,12 +282,14 @@ class DavClient implements RemoteClient {
|
|||
/// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘)
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) async {
|
||||
final res = await (await _open('GET', uriOf(rel))).close();
|
||||
// 관리자 판단 (10/20): 답 (머리) 과 조각 사이를 정한 시간까지만 기다린다 - 서버가 멈추면 연결을 끊고 실패
|
||||
final req = await _open('GET', uriOf(rel));
|
||||
final res = await _send(req);
|
||||
if (res.statusCode != 200) {
|
||||
await res.drain<void>();
|
||||
_fail(tr('받기'), res);
|
||||
}
|
||||
return res;
|
||||
return idleRead(res);
|
||||
}
|
||||
|
||||
/// 올리기 ([length] 를 알면 Content-Length 로)
|
||||
|
|
@ -290,8 +297,11 @@ class DavClient implements RemoteClient {
|
|||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final req = await _open('PUT', uriOf(rel));
|
||||
if (length != null) req.contentLength = length;
|
||||
await req.addStream(data);
|
||||
final res = await req.close();
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하거나 다 보낸 뒤 답이 없으면 실패 (연결을 끊음)
|
||||
final res = await idleWrite(data, (x) async {
|
||||
await req.addStream(x);
|
||||
return req.close();
|
||||
}, onTimeout: req.abort);
|
||||
await res.drain<void>();
|
||||
if (res.statusCode >= 300) _fail(tr('올리기'), res);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import '../../services/storage_service.dart';
|
|||
import '../../l10n/tr.dart';
|
||||
|
||||
class DesktopStorageService implements StorageService {
|
||||
/// [tempDirectory] 의 기준 폴더 (null = 시스템 임시 폴더). 시험이 파일마다 따로 쓰고 끝에 지우게 한다
|
||||
/// (test/flutter_test_config.dart - 예전에는 시험이 %TEMP%\jj_mkvmaker 를 남겼다, 관리자 지시 10/20)
|
||||
static String? tempRoot;
|
||||
|
||||
@override
|
||||
Future<List<String>> pickVideos() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
|
|
@ -108,7 +112,7 @@ class DesktopStorageService implements StorageService {
|
|||
|
||||
@override
|
||||
Future<String> tempDirectory() async {
|
||||
final dir = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker'));
|
||||
final dir = Directory(p.join(tempRoot ?? Directory.systemTemp.path, 'jj_mkvmaker'));
|
||||
await dir.create(recursive: true);
|
||||
return dir.path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,19 @@ class GitHubUpdater implements Updater {
|
|||
..userAgent = 'JJ_MKVMaker-updater'
|
||||
..connectionTimeout = const Duration(seconds: 20);
|
||||
|
||||
/// 받은 업데이트 · 교체 스크립트 · 기록을 둘 곳 (null = 시스템 임시 폴더). 시험은 시험 폴더를 준다
|
||||
/// (예전에는 시험이 %TEMP% 에 jj_mkvmaker_update_1.9.0 · .ps1 · .log 를 남겼다, 관리자 지시 10/20)
|
||||
final String? tempRoot;
|
||||
|
||||
GitHubUpdater({
|
||||
this.apiBase = 'https://api.github.com',
|
||||
String? appDir,
|
||||
this.versionOverride,
|
||||
this.tempRoot,
|
||||
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
|
||||
|
||||
String get _temp => tempRoot ?? Directory.systemTemp.path;
|
||||
|
||||
@override
|
||||
bool get installsInPlace => false;
|
||||
|
||||
|
|
@ -81,7 +88,7 @@ class GitHubUpdater implements Updater {
|
|||
Future<String> download(ReleaseInfo r, void Function(double progress) onProgress) async {
|
||||
final url = r.zipUrl;
|
||||
if (url == null) throw UpdateException(tr('이 버전에는 Windows 용 zip 이 없습니다.'));
|
||||
final work = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update_${r.version}'));
|
||||
final work = Directory(p.join(_temp, 'jj_mkvmaker_update_${r.version}'));
|
||||
if (await work.exists()) await work.delete(recursive: true);
|
||||
await work.create(recursive: true);
|
||||
final zip = File(p.join(work.path, r.zipName ?? 'update.zip'));
|
||||
|
|
@ -134,7 +141,7 @@ param([int]$ParentPid, [string]$Source, [string]$Target, [string]$Exe, [switch]$
|
|||
$ErrorActionPreference = 'Continue'
|
||||
try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {}
|
||||
Start-Sleep -Milliseconds 800
|
||||
$log = Join-Path $env:TEMP 'jj_mkvmaker_update.log'
|
||||
$log = Join-Path (Split-Path -Parent $PSCommandPath) 'jj_mkvmaker_update.log'
|
||||
# keep downloads, models and user data
|
||||
$lib = Join-Path $Source 'Lib'
|
||||
if (Test-Path $lib) {
|
||||
|
|
@ -154,7 +161,7 @@ exit 0
|
|||
|
||||
/// 스크립트 실행. [waitPid] 가 끝나기를 기다렸다가 교체한다.
|
||||
Future<Process> runInstallScript(String source, {required int waitPid, bool restart = true}) async {
|
||||
final ps1 = File(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update.ps1'));
|
||||
final ps1 = File(p.join(_temp, 'jj_mkvmaker_update.ps1'));
|
||||
await ps1.writeAsString(script);
|
||||
return Process.start(
|
||||
'powershell.exe',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class DownloadTask {
|
|||
final Map<String, Object?> extra = {};
|
||||
|
||||
DownloadTask({required this.id, required this.kind, required this.source, required this.dir})
|
||||
: title = source;
|
||||
: title = magnetName(source) ?? source; // 마그넷은 메타데이터 전에도 dn 이름으로 (받으면 토렌트 이름으로 바뀜)
|
||||
|
||||
bool get unfinished =>
|
||||
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'rsync_setup.dart';
|
|||
import 'schedule_editor.dart';
|
||||
import 'theme.dart';
|
||||
import 'setting_tile.dart';
|
||||
import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText;
|
||||
|
||||
/// 환경 설정의 복사 · 동기화 부분.
|
||||
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
|
||||
|
|
@ -75,8 +76,10 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
|
||||
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값).
|
||||
/// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다
|
||||
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) {
|
||||
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set, {bool rsync = false}) {
|
||||
final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘)
|
||||
final bad = rsync ? optionProblems(ctl.text) : const <OptionProblem>[];
|
||||
return SettingTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(hint),
|
||||
|
|
@ -84,11 +87,14 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
width: 380,
|
||||
child: TextFormField(
|
||||
controller: ctl,
|
||||
inputFormatters: const [OptionTextFormatter()],
|
||||
scrollPadding: const EdgeInsets.all(80),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: bad.isEmpty ? null : optionProblemText(bad),
|
||||
errorMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: trf('기본값으로 ({0})', [def]),
|
||||
icon: const Icon(Icons.restart_alt, size: 18),
|
||||
|
|
@ -177,7 +183,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
value: c.settings.copySkipNewer,
|
||||
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
|
||||
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
|
||||
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
|
||||
subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
|
||||
);
|
||||
|
||||
Widget _rsyncPart() {
|
||||
|
|
@ -187,7 +193,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
// 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다
|
||||
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
|
||||
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
|
||||
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v),
|
||||
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true),
|
||||
_skipNewerTile(),
|
||||
SettingTile(
|
||||
title: Text(tr('rsync 가져오기')),
|
||||
|
|
@ -336,7 +342,7 @@ class _LiveSyncTile extends StatelessWidget {
|
|||
value: delete,
|
||||
onChanged: (v) => set(() => delete = v ?? false),
|
||||
title: Text(tr('원본에 없는 것을 대상에서 지우기')),
|
||||
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')),
|
||||
subtitle: Text(keepOptionsTogether(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만'))),
|
||||
),
|
||||
SettingTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
|
|||
|
|
@ -400,7 +400,11 @@ class DownloadProgressBar extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final done = t.state == DownloadState.done;
|
||||
final value = done ? 1.0 : t.progress;
|
||||
// 관리자 지시 (10/20, 28 확인): 멈춘 마그넷 · 토렌트 (줄의 주황 "트래커 · 피어에 닿지 않습니다") 는 막대를 움직이지 않고
|
||||
// 주황으로 "멈춤" - 움직이는 "준비 중…" 이면 받는 중인 것처럼 보였다
|
||||
final stalled = t.state == DownloadState.downloading && t.extra['stalled'] == true;
|
||||
final color = switch (t.state) {
|
||||
_ when stalled => Colors.orangeAccent,
|
||||
DownloadState.done => JjColors.success,
|
||||
DownloadState.failed => JjColors.danger,
|
||||
DownloadState.paused => Colors.amber,
|
||||
|
|
@ -414,7 +418,10 @@ class DownloadProgressBar extends StatelessWidget {
|
|||
? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등)
|
||||
: t.state == DownloadState.queued || t.extra['waiting'] == true
|
||||
? tr('대기 중')
|
||||
: tr('준비 중…'))
|
||||
: stalled
|
||||
// 관리자 지시 (10/20, 28 지적): 영어 "Stopped" 는 일시 정지 · 취소로 읽혀 따로 열쇠 ("Stalled")
|
||||
? tr('멈춤 (시작 안 됨)')
|
||||
: tr('준비 중…'))
|
||||
: '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}';
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
|
@ -423,9 +430,10 @@ class DownloadProgressBar extends StatelessWidget {
|
|||
child: Stack(fit: StackFit.expand, children: [
|
||||
LinearProgressIndicator(
|
||||
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
|
||||
value: value ?? (t.state == DownloadState.downloading ? null : 0),
|
||||
value: value ?? (t.state == DownloadState.downloading && !stalled ? null : 0),
|
||||
minHeight: height,
|
||||
backgroundColor: JjColors.panelHigh,
|
||||
// 멈춘 줄은 빈 막대도 주황 기운으로 (회색 빈 막대면 그냥 시작 전처럼 보임)
|
||||
backgroundColor: stalled ? Colors.orangeAccent.withValues(alpha: 0.28) : JjColors.panelHigh,
|
||||
color: color.withValues(alpha: 0.85),
|
||||
),
|
||||
Center(
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ class _Pane extends ChangeNotifier {
|
|||
final back = <String>[];
|
||||
final forward = <String>[];
|
||||
bool traveling = false;
|
||||
|
||||
/// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews])
|
||||
bool onScreen = false;
|
||||
String? focused;
|
||||
final expanded = <String>{};
|
||||
final cache = <String, List<FileEntry>>{};
|
||||
|
|
@ -1056,19 +1059,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
|
||||
void _reveal(_Pane pane, String path) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !pane.scroll.hasClients) return;
|
||||
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
|
||||
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) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
|
||||
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
|
||||
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
|
||||
});
|
||||
void _reveal(_Pane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
|
||||
|
||||
void _revealNow(_Pane pane, String path) {
|
||||
if (!mounted || !pane.scroll.hasClients) return;
|
||||
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
|
||||
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) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
|
||||
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
|
||||
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
|
||||
}
|
||||
|
||||
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
|
||||
/// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다
|
||||
void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
for (final pane in _panes) {
|
||||
final on = pane.scroll.hasClients;
|
||||
if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current);
|
||||
pane.onScreen = on;
|
||||
}
|
||||
});
|
||||
|
||||
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
|
||||
Future<void> _travel(_Pane pane, {required bool forward}) async {
|
||||
// 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게
|
||||
|
|
@ -3418,6 +3432,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
var filterAck = false;
|
||||
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
|
||||
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다
|
||||
List<OptionProblem> badOptions() {
|
||||
final seen = <String>{};
|
||||
return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))];
|
||||
}
|
||||
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
|
||||
bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
|
||||
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
|
||||
|
|
@ -3459,7 +3478,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
// 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178)
|
||||
if (both) ...[
|
||||
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
|
||||
Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
if (optionMove)
|
||||
Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'),
|
||||
|
|
@ -3474,7 +3493,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
preview = null;
|
||||
previewError = null;
|
||||
}),
|
||||
title: Text(tr('원본 파일 지우기 (--remove-source-files)')),
|
||||
title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))),
|
||||
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
|
||||
),
|
||||
if (removeSource) ...[
|
||||
|
|
@ -3493,7 +3512,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
],
|
||||
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
|
||||
const SizedBox(height: 8),
|
||||
if (filterLimits().isNotEmpty)
|
||||
if (badOptions().isNotEmpty) OptionProblemNote(badOptions()),
|
||||
// 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
|
||||
if (badOptions().isEmpty && filterLimits().isNotEmpty)
|
||||
FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)),
|
||||
SyncPreviewView(
|
||||
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
|
||||
|
|
@ -3552,7 +3573,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0;
|
||||
return FilledButton(
|
||||
style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
|
||||
onPressed: nestedDelete() || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck)
|
||||
onPressed: nestedDelete() || badOptions().isNotEmpty || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck)
|
||||
? null
|
||||
: () => Navigator.pop(ctx, true),
|
||||
// 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨).
|
||||
|
|
@ -4404,6 +4425,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final dual = _dual;
|
||||
final split = _split;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
|
||||
if (_ready) _revealNewViews();
|
||||
final side = !(dual || split) ||
|
||||
switch (s.explorerOrientation) {
|
||||
'side' => true,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import 'path_label.dart';
|
|||
import 'rsync_setup.dart';
|
||||
import 'schedule_editor.dart';
|
||||
import '../core/sync_preview.dart' show optionsRemoveSource;
|
||||
import 'sync_preview_view.dart' show confirmDeletingRun, effectiveRunText;
|
||||
import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, keepOptionsTogether, unbreakableOption;
|
||||
import 'theme.dart';
|
||||
|
||||
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
|
||||
|
|
@ -80,40 +80,87 @@ class MonitorPage extends StatelessWidget {
|
|||
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
|
||||
if (list.isEmpty) return;
|
||||
final pick = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(trf('대상에서 {0}개를 지울까요?', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
height: 360,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
|
||||
[vDisplay(x.source), vDisplay(x.target)])),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(children: [
|
||||
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(trf('{0}개 지우고 맞추기', [list.length])),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
|
||||
if (pick == null) return;
|
||||
await live.decideDelete(x, delete: pick);
|
||||
}
|
||||
|
||||
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
|
||||
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
|
||||
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final (list, excl) = await live.previewDeletes(x);
|
||||
if (!context.mounted) return;
|
||||
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
|
||||
if (pick == true) await live.deleteNow(x);
|
||||
}
|
||||
|
||||
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
|
||||
{required bool now}) {
|
||||
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
|
||||
final excl = exclIn.toSet();
|
||||
final plain = [for (final n in list) if (!excl.contains(n)) n];
|
||||
final filtered = [for (final n in list) if (excl.contains(n)) n];
|
||||
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
|
||||
final limits = live.unsupportedOf(x);
|
||||
var ack = false;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
|
||||
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (limits.isNotEmpty) ...[
|
||||
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
|
||||
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
|
||||
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
|
||||
[vDisplay(x.source), vDisplay(x.target)])),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(children: [
|
||||
if (list.isEmpty)
|
||||
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
if (plain.isNotEmpty) const SizedBox(height: 10),
|
||||
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
|
||||
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 4),
|
||||
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
|
||||
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
|
||||
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
||||
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
|
||||
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
|
||||
Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const <String>[];
|
||||
|
|
@ -600,6 +647,7 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
width: 320,
|
||||
child: TextField(
|
||||
controller: _options,
|
||||
inputFormatters: const [OptionTextFormatter()],
|
||||
enabled: _method != 'builtin',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
||||
|
|
@ -614,6 +662,8 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
|
||||
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
|
||||
),
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
|
||||
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
|
||||
if (!t.move && optionsRemoveSource(_options.text))
|
||||
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
|
|
@ -648,7 +698,8 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () => _runPressed(t),
|
||||
// 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다
|
||||
onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
|
||||
),
|
||||
|
|
@ -922,6 +973,18 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
final active = LiveSync.activeNow(x);
|
||||
final running = live?.isRunning(x) ?? false;
|
||||
final held = live?.isPaused(x) ?? false;
|
||||
// 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면
|
||||
// "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆)
|
||||
final narrow = MediaQuery.sizeOf(context).width < 600;
|
||||
final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ');
|
||||
final chip = Tooltip(
|
||||
message: chipText,
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
|
||||
label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
);
|
||||
return Card(
|
||||
color: JjColors.panel,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
|
|
@ -959,17 +1022,7 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
),
|
||||
),
|
||||
// 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스)
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '),
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
|
||||
label: Text([tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!narrow) Flexible(child: chip),
|
||||
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
|
||||
if (live != null && x.enabled)
|
||||
IconButton(
|
||||
|
|
@ -979,6 +1032,7 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
),
|
||||
Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))),
|
||||
]),
|
||||
if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip),
|
||||
if (held)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 2),
|
||||
|
|
@ -999,6 +1053,21 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
),
|
||||
]),
|
||||
),
|
||||
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
|
||||
if (live != null && live.noAutoDelete[k] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
const Icon(Icons.block, size: 16, color: Colors.redAccent),
|
||||
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||||
OutlinedButton(
|
||||
onPressed: () => confirmLiveDeleteNow(context, live, x),
|
||||
child: Text(tr('지금 지우기 (확인)')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import 'toast_status.dart';
|
|||
import 'license_texts.dart';
|
||||
import 'subtitle_language_picker.dart';
|
||||
import 'hidden_items_dialog.dart';
|
||||
import '../core/remote_idle.dart';
|
||||
|
||||
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
|
||||
class CountSelector extends StatelessWidget {
|
||||
|
|
@ -926,6 +927,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
FtpSettings(c: c),
|
||||
// 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다)
|
||||
if (Platform.isAndroid) SmbSettings(c: c),
|
||||
// 관리자 판단 (10/20): 원격 서버가 멈췄을 때 기다리는 시간
|
||||
SettingTile(
|
||||
leading: const Icon(Icons.hourglass_bottom_outlined),
|
||||
title: Text(tr('원격 서버 응답 기다리기')),
|
||||
subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds,
|
||||
items: [
|
||||
for (final n in RemoteIdle.choices)
|
||||
DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.remoteIdleSeconds = v!),
|
||||
),
|
||||
),
|
||||
ViewerSettings(c: c),
|
||||
]),
|
||||
// Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../app/settings.dart' show CopyTask;
|
||||
import '../core/file_ops.dart' show formatSize, isPartialFile;
|
||||
|
|
@ -125,7 +126,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
|
|||
IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run),
|
||||
]),
|
||||
if (dels > 0)
|
||||
Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]),
|
||||
Text(keepOptionsTogether(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels])),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로
|
||||
if (partials > 0)
|
||||
|
|
@ -227,6 +228,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
|
||||
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
|
||||
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
|
||||
final bad = t.method == 'rsync' ? optionProblems(t.options) : const <OptionProblem>[];
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -255,7 +257,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
|
||||
if (filter.unsupported.isNotEmpty)
|
||||
if (bad.isNotEmpty) OptionProblemNote(bad),
|
||||
// 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
|
||||
if (bad.isEmpty && filter.unsupported.isNotEmpty)
|
||||
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
|
||||
SyncPreviewView(
|
||||
compute: () async => [
|
||||
|
|
@ -294,7 +298,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(
|
||||
style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
|
||||
onPressed: preview == null || (deletes && filter.unsupported.isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true),
|
||||
onPressed: preview == null || bad.isNotEmpty || (deletes && filter.unsupported.isNotEmpty && !filterAck)
|
||||
? null
|
||||
: () => Navigator.pop(ctx, true),
|
||||
child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게)
|
||||
),
|
||||
],
|
||||
|
|
@ -307,6 +313,59 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
|
||||
/// 15 검토 F2 (177): rsync 옵션에 미리 볼 수 없는 거르기 규칙 (고치개 · H · P · merge · -F · -C · 읽지 못한 파일 등) 이 있으면
|
||||
/// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게)
|
||||
/// 옵션 글 (예: --filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리지 않게 줄바꿈 없이 묶는다 (관리자 지시 10/20, 28 지적):
|
||||
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
|
||||
String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060');
|
||||
|
||||
/// 28 확인 (10/20): 글 안의 옵션 이름만 묶는다 - 괄호째 ("(--remove-source-files)" · "(--delete, 되돌릴 수 없음)") 와 따로 쓴
|
||||
/// "--delete-excluded" · "-u" · "/PURGE". 예전에는 en 412 에서 "(--" 에서 갈렸다. 나머지 글은 그대로 줄바꿈
|
||||
String keepOptionsTogether(String s) => s
|
||||
.replaceAllMapped(RegExp(r'\([-/][^()]*\)'), (m) => unbreakableOption(m[0]!))
|
||||
.replaceAllMapped(RegExp(r'(?<![\w\-\u2060/])(?:--?[A-Za-z][\w-]*(?:=\S*)?|/[A-Z]{2,}\b)'), (m) => unbreakableOption(m[0]!));
|
||||
|
||||
/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20).
|
||||
/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게
|
||||
class OptionTextFormatter extends TextInputFormatter {
|
||||
const OptionTextFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final text = cleanOptionText(newValue.text);
|
||||
if (text == newValue.text) return newValue;
|
||||
// 커서 · 고른 곳은 앞부분을 고친 길이로 옮긴다
|
||||
int at(int i) => i < 0 ? i : cleanOptionText(newValue.text.substring(0, i.clamp(0, newValue.text.length))).length;
|
||||
return TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection(baseOffset: at(newValue.selection.baseOffset), extentOffset: at(newValue.selection.extentOffset)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
|
||||
/// 알아볼 수 없는 옵션 안내 글 (확인 창 · 설정 칸): 옵션 낱말은 줄바꿈 없이 묶고 (28 지적: "--" 와 "exclude" 로 갈림),
|
||||
/// 고칠 방법의 - · -- 뒤에서도 줄을 바꾸지 않는다 (나머지 글은 보통처럼 줄을 바꾼다)
|
||||
String optionProblemText(List<OptionProblem> bad) {
|
||||
final wj = String.fromCharCode(0x2060);
|
||||
return trf('옵션을 알아볼 수 없습니다: {0} - {1}', [
|
||||
bad.map((x) => x.token.isEmpty ? x.text : x.text.replaceFirst(x.token, unbreakableOption(x.token))).join(' · '),
|
||||
optionProblemHint(bad).replaceAll('-', '-$wj'),
|
||||
]);
|
||||
}
|
||||
|
||||
class OptionProblemNote extends StatelessWidget {
|
||||
final List<OptionProblem> bad;
|
||||
const OptionProblemNote(this.bad, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
// 28 지적 (10/20): 끝 문구는 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로)
|
||||
optionProblemText(bad),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class FilterLimitNote extends StatelessWidget {
|
||||
final List<String> unsupported;
|
||||
final bool deletes;
|
||||
|
|
@ -316,7 +375,7 @@ class FilterLimitNote extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(trf('이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}', [unsupported.join(' · ')]),
|
||||
Text(trf('이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}', [unsupported.map(unbreakableOption).join(' · ')]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
if (deletes)
|
||||
CheckboxListTile(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2026.10.18+1
|
||||
version: 2026.10.20+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.4
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ void main() {
|
|||
..['stallReasons'] = ['ipv6only', 'firewall', 'dns'];
|
||||
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
|
||||
await tester.pump();
|
||||
expect(find.textContaining(RegExp(r'트래커 · 피어에 닿지 않습니다 \(6[1-3]초째\)')), findsOneWidget);
|
||||
// 61초 전부터 멈춤 - 시험이 느리면 (전체 시험 부하) 몇 초 더 지난다
|
||||
expect(find.textContaining(RegExp(r'트래커 · 피어에 닿지 않습니다 \((6[1-9]|[7-9]\d)초째\)')), findsOneWidget);
|
||||
expect(find.textContaining('인터넷 IPv4 가 없음 (IPv6 만) · 방화벽 · 공유기가 토렌트를 막음 · DNS 강제'), findsOneWidget);
|
||||
await tester.tap(find.text('다시 시도'));
|
||||
await tester.pump();
|
||||
|
|
|
|||
|
|
@ -143,4 +143,35 @@ void main() {
|
|||
expect(Cleaner.sizeText(5 * 1024 * 1024), '5.0 MB');
|
||||
expect(Cleaner.sizeText(3 * 1024 * 1024 * 1024), '3.0 GB');
|
||||
});
|
||||
test('관리자 지시 (10/20): 앱이 켜질 때 지난 업데이트가 남긴 것 (풀어 둔 새 판 · .ps1 · .log) 만 지운다 - 막 생긴 것 · 다른 jj 이름은 남김', () {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_upd_left_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final old = DateTime.now().subtract(const Duration(hours: 1));
|
||||
void make(String rel, {bool fresh = false}) {
|
||||
final f = File(p.join(tmp.path, rel))..createSync(recursive: true);
|
||||
if (!fresh) f.setLastModifiedSync(old);
|
||||
}
|
||||
|
||||
make(p.join('jj_mkvmaker_update_2026.10.18_979', 'extract', 'jj_mkvmaker.exe'));
|
||||
make('jj_mkvmaker_update.ps1');
|
||||
make('jj_mkvmaker_update.log', fresh: true); // 교체 스크립트가 막 쓴 것
|
||||
// 앱 자신이 업데이트 때 만든 이름만 지운다 - 다른 jj 이름 (앱 임시 폴더 · 도구 받기 · Chrome 엔진 · 비슷한 이름) 은 건드리지 않음
|
||||
final others = ['jj_mkvmaker_updater.txt', p.join('jj_mkvmaker', 'a.wav'), 'jj_ffmpeg.download', p.join('jj_cef_1', 'x'), p.join('jj_tool_1', 'x'),
|
||||
p.join('jj_mkvmaker_update', 'x'), 'jj_mkvmaker_update.txt', p.join('jj_other_update_1', 'x')];
|
||||
for (final o in others) {
|
||||
make(o);
|
||||
}
|
||||
// 폴더는 안에 파일을 만들어 바뀐 때가 지금이다 - 5분 뒤로 보고 지우는지 본다
|
||||
final dir = Directory(p.join(tmp.path, 'jj_mkvmaker_update_2026.10.18_979'));
|
||||
final gone = removeUpdateLeftovers(tmp.path, now: DateTime.now().add(const Duration(minutes: 5)));
|
||||
expect(gone..sort(), ['jj_mkvmaker_update.log', 'jj_mkvmaker_update.ps1', 'jj_mkvmaker_update_2026.10.18_979'],
|
||||
reason: '5분 뒤로 보면 막 생긴 기록도 2분이 지남');
|
||||
expect(dir.existsSync(), isFalse);
|
||||
for (final o in others) {
|
||||
expect(File(p.join(tmp.path, o)).existsSync(), isTrue, reason: '$o 는 남김');
|
||||
}
|
||||
|
||||
make('jj_mkvmaker_update.log', fresh: true);
|
||||
expect(removeUpdateLeftovers(tmp.path), isEmpty, reason: '2분이 안 된 것은 남김 (스크립트가 끝나는 중일 수 있음)');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,26 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 - "미리 볼 수 없습니다" · [알고 실행합니다] 는 숨김', (tester) async {
|
||||
final src = p.join(tmp.path, 'bs'), dst = p.join(tmp.path, 'bd');
|
||||
File(p.join(src, 'a.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('a');
|
||||
Directory(dst).createSync(recursive: true);
|
||||
bool? r;
|
||||
// 미리 볼 수 없는 규칙 (merge) + 알아볼 수 없는 낱말 (delete) - 실행은 막힘
|
||||
final t = CopyTask(id: '9', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt" delete');
|
||||
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
|
||||
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
|
||||
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
|
||||
expect(find.textContaining('미리 볼 수 없습니다'), findsNothing);
|
||||
expect(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'), findsNothing);
|
||||
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
|
||||
await tester.tap(find.text('취소').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(r, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('177 · 15 검토 F2: 미리 볼 수 없는 거르기 규칙 + --delete 면 빨간 경고 · 체크해야 [실행] 이 켜진다', (tester) async {
|
||||
final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd');
|
||||
File(p.join(src, 'a.txt'))
|
||||
|
|
@ -228,11 +248,12 @@ void main() {
|
|||
..createSync(recursive: true)
|
||||
..writeAsStringSync('o');
|
||||
bool? r;
|
||||
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="H a.txt"');
|
||||
// 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로
|
||||
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt"'); // 목록 파일은 전체 경로 (관리자 판단 10/20)
|
||||
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
|
||||
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
|
||||
expect(find.textContaining('미리 볼 수 없습니다'), findsOneWidget);
|
||||
expect(find.textContaining('H a.txt'), findsWidgets);
|
||||
expect(find.textContaining('merge C:/jj_rules/rules.txt'), findsWidgets);
|
||||
FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행'));
|
||||
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
|
||||
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));
|
||||
|
|
@ -364,7 +385,7 @@ void main() {
|
|||
expect(find.text('rsync'), findsWidgets);
|
||||
// 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐"
|
||||
expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget);
|
||||
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget);
|
||||
expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ class _Backend implements DownloadBackend {
|
|||
Future<void> shutdown() async {}
|
||||
}
|
||||
|
||||
/// 이름을 바꾸지 않는 백엔드 (마그넷 메타데이터를 아직 받지 못한 상태)
|
||||
class _Quiet extends _Backend {
|
||||
_Quiet(super.kind);
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
t
|
||||
..state = DownloadState.downloading
|
||||
..progress = null;
|
||||
changed();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
DownloadManager make() => DownloadManager(
|
||||
backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)],
|
||||
|
|
@ -178,4 +190,61 @@ void main() {
|
|||
expect(find.textContaining('대기 중'), findsWidgets);
|
||||
expect(find.textContaining('받는 중'), findsNothing);
|
||||
});
|
||||
|
||||
test('관리자 지시 (10/20): 마그넷 이름 (dn) - 주소 글 풀기 · + 는 빈칸 · 없거나 마그넷이 아니면 null', () {
|
||||
const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
|
||||
expect(magnetName('$h&dn=Ubuntu+24.04%20Desktop&tr=udp%3A%2F%2Ft.example%3A80'), 'Ubuntu 24.04 Desktop');
|
||||
expect(magnetName('$h&DN=%ED%95%9C%EA%B8%80%20%EC%9D%B4%EB%A6%84'), '한글 이름');
|
||||
expect(magnetName('magnet:?dn=first&xt=urn:btih:00&dn=second'), 'first', reason: '처음 것');
|
||||
expect(magnetName('$h&dn=bad%zz'), 'bad%zz', reason: '잘못 적힌 % 는 그대로');
|
||||
expect(magnetName('$h&dn=a%0Ab'), 'a b', reason: '줄바꿈은 빈칸 (한 줄 제목)');
|
||||
expect(magnetName('$h&dn='), isNull);
|
||||
expect(magnetName('$h&dn=%20+'), isNull);
|
||||
expect(magnetName(h), isNull);
|
||||
expect(magnetName('https://example.com/?dn=x'), isNull);
|
||||
final t = DownloadTask(id: '1', kind: DownloadKind.torrent, source: '$h&dn=Big+Buck+Bunny', dir: r'D:\dl');
|
||||
expect(t.title, 'Big Buck Bunny');
|
||||
expect(DownloadTask(id: '2', kind: DownloadKind.torrent, source: h, dir: r'D:\dl').title, h, reason: '이름이 없으면 주소 그대로');
|
||||
});
|
||||
|
||||
testWidgets('관리자 지시 (10/20): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈추면 막대가 움직이지 않고 주황 "멈춤"', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final d = DownloadManager(
|
||||
backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)],
|
||||
settings: () => AppSettings()..downloadRoot = r'D:\dl',
|
||||
readClipboard: () async => null,
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
|
||||
const url = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Ft.example%3A80';
|
||||
d.add(url);
|
||||
await tester.pump();
|
||||
expect(d.tasks.single.state, DownloadState.downloading);
|
||||
expect(find.text('Big Buck Bunny'), findsOneWidget, reason: '마그넷 주소 전체가 아니라 이름');
|
||||
expect(find.textContaining('urn:btih'), findsNothing);
|
||||
// 메타데이터를 받는 중: 움직이는 "준비 중…"
|
||||
LinearProgressIndicator bar() => tester.widget(find.descendant(of: find.byType(DownloadProgressBar), matching: find.byType(LinearProgressIndicator)));
|
||||
expect(find.text('준비 중…'), findsOneWidget);
|
||||
expect(bar().value, isNull);
|
||||
// 정한 시간 동안 트래커 · 피어가 없음 → 멈춤
|
||||
d.tasks.single.extra
|
||||
..['stalled'] = true
|
||||
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 61000
|
||||
..['stallReasons'] = ['firewall'];
|
||||
d.selectNone();
|
||||
await tester.pump();
|
||||
expect(find.textContaining('트래커 · 피어에 닿지 않습니다'), findsOneWidget);
|
||||
expect(find.text('멈춤 (시작 안 됨)'), findsOneWidget);
|
||||
expect(find.text('준비 중…'), findsNothing);
|
||||
expect(bar().value, 0, reason: '움직이지 않는 막대');
|
||||
expect(bar().color, Colors.orangeAccent.withValues(alpha: 0.85));
|
||||
await tester.pumpAndSettle(); // 움직이는 막대가 있으면 끝나지 않는다
|
||||
// 다시 받기 시작하면 (멈춤 풀림) 원래대로
|
||||
d.tasks.single.extra.remove('stalled');
|
||||
d.selectNone();
|
||||
await tester.pump();
|
||||
expect(find.text('준비 중…'), findsOneWidget);
|
||||
expect(bar().value, isNull);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,47 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭 · 넓게 바꾼 두 창도 그 창의 지금 폴더가 보인다 (저장 장치 맨 위에 머물지 않음)', (tester) async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsync_reveal_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳 - 펼쳐도 스크롤하지 않으면 화면 밖
|
||||
for (var i = 0; i < 80; i++) {
|
||||
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
|
||||
}
|
||||
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
|
||||
Directory(p.join(l, 'sub')).createSync(recursive: true);
|
||||
Directory(p.join(r, 'rsub')).createSync(recursive: true);
|
||||
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
|
||||
addTearDown(() => ExplorerPage.debugVolumes = null);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..explorerOrientation = 'side'
|
||||
..rsyncPaths = [l, r];
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
|
||||
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty);
|
||||
expect(find.text('sub').hitTestable(), findsOneWidget, reason: '왼쪽은 처음부터 지금 폴더');
|
||||
// [오른쪽] 탭을 처음 보면 그 창의 지금 폴더 (R) 로 - 예전에는 맨 위 (a00 …) 에서 시작
|
||||
await tester.tap(find.text('오른쪽 · R'));
|
||||
await settle(tester, () => find.text('rsub').hitTestable().evaluate().isNotEmpty, rounds: 40);
|
||||
expect(find.text('rsub').hitTestable(), findsOneWidget);
|
||||
// 넓게 바꾸면 두 창 - 둘 다 지금 폴더가 보임
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty && find.text('rsub').hitTestable().evaluate().isNotEmpty,
|
||||
rounds: 40);
|
||||
expect(find.text('sub').hitTestable(), findsOneWidget);
|
||||
expect(find.text('rsub').hitTestable(), findsOneWidget);
|
||||
// 다시 좁게 → [왼쪽] 탭 (지금 보이는 창) 도 지금 폴더
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
await settle(tester, () => find.text('왼쪽 · L').evaluate().isNotEmpty);
|
||||
await tester.tap(find.text('왼쪽 · L'));
|
||||
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty, rounds: 40);
|
||||
expect(find.text('sub').hitTestable(), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
import 'support/fake_recycle_bin.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest;
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||
|
||||
/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게)
|
||||
class _Shell extends NoopShell {
|
||||
|
|
@ -1164,15 +1165,16 @@ void main() {
|
|||
await tester.tap(find.text('취소'));
|
||||
await calm(tester);
|
||||
expect(c.settings.copyTasks, isEmpty);
|
||||
expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음
|
||||
expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음
|
||||
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
|
||||
await act(tester, () => tester.tap(find.text('좌 → 우')));
|
||||
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
|
||||
expect(find.text('$ls/ → $rs/'), findsOneWidget);
|
||||
expect(find.text('$rs/ → $ls/'), findsNothing);
|
||||
expect(find.textContaining('-u'), findsNothing);
|
||||
// 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때)
|
||||
expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing);
|
||||
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing);
|
||||
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
|
||||
await calm(tester);
|
||||
expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget);
|
||||
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget);
|
||||
|
|
@ -1428,6 +1430,8 @@ void main() {
|
|||
..explorerDropAction = 'move'
|
||||
..copyConflict = 'overwrite';
|
||||
await open(tester);
|
||||
// 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지
|
||||
await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty);
|
||||
await act(tester, () => tester.tap(find.text('선택').first));
|
||||
await act(tester, () => tester.tap(markOf('doc.txt')));
|
||||
await act(tester, () => tester.tap(markOf('e.txt')));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
|
||||
/// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config).
|
||||
/// 관리자 지시 (10/20): 앱 임시 폴더 ([DesktopStorageService.tempDirectory] = %TEMP%\jj_mkvmaker) 를 시험이 남겼다.
|
||||
/// 시험 파일들은 나란히 돌아 같은 폴더를 끝에 지우면 서로 부딪치므로, 파일마다 따로 jj_store_… 를 주고 그 파일이 끝나면 지운다
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
final root = Directory.systemTemp.createTempSync('jj_store_');
|
||||
DesktopStorageService.tempRoot = root.path;
|
||||
tearDownAll(() {
|
||||
try {
|
||||
root.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
});
|
||||
await testMain();
|
||||
}
|
||||
|
|
@ -14,9 +14,17 @@ import 'package:jj_mkvmaker/core/file_ops.dart';
|
|||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'support/rsync_scenes.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
|
||||
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';
|
||||
|
||||
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
|
||||
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
|
||||
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
|
||||
const ftpPw = 'ftp-Pw-7Rk2xQ9t';
|
||||
|
||||
void main() {
|
||||
final hasPy = File(sftpTestPython).existsSync();
|
||||
|
||||
|
|
@ -71,7 +79,8 @@ void main() {
|
|||
late String caFp;
|
||||
|
||||
Future<void> start(String mode, {bool chain = false}) async {
|
||||
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, if (chain) '--chain']);
|
||||
final pr = await Process.start(
|
||||
sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, '--password', ftpPw, if (chain) '--chain']);
|
||||
proc = pr;
|
||||
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||
pr.stderr.drain<void>();
|
||||
|
|
@ -106,7 +115,7 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
FtpServer srv({String security = 'explicit', String cert = '', String password = 'pw'}) =>
|
||||
FtpServer srv({String security = 'explicit', String cert = '', String password = ftpPw}) =>
|
||||
FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
|
||||
|
||||
Future<Object?> err(Future<void> Function() f) async {
|
||||
|
|
@ -236,19 +245,61 @@ void main() {
|
|||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('관리자 지시 (10/20): FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
|
||||
final rsync = bundledRsync;
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
await start('plain');
|
||||
FtpRegistry.configure([srv(security: 'none')]);
|
||||
final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene');
|
||||
final bad = <String>[];
|
||||
// 관리자 지시 (10/20): 가끔 실패해 원인을 보려고, 실패하면 옵션 · 걸린 시간 · 포트 · 예외 · 다른 점과
|
||||
// 보내는 쪽 · 맞추기 전 앱 쪽 · rsync 쪽 · 앱 쪽 목록 (파일은 크기 · 바뀐 시각 ms) 을 한 번에 보인다
|
||||
String listing(String dir) {
|
||||
final root = Directory(dir);
|
||||
if (!root.existsSync()) return ' (없음)';
|
||||
final rows = <String>[];
|
||||
for (final e in root.listSync(recursive: true)) {
|
||||
final rel = p.relative(e.path, from: dir).replaceAll(r'\', '/');
|
||||
if (e is Directory) {
|
||||
rows.add('$rel/');
|
||||
} else {
|
||||
final s = e.statSync();
|
||||
rows.add('$rel ${s.size}B ${s.modified.toIso8601String()}');
|
||||
}
|
||||
}
|
||||
return (rows..sort()).map((r) => ' $r').join('\n');
|
||||
}
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
for (final opts in sceneOptions) {
|
||||
final t0 = sw.elapsedMilliseconds;
|
||||
writeScene(src, want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
final wantList = listing(want);
|
||||
writeScene(src, got);
|
||||
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
||||
final beforeList = listing(got);
|
||||
Object? error;
|
||||
try {
|
||||
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
||||
} catch (e, st) {
|
||||
error = '$e\n$st';
|
||||
}
|
||||
final d = treeDiff(w, treeOf(got));
|
||||
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
|
||||
if (d.isNotEmpty || error != null) {
|
||||
bad.add([
|
||||
'옵션: $opts (${sw.elapsedMilliseconds - t0}ms, 시험 시작부터 ${t0}ms, 포트 $port, ${DateTime.now().toIso8601String()})',
|
||||
if (error != null) ' 예외: $error',
|
||||
' 다른 점:',
|
||||
...d.map((x) => ' $x'),
|
||||
' 보내는 쪽:',
|
||||
listing(src),
|
||||
' 맞추기 전 앱 쪽:',
|
||||
beforeList,
|
||||
' rsync 쪽:',
|
||||
wantList,
|
||||
' 앱 쪽:',
|
||||
listing(got),
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
|
||||
|
|
@ -263,6 +314,48 @@ void main() {
|
|||
expect(await range(7, null), '789');
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('관리자 지시 (10/20): FTP 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
|
||||
await start('plain');
|
||||
FtpRegistry.configure([srv(security: 'none')]);
|
||||
Directory(p.join(root.path, '영상')).createSync();
|
||||
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
|
||||
File(p.join(root.path, '영상', '2화.mkv')).writeAsStringSync('abcdefghij');
|
||||
File(p.join(root.path, '영상', '2화.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n둘째\n');
|
||||
File(p.join(root.path, '영상', '메모.txt')).writeAsStringSync('t');
|
||||
addTearDown(RemoteStreamBridge.close);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.playlistMode = PlaylistMode.folder;
|
||||
final plan = await c.preparePlayback(['ftp://f1/영상/1화.mkv']);
|
||||
expect(plan!.$1, ['ftp://f1/영상/1화.mkv', 'ftp://f1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');
|
||||
|
||||
final bridge = await RemoteStreamBridge.get();
|
||||
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv');
|
||||
expect(u1, startsWith('http://127.0.0.1:'));
|
||||
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
|
||||
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
|
||||
expect(u1, isNot(contains(ftpPw)), reason: '주소에 비밀번호 없음');
|
||||
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
|
||||
Future<(int, String)> get(String url, [String? range]) async {
|
||||
final http = HttpClient();
|
||||
try {
|
||||
final req = await http.getUrl(Uri.parse(url));
|
||||
if (range != null) req.headers.set('Range', range);
|
||||
final res = await req.close();
|
||||
return (res.statusCode, await res.transform(utf8.decoder).join());
|
||||
} finally {
|
||||
http.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
expect(await get(u1), (200, '0123456789'));
|
||||
expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터');
|
||||
expect(await get(u1, 'bytes=7-'), (206, '789'));
|
||||
expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로');
|
||||
final subs = await c.externalSubtitlesFor('ftp://f1/영상/2화.mkv');
|
||||
expect(subs.map(p.basename), ['2화.ko.srt']);
|
||||
expect(File(subs.single).readAsStringSync(), contains('둘째'));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('FTPS (암시, 990 꼴)', () async {
|
||||
await start('implicit');
|
||||
final c = FtpRemote(srv(security: 'implicit', cert: fp));
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ void main() {
|
|||
expect(on.where((o) => o.startsWith('--del')), ['--delete']);
|
||||
});
|
||||
|
||||
test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () {
|
||||
final s = AppSettings()..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||
final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false);
|
||||
expect(off.where((o) => o.startsWith('--del')), isEmpty);
|
||||
final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
|
||||
expect(on.where((o) => o.startsWith('--del')), ['--delete', '--delete-excluded']);
|
||||
expect(on, contains('--exclude=*.tmp'));
|
||||
});
|
||||
|
||||
test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () {
|
||||
final s = AppSettings()..robocopyOptions = '/MIR /PURGE /R:2';
|
||||
final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart' show splitOptions, toCygwinPath;
|
||||
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/monitor_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/rsync_scenes.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||
|
||||
/// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync,
|
||||
/// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음)
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
test('실시간 동기화 (로컬 rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_livescene_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), got = p.join(tmp.path, 'live');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
// 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로)
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync;
|
||||
final bad = <String>[];
|
||||
for (final opts in sceneOptions) {
|
||||
writeScene(src, want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
writeScene(src, got);
|
||||
final before = treeOf(got);
|
||||
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
|
||||
c.settings.rsyncOptions = opts;
|
||||
final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts));
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
final k = LiveSync.keyOf(pair);
|
||||
final why = <String>[];
|
||||
await live.syncNow(pair);
|
||||
if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}');
|
||||
final asked = live.toDelete[k] ?? const <String>[];
|
||||
for (final g in gone) {
|
||||
if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('rsync 는 지우는데 묻지 않음: $g');
|
||||
}
|
||||
for (final a in asked) {
|
||||
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
|
||||
}
|
||||
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 확인 창에서 따로
|
||||
final excl = live.toDeleteExcluded[k] ?? const <String>[];
|
||||
final wantExcl = {
|
||||
'-a --delete --exclude=*.bak --delete-excluded': {'onlydst'},
|
||||
'-a --delete --exclude=*.log --delete-excluded': {'keep.log', 'old', 'old2'},
|
||||
}[opts] ?? const <String>{};
|
||||
if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl');
|
||||
if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl');
|
||||
if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl');
|
||||
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
|
||||
why.addAll(treeDiff(w, treeOf(got)));
|
||||
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
|
||||
live.dispose();
|
||||
if (why.isNotEmpty) bad.add('$opts\n ${why.join('\n ')}');
|
||||
}
|
||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||
}, timeout: const Timeout(Duration(minutes: 5)));
|
||||
|
||||
testWidgets('관리자 3a 조건 (10/20): 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것은 따로 묶어 빨갛게', (tester) async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_liveexcl_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.txt'), (dst, 'skip.tmp'), (dst, 'cache/x.tmp')]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
}
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
await tester.runAsync(() => live.syncNow(pair));
|
||||
final k = LiveSync.keyOf(pair);
|
||||
expect(live.toDelete[k], unorderedEquals(['only.txt', 'skip.tmp', 'cache']));
|
||||
expect(live.toDeleteExcluded[k], unorderedEquals(['skip.tmp', 'cache']), reason: 'cache 는 거른 것이 든 폴더');
|
||||
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
|
||||
ctx = x;
|
||||
return const SizedBox();
|
||||
})));
|
||||
unawaited(confirmLiveDeletes(ctx, live, pair));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget);
|
||||
expect(find.text(keepOptionsTogether('거른 것 2개도 지워집니다 (--delete-excluded)')), findsOneWidget);
|
||||
// 순서: 그냥 지울 것 → 묶음 제목 → 거른 것
|
||||
final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(keepOptionsTogether(n))).dy};
|
||||
expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue);
|
||||
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue);
|
||||
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue);
|
||||
await tester.tap(find.text('나중에'));
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
test('관리자 3a 판단 (10/20): 확인한 쌍도 rsync 옵션이 바뀌어 지울 것이 늘면 다시 묻고, 늘지 않으면 묻지 않고 기억', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_reask_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.tmp'), (dst, 'keep/x.tmp')]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
}
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-av --exclude=*.tmp';
|
||||
// 이미 이 옵션으로 확인한 쌍
|
||||
c.settings.liveSyncPairs = [
|
||||
LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
|
||||
];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
LiveSyncPair pair() => c.settings.liveSyncPairs.single;
|
||||
final k = LiveSync.keyOf(pair());
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], isNull);
|
||||
expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue);
|
||||
|
||||
// ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억
|
||||
c.settings.rsyncOptions = '-avh --exclude=*.tmp';
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], isNull);
|
||||
expect(pair().confirmedOptions, '-avh --exclude=*.tmp');
|
||||
|
||||
// ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음
|
||||
c.settings.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded';
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep']));
|
||||
expect(live.reasked, contains(k));
|
||||
expect(live.status[k]!.$2, contains('확인 필요'));
|
||||
expect(File(p.join(dst, 'only.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
|
||||
expect(pair().deleteConfirmed, isTrue, reason: '쌍의 확인은 그대로 - 바뀐 옵션만 다시 묻는다');
|
||||
// 확인하면 지우고 그 옵션을 기억
|
||||
await live.decideDelete(pair(), delete: true);
|
||||
expect(File(p.join(dst, 'only.tmp')).existsSync(), isFalse);
|
||||
expect(Directory(p.join(dst, 'keep')).existsSync(), isFalse);
|
||||
expect(pair().confirmedOptions, '-avh --exclude=*.tmp --delete-excluded');
|
||||
expect(live.reasked, isNot(contains(k)));
|
||||
|
||||
// ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음
|
||||
c.settings.rsyncOptions = '-avh';
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], isNull);
|
||||
expect(pair().confirmedOptions, '-avh');
|
||||
|
||||
// ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만
|
||||
File(p.join(dst, 'new.tmp')).writeAsStringSync('n');
|
||||
c.settings
|
||||
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'
|
||||
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
|
||||
expect(pair().confirmedOptions, isNull);
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], ['new.tmp']);
|
||||
expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue);
|
||||
c.settings
|
||||
..rsyncOptions = '-av --exclude=*.tmp'
|
||||
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], isNull);
|
||||
expect(pair().confirmedOptions, '-av --exclude=*.tmp');
|
||||
expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue, reason: '거른 것 - 지우지 않음');
|
||||
|
||||
// 설정 파일: 기억한 옵션이 저장 · 읽기에서 남는다
|
||||
final j = pair().toJson();
|
||||
expect(j['confirmedOptions'], '-av --exclude=*.tmp');
|
||||
expect(LiveSyncPair.fromJson(j).confirmedOptions, '-av --exclude=*.tmp');
|
||||
expect(LiveSyncPair.fromJson({...j, 'confirmedOptions': 3}).confirmedOptions, isNull, reason: '틀린 값은 없는 것으로');
|
||||
});
|
||||
|
||||
// P0 (관리자 3a 10/20, 28 재현): 미리 볼 수 없는 거르기 규칙이 있는 지우기 포함 쌍
|
||||
group('P0: 미리 볼 수 없는 거르기 규칙 - 자동은 보내기만 · 지우기는 확인 뒤 한 번', () {
|
||||
late Directory tmp;
|
||||
late String src, dst;
|
||||
late AppController c;
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_p0_');
|
||||
src = p.join(tmp.path, 's');
|
||||
dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel, s) in [
|
||||
(src, 'a.txt', 's:a'),
|
||||
(src, 'b.txt', 's:b'),
|
||||
(src, 'c.log', 's:c'),
|
||||
(dst, 'a.txt', 'd:a'),
|
||||
(dst, 'old.log', 'd:old'),
|
||||
(dst, 'extra.txt', 'd:extra'),
|
||||
]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(s);
|
||||
}
|
||||
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = sceneRsync ?? '';
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
bool there(String rel) => File(p.join(dst, rel)).existsSync();
|
||||
|
||||
// 관리자 지시 (10/20): -,s · H 는 이제 미리 볼 수 있다 (rsync_phsr_test) - 28 의 재현은 아직 따르지 못하는 merge 규칙 (그 파일 안에
|
||||
// "H a.txt" · "-,s a.txt") 으로 한다. 실제 rsync 는 그 규칙을 따라 a.txt 를 지우지만 앱은 merge 를 읽지 않는다
|
||||
for (final (label, rule, form) in [
|
||||
('merge "-,s a.txt"', '-,s a.txt', '-a --filter="merge {f}"'),
|
||||
('merge "H a.txt"', 'H a.txt', '-a --filter="merge {f}"'),
|
||||
('-f"merge" "H a.txt"', 'H a.txt', '-a -f"merge {f}"'),
|
||||
]) {
|
||||
for (final confirmed in [false, true]) {
|
||||
test('28 재현 $label (${confirmed ? '확인한 쌍' : '새 쌍'}): 묻는 목록 밖의 a.txt 가 지워지지 않음', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final rules = File(p.join(tmp.path, 'rules.txt'))..writeAsStringSync('$rule\n');
|
||||
final opts = form.replaceAll('{f}', toCygwinPath(rules.path));
|
||||
// 먼저 실제 rsync 가 이 규칙 + --delete 로 받는 쪽 a.txt 를 지우는 것을 확인 (위험이 실제로 있음)
|
||||
final probe = p.join(tmp.path, 'probe');
|
||||
Directory(probe).createSync();
|
||||
for (final f in Directory(dst).listSync()) {
|
||||
File(f.path).copySync(p.join(probe, p.basename(f.path)));
|
||||
}
|
||||
final r = await Process.run(rsync, ['-8', '--delete', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(probe)}/']);
|
||||
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||
expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움');
|
||||
|
||||
c.settings.rsyncOptions = opts;
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
final k = LiveSync.keyOf(pair);
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await live.syncNow(c.settings.liveSyncPairs.single);
|
||||
expect(live.status[k]!.$2, isNot(contains('실패')));
|
||||
expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)');
|
||||
expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함');
|
||||
expect(live.noAutoDelete[k], isNotEmpty);
|
||||
expect(live.toDelete[k], isNull, reason: '묻지도 않음 (목록이 실제와 다름)');
|
||||
expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음');
|
||||
}
|
||||
// [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크)
|
||||
final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single);
|
||||
expect(list, unorderedEquals(['old.log', 'extra.txt']));
|
||||
await live.deleteNow(c.settings.liveSyncPairs.single);
|
||||
expect([there('old.log'), there('extra.txt')], [false, false]);
|
||||
// 다음 자동 맞추기는 다시 보내기만
|
||||
File(p.join(dst, 'extra2.txt')).writeAsStringSync('x');
|
||||
await live.syncNow(c.settings.liveSyncPairs.single);
|
||||
expect(there('extra2.txt'), isTrue);
|
||||
expect(live.noAutoDelete[k], isNotEmpty);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async {
|
||||
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
c.settings.rsyncOptions = '-a --filter="P old.log"';
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
final k = LiveSync.keyOf(pair);
|
||||
await live.syncNow(pair);
|
||||
expect(live.noAutoDelete[k], isNull);
|
||||
expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴');
|
||||
await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
|
||||
expect((there('old.log'), there('extra.txt')), (true, false));
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await live.syncNow(c.settings.liveSyncPairs.single);
|
||||
expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)');
|
||||
expect(live.pending[k], isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async {
|
||||
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
c.settings.rsyncOptions = '-a --exclude=*.log';
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
await live.syncNow(pair);
|
||||
final k = LiveSync.keyOf(pair);
|
||||
expect(live.noAutoDelete[k], isNull);
|
||||
expect(live.toDelete[k], ['extra.txt']);
|
||||
});
|
||||
|
||||
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
|
||||
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
|
||||
ctx = x;
|
||||
return const SizedBox();
|
||||
})));
|
||||
await tester.runAsync(() async {
|
||||
unawaited(confirmLiveDeleteNow(ctx, live, pair));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.textContaining('미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다'), findsOneWidget);
|
||||
expect(find.text('대상에서 2개를 지울까요?'), findsOneWidget);
|
||||
expect(find.text('지우지 않기 (지우기 끄기)'), findsNothing);
|
||||
FilledButton go() => tester.widget(find.widgetWithText(FilledButton, '2개 지우고 맞추기'));
|
||||
expect(go().onPressed, isNull, reason: '체크 전에는 꺼짐');
|
||||
await tester.tap(find.byType(Checkbox));
|
||||
await tester.pump();
|
||||
expect(go().onPressed, isNotNull);
|
||||
await tester.tap(find.text('나중에'));
|
||||
await tester.pump();
|
||||
expect(there('old.log') && there('extra.txt') && there('a.txt'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -329,6 +329,26 @@ void main() {
|
|||
expect(find.text('복사 · rsync 로 이동'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내) 카드의 [▶ 실행] 도 꺼진다', (tester) async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_monbad_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')];
|
||||
tester.view.physicalSize = const Size(1500, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c)));
|
||||
await tester.pump();
|
||||
FilledButton run() => tester.widget<FilledButton>(find.ancestor(of: find.text('실행'), matching: find.byWidgetPredicate((w) => w is FilledButton)));
|
||||
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
|
||||
expect(run().onPressed, isNull, reason: '막히면 꺼짐');
|
||||
// 옵션을 고치면 다시 켜진다
|
||||
await tester.enterText(find.widgetWithText(TextField, '옵션'), '-av');
|
||||
await tester.pump();
|
||||
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsNothing);
|
||||
expect(run().onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('233: 이동 카드의 저장 창은 "이 이동을" · 229 설정은 Rsync 탭에도 있다', (tester) async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_mon233_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart' show optionsDelete;
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/rsync_scenes.dart';
|
||||
|
||||
/// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 옵션 칸에 붙여 넣은 글 (굽은 따옴표 · 대시 · 보이지 않는 글자) 을
|
||||
/// 곧은 글자로 쓴 것과 똑같이 읽는다. 예전에는 --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고,
|
||||
/// –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. 보이지 않는 글자는 \u 표기로 쓴다
|
||||
void main() {
|
||||
// 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것)
|
||||
const cases = <(String, String, String)>[
|
||||
('줄바꿈 없는 빈칸 (U+00A0)', '-a\u00A0--delete\u00A0--exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('화면 글의 U+2060', '-a --delete --exclude=\u2060*.log', '-a --delete --exclude=*.log'),
|
||||
('U+200B', '-a --delete --excl\u200Bude=*.log', '-a --delete --exclude=*.log'),
|
||||
('줄바꿈 · 탭', '-a\t--delete\n--exclude=*.log\r\n', '-a --delete --exclude=*.log'),
|
||||
('전각 빈칸 (U+3000)', '-a\u3000--delete\u3000--exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('en dash (\u2013delete)', '-a \u2013delete --exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('em dash (\u2014delete)', '-a \u2014delete --exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('굽은 큰따옴표', '-a --delete --filter=\u201C- *.log\u201D', '-a --delete --filter="- *.log"'),
|
||||
('굽은 작은따옴표', '-a --delete --exclude=\u2018*.log\u2019', "-a --delete --exclude='*.log'"),
|
||||
('소프트 하이픈 (U+00AD)', '-a --del\u00ADete --exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('줄바꿈 없는 하이픈 (U+2011)', '-a \u2011\u2011delete --exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('전각 하이픈 (U+FF0D)', '-a \uFF0D\uFF0Ddelete --exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
('BOM 앞', '\uFEFF-a --delete --exclude=*.log', '-a --delete --exclude=*.log'),
|
||||
// 15 확인 (10/20): F2 짧은 옵션 묶음 앞의 대시 하나 · F3 거르기 규칙 머리의 대시 · F5 닫는 따옴표 뒤에 글
|
||||
('F2 \u2013avz', '\u2013avz --delete --exclude=*.log', '-avz --delete --exclude=*.log'),
|
||||
('F2 \u2013rlpt', '\u2013rlpt --delete --exclude=*.log', '-rlpt --delete --exclude=*.log'),
|
||||
('F3 --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter="- *.log"'),
|
||||
('F3 -f\u201C\u2013 *.log\u201D', '-a --delete -f\u201C\u2013 *.log\u201D', '-a --delete -f"- *.log"'),
|
||||
('F3 --filter \u201C\u2013 *.log\u201D (값 따로)', '-a --delete --filter \u201C\u2013 *.log\u201D', '-a --delete --filter "- *.log"'),
|
||||
('F5 \u201C*\u201D.log', '-a --delete --exclude=\u201C*\u201D.log', '-a --delete --exclude="*".log'),
|
||||
];
|
||||
|
||||
group('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () {
|
||||
for (final (name, pasted, straight) in cases) {
|
||||
test(name, () {
|
||||
// 옵션 칸 (OptionTextFormatter) 을 거친 글도, 거치지 않은 글 (예전에 저장된 값) 도
|
||||
final typed = const OptionTextFormatter()
|
||||
.formatEditUpdate(TextEditingValue.empty, TextEditingValue(text: pasted, selection: TextSelection.collapsed(offset: pasted.length)))
|
||||
.text;
|
||||
for (final s in [pasted, typed]) {
|
||||
expect(splitOptions(s), splitOptions(straight));
|
||||
expect(unreadableOptions(s), isEmpty);
|
||||
expect(optionsDelete(s), optionsDelete(straight));
|
||||
final f = RsyncFilter.parse(s), g = RsyncFilter.parse(straight);
|
||||
expect(f.excluded('keep.log', isDir: false), g.excluded('keep.log', isDir: false));
|
||||
expect(f.excluded('a.txt', isDir: false), isFalse);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () {
|
||||
// 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다
|
||||
expect(splitOptions('--exclude=it\u2019s.txt'), ['--exclude=it\u2019s.txt']);
|
||||
expect(splitOptions('--exclude=a\u2013b.txt'), ['--exclude=a\u2013b.txt']);
|
||||
// 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음)
|
||||
expect(splitOptions('--exclude=\u2018it\u2019s a.txt\u2019 -a'), ['--exclude=it\u2019s a.txt', '-a']);
|
||||
// 짧은 옵션 뒤 (-f“- *.log”)
|
||||
expect(splitOptions('-f\u201C- *.log\u201D'), ['-f- *.log']);
|
||||
// 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --bwlimit=100)
|
||||
expect(splitOptions('\u2013av \u2013delete \u2013bwlimit=100'), ['-av', '--delete', '--bwlimit=100']);
|
||||
});
|
||||
|
||||
test('15 확인 F1 (데이터): 닫히지 않은 따옴표는 뒤의 옵션을 삼키지 않고, 알려서 실행을 막는다 (굽은 · 곧은 모두)', () {
|
||||
for (final q in ['\u2018', '\u201C', '"', "'"]) {
|
||||
final o = '-a --delete --exclude=${q}a.tmp --exclude=keep.txt';
|
||||
expect(splitOptions(o), ['-a', '--delete', '--exclude=${q}a.tmp', '--exclude=keep.txt'], reason: q);
|
||||
expect(unreadableOptions(o).single, contains('따옴표가 닫히지 않았습니다'), reason: q);
|
||||
expect(RsyncFilter.parse(o).excluded('keep.txt', isDir: false), isTrue, reason: '뒤의 규칙이 살아 있음');
|
||||
}
|
||||
// 닫힌 것은 문제 없음
|
||||
expect(unreadableOptions('-a --exclude="a b.txt" --exclude=\u2018c d\u2019'), isEmpty);
|
||||
});
|
||||
|
||||
test('15 확인 F4 · F5: 값 자리의 대시는 그대로, 닫는 굽은 따옴표 뒤에 글이 이어져도 닫는다', () {
|
||||
expect(splitOptions('--exclude \u2013x.txt -a'), ['--exclude', '\u2013x.txt', '-a']);
|
||||
expect(splitOptions('-e \u2013x'), ['-e', '\u2013x']);
|
||||
expect(splitOptions('--exclude=\u201Ca b\u201D.txt'), ['--exclude=a b.txt']);
|
||||
expect(splitOptions('--exclude=\u2018a b\u2019.txt'), ['--exclude=a b.txt']);
|
||||
// 긴 옵션 이름 (–no-perms · –dry-run) 은 --, 목록에 없는 것은 짧은 옵션 묶음
|
||||
expect(splitOptions('\u2013no-perms \u2013dry-run \u2013avh'), ['--no-perms', '--dry-run', '-avh']);
|
||||
});
|
||||
|
||||
test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () {
|
||||
expect(unreadableOptions('-a \u00ABdelete\u00BB'), ['경로로 읽힙니다: \u00ABdelete\u00BB']);
|
||||
expect(unreadableOptions('-a \u2026delete'), ['경로로 읽힙니다: \u2026delete']);
|
||||
expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값');
|
||||
expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값');
|
||||
expect(unreadableOptions('-a 한글'), ['경로로 읽힙니다: 한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다');
|
||||
expect(unreadableOptions('-avPog --delete'), isEmpty);
|
||||
// 관리자 판단 (10/20, 28 확인): - 로 시작하지 않는 낱말 · 하이픈 셋 이상 · 끝에 값 없는 옵션
|
||||
expect(unreadableOptions('-a delete'), ['경로로 읽힙니다: delete']);
|
||||
expect(unreadableOptions('-a - --delete'), ['경로로 읽힙니다: -']);
|
||||
expect(unreadableOptions('-a ---delete'), ['---delete']);
|
||||
expect(unreadableOptions('-a --exclude'), ['--exclude 의 값이 없습니다']);
|
||||
expect(unreadableOptions('-a -f'), ['-f 의 값이 없습니다']);
|
||||
expect(unreadableOptions('-a --exclude *.log --delete'), isEmpty, reason: '값이 있음');
|
||||
expect(unreadableOptions('-a --exclude=*.log'), isEmpty);
|
||||
expect(unreadableOptions(''), isEmpty);
|
||||
// 28 확인 (10/20): rsync 의 짧은 별칭 (--cc · --zc · --zl) 도 값을 받는다 (오탐이던 것)
|
||||
expect(unreadableOptions('-a --cc md5 --zc zlib --zl 3 --zt 2'), isEmpty);
|
||||
// 곧은 하이픈과 대시가 섞인 것 (-\u2013delete)
|
||||
expect(splitOptions('-a -\u2013delete \u2013-del'), ['-a', '--delete', '--del']);
|
||||
expect(unreadableOptions('-a -\u2013delete'), isEmpty);
|
||||
});
|
||||
|
||||
test('28 지적 (10/20): 안내 끝 문구는 문제 종류에 맞게', () {
|
||||
String hint(String o) => optionProblemHint(optionProblems(o));
|
||||
expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요');
|
||||
expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다');
|
||||
expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)');
|
||||
expect(hint('-a ---delete'), '옵션은 - 나 -- 로 시작합니다', reason: '관리자 지시 (10/20)');
|
||||
expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
|
||||
expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)');
|
||||
expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다');
|
||||
});
|
||||
|
||||
testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OptionProblemNote(optionProblems('-a \u00ABdelete\u00BB')))));
|
||||
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
|
||||
expect(find.textContaining('곧은 따옴표와 하이픈'), findsOneWidget);
|
||||
});
|
||||
|
||||
// 28 확인 (10/20): 막기 전 실제 rsync 는 이런 낱말을 원본 경로로 먹는다 - 그래서 [unreadableOptions] 로 막는다
|
||||
test('실제 rsync: -a delete 의 delete · 끝의 --exclude 는 원본 경로로 먹혀 의도와 다르게 돈다', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_optpath_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final s = p.join(tmp.path, 's'), d = p.join(tmp.path, 'd');
|
||||
Future<ProcessResult> run(String opts) {
|
||||
writeScene(s, d, srcFiles: const ['a.txt'], dstFiles: const ['old.txt']);
|
||||
return Process.run(rsync, [...splitOptions(opts), '${toCygwinPath(s)}/', '${toCygwinPath(d)}/'], workingDirectory: tmp.path);
|
||||
}
|
||||
|
||||
// "delete" 를 원본으로 찾다 실패 (exit 23) - --delete 를 뜻했지만 old.txt 는 그대로
|
||||
final r1 = await run('-r delete');
|
||||
expect(r1.exitCode, isNot(0));
|
||||
expect(File(p.join(d, 'old.txt')).existsSync(), isTrue);
|
||||
// --exclude 가 원본 폴더를 값으로 먹고 받는 쪽 하나만 남아 목록만 보이고 끝 (아무것도 보내지 않음)
|
||||
final r2 = await run('-r --exclude');
|
||||
expect(r2.exitCode, 0);
|
||||
expect(File(p.join(d, 'a.txt')).existsSync(), isFalse, reason: '보내지 않음');
|
||||
expect(unreadableOptions('-r delete'), isNotEmpty);
|
||||
expect(unreadableOptions('-r --exclude'), isNotEmpty);
|
||||
});
|
||||
|
||||
// 실제 rsync (Windows, 앱에 든 것): 붙여 넣은 글로 맞춘 결과 = 곧은 글자로 맞춘 결과 (28 의 장면: 받는 쪽 keep.log · old.txt)
|
||||
group('실제 rsync 결과가 같다', () {
|
||||
for (final (name, pasted, straight) in cases) {
|
||||
test(name, () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_optpaste_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
Future<Map<String, String>> run(String opts, String tag) async {
|
||||
final s = p.join(tmp.path, '${tag}s'), d = p.join(tmp.path, '${tag}d');
|
||||
writeScene(s, d, srcFiles: const ['a.txt', 'b.log'], dstFiles: const ['old.txt', 'keep.log']);
|
||||
return realRsyncTree(rsync, s, d, opts);
|
||||
}
|
||||
|
||||
final want = await run(straight, 'w');
|
||||
expect(want.containsKey('keep.log'), isTrue, reason: '곧은 글자: *.log 는 거르므로 받는 쪽 keep.log 를 지키고');
|
||||
expect(want.containsKey('old.txt'), isFalse, reason: '--delete 로 old.txt 는 지움');
|
||||
expect(await run(pasted, 'g'), want);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/ftp.dart';
|
||||
import 'package:jj_mkvmaker/core/remote_idle.dart';
|
||||
import 'package:jj_mkvmaker/core/sftp.dart';
|
||||
import 'package:jj_mkvmaker/core/vfs.dart';
|
||||
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/sftp_server.dart';
|
||||
|
||||
/// 관리자 판단 (10/20): 원격 서버의 받기 · 올리기 자료 연결에서 조각 사이에 정한 시간 동안 아무것도 오가지 않으면 실패
|
||||
/// ("서버가 답하지 않습니다 (n초)"). 예전에는 서버가 멈추면 복사 · 동기화가 영원히 멈춰 있었다.
|
||||
/// 멈추는 시험 서버 (보내다 멈춤 · 받다 멈춤) 로 정한 시간 뒤 실패하는지 본다 (시험은 1초로).
|
||||
|
||||
/// 보내기가 막히도록 큰 자료를 필요한 만큼만 만든다 (서버가 받지 않으면 운영체제 버퍼가 차서 더 가져가지 않음)
|
||||
Stream<List<int>> _big() async* {
|
||||
final chunk = List<int>.filled(64 * 1024, 7);
|
||||
for (var i = 0; i < 4096; i++) {
|
||||
yield chunk; // 최대 256MB
|
||||
}
|
||||
}
|
||||
|
||||
/// 걸린 시간 · 결과 오류
|
||||
Future<(Duration, Object?)> _timed(Future<void> Function() f) async {
|
||||
final sw = Stopwatch()..start();
|
||||
Object? err;
|
||||
try {
|
||||
await f();
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
return (sw.elapsed, err);
|
||||
}
|
||||
|
||||
/// FTP 명령만 하는 작은 시험 서버 - RETR 은 조금 보내고 멈추고, STOR 은 자료 연결을 읽지 않는다 (받다 멈춤)
|
||||
class _StallFtp {
|
||||
late ServerSocket _ctl;
|
||||
final _open = <Socket>[];
|
||||
final _servers = <ServerSocket>[];
|
||||
Socket? _data;
|
||||
int get port => _ctl.port;
|
||||
|
||||
Future<void> start() async {
|
||||
_ctl = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_ctl.listen((s) {
|
||||
_open.add(s);
|
||||
void say(String l) => s.write('$l\r\n');
|
||||
say('220 stall');
|
||||
utf8.decoder.bind(s).transform(const LineSplitter()).listen((line) async {
|
||||
switch (line.split(' ').first.toUpperCase()) {
|
||||
case 'USER':
|
||||
say('331 password');
|
||||
case 'PASS':
|
||||
say('230 ok');
|
||||
case 'FEAT':
|
||||
say('211 End');
|
||||
case 'PWD':
|
||||
say('257 "/" is current');
|
||||
case 'EPSV':
|
||||
final d = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_servers.add(d);
|
||||
d.listen((x) {
|
||||
_open.add(x);
|
||||
_data = x; // 읽지 않는다 (STOR 은 받다 멈춤)
|
||||
});
|
||||
say('229 Entering Extended Passive Mode (|||${d.port}|)');
|
||||
case 'RETR':
|
||||
say('150 sending');
|
||||
for (var i = 0; i < 50 && _data == null; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_data?.add(List<int>.filled(1000, 65)); // 조금 보내고 멈춤 (닫지 않음)
|
||||
case 'STOR':
|
||||
say('150 receiving');
|
||||
case 'QUIT':
|
||||
say('221 bye');
|
||||
default:
|
||||
say('200 ok');
|
||||
}
|
||||
}, onError: (_) {});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
for (final s in _open) {
|
||||
s.destroy();
|
||||
}
|
||||
for (final s in _servers) {
|
||||
await s.close();
|
||||
}
|
||||
await _ctl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// SFTP 시험 서버 앞의 중계: [stall] 이면 양쪽으로 아무것도 넘기지 않는다 (서버가 멈춘 것처럼)
|
||||
class _StallProxy {
|
||||
final int upstream;
|
||||
_StallProxy(this.upstream);
|
||||
late ServerSocket _ss;
|
||||
final _open = <Socket>[];
|
||||
bool stall = false;
|
||||
|
||||
Future<void> start() async {
|
||||
_ss = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_ss.listen((c) async {
|
||||
final u = await Socket.connect(InternetAddress.loopbackIPv4, upstream);
|
||||
_open.addAll([c, u]);
|
||||
u.listen((d) {
|
||||
if (!stall) c.add(d);
|
||||
}, onError: (_) {}, onDone: c.destroy);
|
||||
c.listen((d) {
|
||||
if (!stall) u.add(d);
|
||||
}, onError: (_) {}, onDone: u.destroy);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Socket> connect(String host, int port, Duration timeout) => Socket.connect(InternetAddress.loopbackIPv4, _ss.port, timeout: timeout);
|
||||
|
||||
Future<void> stop() async {
|
||||
for (final s in _open) {
|
||||
s.destroy();
|
||||
}
|
||||
await _ss.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => RemoteIdle.limit = const Duration(seconds: 1));
|
||||
tearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds));
|
||||
|
||||
test('설정 "원격 서버 응답 기다리기": 처음 60초 · 30 · 60 · 120 · 300 만 · 그 밖의 값은 60 · 오류 글은 초까지', () {
|
||||
expect(AppSettings().remoteIdleSeconds, 60);
|
||||
for (final n in RemoteIdle.choices) {
|
||||
expect(AppSettings.fromJson({'remoteIdleSeconds': n}).remoteIdleSeconds, n);
|
||||
}
|
||||
for (final v in [0, 45, -1, 'x', null, 99999]) {
|
||||
expect(AppSettings.fromJson({'remoteIdleSeconds': v}).remoteIdleSeconds, 60, reason: '$v');
|
||||
}
|
||||
expect((AppSettings()..remoteIdleSeconds = 120).toJson()['remoteIdleSeconds'], 120);
|
||||
expect('${const RemoteIdleTimeout(60)}', '서버가 답하지 않습니다 (60초)');
|
||||
});
|
||||
|
||||
test('도우미: 받기 - 조각 사이가 넘으면 실패 · 끊기 · 조각이 계속 오면 실패하지 않음', () async {
|
||||
final c = StreamController<List<int>>();
|
||||
var cut = false;
|
||||
c.add([1]);
|
||||
final (t, e) = await _timed(() => idleRead(c.stream, onTimeout: () => cut = true).drain<void>());
|
||||
expect(e, isA<RemoteIdleTimeout>());
|
||||
expect(t, lessThan(const Duration(seconds: 5)));
|
||||
expect(cut, isTrue);
|
||||
await c.close();
|
||||
// 0.1초마다 조각 (정한 3초 안 - 전체 시험의 부하에서도 넉넉하게, 0b 지적 10/20: 0.4초 대 1초는 한 번 넘음) - 끝까지
|
||||
Stream<List<int>> slow() async* {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
yield [i];
|
||||
}
|
||||
}
|
||||
|
||||
expect(await idleRead(slow(), limit: const Duration(seconds: 3)).length, 5);
|
||||
});
|
||||
|
||||
test('도우미: 올리기 - 보내는 쪽이 더 가져가지 않으면 · 다 가져간 뒤 답이 없으면 실패', () async {
|
||||
var cut = false;
|
||||
final (t, e) = await _timed(() => idleWrite(_big(), (s) async {
|
||||
await s.first; // 한 조각만 가져가고 멈춤
|
||||
await Completer<void>().future;
|
||||
}, onTimeout: () => cut = true));
|
||||
expect(e, isA<RemoteIdleTimeout>());
|
||||
expect(t, lessThan(const Duration(seconds: 5)));
|
||||
expect(cut, isTrue);
|
||||
final (_, e2) = await _timed(() => idleWrite(Stream.value([1]), (s) async {
|
||||
await s.drain<void>();
|
||||
await Completer<void>().future; // 마지막 답이 없음
|
||||
}));
|
||||
expect(e2, isA<RemoteIdleTimeout>());
|
||||
expect(await idleWrite(Stream.value([1, 2]), (s) => s.expand((x) => x).toList()), [1, 2], reason: '멈추지 않으면 그대로');
|
||||
});
|
||||
|
||||
group('FTP (멈추는 시험 서버)', () {
|
||||
late _StallFtp server;
|
||||
setUp(() async {
|
||||
server = _StallFtp();
|
||||
await server.start();
|
||||
FtpRegistry.configure([FtpServer(id: 'st', name: 'stall', host: '127.0.0.1', port: server.port, user: 'jj', password: 'pw', security: 'none')]);
|
||||
});
|
||||
tearDown(() async {
|
||||
FtpRegistry.configure(const []);
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('받다가 서버가 보내지 않으면 (보내다 멈춤) 정한 시간 뒤 "서버가 답하지 않습니다" · 로컬에 받다 만 파일 없음', () async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_idleftp_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final local = p.join(tmp.path, 'a.bin');
|
||||
final (t, e) = await _timed(() async => vWrite(local, await vOpenRead('ftp://st/a.bin')));
|
||||
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
|
||||
expect('$e', '서버가 답하지 않습니다 (1초)');
|
||||
expect(t, lessThan(const Duration(seconds: 10)));
|
||||
expect(tmp.listSync(), isEmpty, reason: '받다 만 .jjsync 를 남기지 않음');
|
||||
});
|
||||
|
||||
test('올리다가 서버가 받지 않으면 (받다 멈춤) 정한 시간 뒤 실패', () async {
|
||||
final (t, e) = await _timed(() => FtpRegistry.client('st').write('/b.bin', _big()));
|
||||
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
|
||||
expect(t, lessThan(const Duration(seconds: 15)));
|
||||
});
|
||||
});
|
||||
|
||||
group('WebDAV (멈추는 시험 서버)', () {
|
||||
late HttpServer http;
|
||||
final seen = <String>[];
|
||||
setUp(() async {
|
||||
seen.clear();
|
||||
http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
http.listen((req) async {
|
||||
seen.add('${req.method} ${req.uri.path}');
|
||||
final res = req.response;
|
||||
switch ((req.method, req.uri.path)) {
|
||||
case ('GET', '/dav/a.bin'):
|
||||
res.contentLength = 1 << 20;
|
||||
res.add(List<int>.filled(1000, 65)); // 조금 보내고 멈춤
|
||||
await res.flush();
|
||||
case ('GET', '/dav/nohead.bin'):
|
||||
break; // 답 (머리) 을 보내지 않음
|
||||
case ('PUT', _):
|
||||
break; // 몸을 읽지 않고 답도 없음 (받다 멈춤)
|
||||
case ('PROPFIND', _):
|
||||
res.statusCode = 404; // 지우기 전 확인 (stat) - 없음으로
|
||||
await res.close();
|
||||
default:
|
||||
res.statusCode = 204;
|
||||
await res.close();
|
||||
}
|
||||
});
|
||||
DavRegistry.configure([DavServer(id: 'st', name: 'stall', url: 'http://127.0.0.1:${http.port}/dav')]);
|
||||
});
|
||||
tearDown(() async {
|
||||
DavRegistry.configure(const []);
|
||||
await http.close(force: true);
|
||||
});
|
||||
|
||||
test('받다가 멈춤 · 답 머리가 없음 → 정한 시간 뒤 실패', () async {
|
||||
final (t, e) = await _timed(() async => (await vOpenRead('dav://st/a.bin')).drain<void>());
|
||||
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
|
||||
expect(t, lessThan(const Duration(seconds: 10)));
|
||||
final (t2, e2) = await _timed(() async => (await vOpenRead('dav://st/nohead.bin')).drain<void>());
|
||||
expect(e2, isA<RemoteIdleTimeout>(), reason: '$e2');
|
||||
expect(t2, lessThan(const Duration(seconds: 10)));
|
||||
});
|
||||
|
||||
test('올리다가 서버가 받지 않으면 실패 · 받다 만 .jjpart 지우기를 서버에 부탁 (그것도 정한 시간까지만)', () async {
|
||||
final (t, e) = await _timed(() => vWrite('dav://st/b.bin', _big()));
|
||||
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
|
||||
expect(t, lessThan(const Duration(seconds: 20)));
|
||||
expect(seen, contains('DELETE /dav/b.bin.jjpart'));
|
||||
});
|
||||
});
|
||||
|
||||
group('SFTP (paramiko 시험 서버 + 멈추는 중계)', () {
|
||||
final has = sftpTestServerAvailable;
|
||||
late Directory tmp;
|
||||
SftpTestServer? server;
|
||||
_StallProxy? proxy;
|
||||
setUp(() async {
|
||||
if (!has) return;
|
||||
tmp = Directory.systemTemp.createTempSync('jj_idlesftp_');
|
||||
final root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||
File(p.join(root.path, 'a.bin')).writeAsBytesSync(List<int>.filled(4 << 20, 1));
|
||||
server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
|
||||
proxy = _StallProxy(server!.port);
|
||||
await proxy!.start();
|
||||
});
|
||||
tearDown(() async {
|
||||
if (!has) return;
|
||||
await proxy?.stop();
|
||||
await server?.stop();
|
||||
for (var i = 0;; i++) {
|
||||
try {
|
||||
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
||||
break;
|
||||
} on FileSystemException {
|
||||
if (i > 30) rethrow;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
});
|
||||
SftpRemote client() => SftpRemote(
|
||||
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: server!.port, user: 'jj', password: 'pw', hostKeyFingerprint: server!.fingerprint),
|
||||
connect: proxy!.connect);
|
||||
|
||||
test('받는 중에 서버가 멈추면 · 명령 (열기) 에 답이 없으면 정한 시간 뒤 실패', () async {
|
||||
final c = client();
|
||||
addTearDown(c.close);
|
||||
// 준비 (연결 · 목록 · 열기) 는 넉넉하게 - 함께 도는 시험의 부하에서 paramiko 의 답이 1초를 넘은 적이 있다 (15 검증 10/20)
|
||||
RemoteIdle.limit = const Duration(seconds: 10);
|
||||
expect((await c.list('/')).map((e) => e.rel), contains('/a.bin'));
|
||||
final s = await c.openReadFrom('a.bin'); // 열기까지는 됨
|
||||
RemoteIdle.limit = const Duration(seconds: 1); // 받기 (listen) 때 이 시간으로
|
||||
proxy!.stall = true; // 이제 서버가 아무것도 보내지 않음
|
||||
final (t, e) = await _timed(() => s.drain<void>());
|
||||
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
|
||||
expect(t, lessThan(const Duration(seconds: 10)));
|
||||
final c2 = client();
|
||||
addTearDown(c2.close);
|
||||
proxy!.stall = false;
|
||||
RemoteIdle.limit = const Duration(seconds: 10);
|
||||
await c2.list('/');
|
||||
RemoteIdle.limit = const Duration(seconds: 1);
|
||||
proxy!.stall = true;
|
||||
final (t2, e2) = await _timed(() => c2.openReadFrom('a.bin'));
|
||||
expect(e2, isA<RemoteIdleTimeout>(), reason: '$e2');
|
||||
expect(t2, lessThan(const Duration(seconds: 10)));
|
||||
}, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||
|
||||
test('올리는 중에 서버가 멈추면 정한 시간 뒤 실패', () async {
|
||||
final c = client();
|
||||
addTearDown(c.close);
|
||||
RemoteIdle.limit = const Duration(seconds: 10); // 준비는 넉넉하게 (위와 같음)
|
||||
await c.list('/');
|
||||
RemoteIdle.limit = const Duration(seconds: 1);
|
||||
proxy!.stall = true;
|
||||
final (t, e) = await _timed(() => c.write('b.bin', _big()));
|
||||
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
|
||||
expect(t, lessThan(const Duration(seconds: 10)));
|
||||
}, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||
});
|
||||
}
|
||||
|
|
@ -85,9 +85,11 @@ void main() {
|
|||
expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue);
|
||||
expect(ex('--exclude=**/deep/**', 'deep', dir: true), isFalse);
|
||||
});
|
||||
test('15 검토 F1 · F3: 고치개 (,s · ,r · ,! · ,p) · H · S · P · R · merge 와 exclude-from 의 ! · 머리 줄은 적용하지 않고 "미리 볼 수 없음"', () {
|
||||
for (final o in ['--filter="-,s a.txt"', '--filter="+,r a.txt"', '--filter="-,! a.txt"', '--filter="-,p a.txt"', '--filter="H a.txt"',
|
||||
'--filter="P a.txt"', '--filter="S a.txt"', '--filter="R a.txt"', '--filter=". rules"', '--filter=": .rsync-filter"']) {
|
||||
test('관리자 지시 (10/20): 고치개 ,s · ,r 과 H · S · P · R 은 따른다 (rsync_phsr_test 가 실제 rsync 와 견줌) · 15 검토 F1 · F3: 그 밖의 고치개 (,! · ,p) · merge 와 exclude-from 의 ! · 머리 줄은 적용하지 않고 "미리 볼 수 없음"', () {
|
||||
for (final o in ['--filter="-,s a.txt"', '--filter="+,r a.txt"', '--filter="H a.txt"', '--filter="P a.txt"', '--filter="S a.txt"', '--filter="R a.txt"']) {
|
||||
expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o);
|
||||
}
|
||||
for (final o in ['--filter="-,! a.txt"', '--filter="-,p a.txt"', '--filter=". rules"', '--filter=": .rsync-filter"']) {
|
||||
final f = RsyncFilter.parse(o);
|
||||
expect(f.unsupported, isNotEmpty, reason: o);
|
||||
expect(f.excluded('a.txt', isDir: false), isFalse, reason: '$o 는 지키지 않음 (미리 보기가 더 적게 지우게 되지 않게)');
|
||||
|
|
@ -214,14 +216,15 @@ void main() {
|
|||
}
|
||||
|
||||
for (final opts in ['--filter="-,s a.txt"', '--filter="H a.txt"']) {
|
||||
test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기는 그 규칙을 "미리 볼 수 없음" 으로 알림', () async {
|
||||
test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기도 지움으로 (관리자 지시 10/20: 이제 이 규칙을 따름)', () async {
|
||||
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
File(p.join(dst, 'a.txt')).writeAsStringSync('받는 쪽');
|
||||
expect((await preview(opts))['a.txt'], 'delete', reason: '보내는 쪽에서만 빼므로 받는 쪽 a.txt 는 지킴이 없다');
|
||||
final r = await Process.run(rsync, ['-r', '-8', '--delete', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'],
|
||||
stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||
expect(File(p.join(dst, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움 (보내는 쪽만 빼기 · 받는 쪽은 지키지 않음)');
|
||||
expect(RsyncFilter.parse(opts).unsupported, isNotEmpty, reason: '미리 보기는 이 규칙을 따르지 못한다고 알려야 함');
|
||||
expect(RsyncFilter.parse(opts).unsupported, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/app/transfer_job.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_launch.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.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:path/path.dart' as p;
|
||||
|
||||
import 'support/rsync_scenes.dart' show sceneRsync, treeOf;
|
||||
|
||||
/// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져
|
||||
/// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다.
|
||||
/// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async {
|
||||
final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false);
|
||||
expect(plain.args, ['-a', "it's"]);
|
||||
expect(plain.environment, isNull);
|
||||
final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true);
|
||||
addTearDown(w.dispose);
|
||||
expect(w.args, ['@args.txt']);
|
||||
expect(w.environment!['MSYS'], contains('noglob'));
|
||||
expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"');
|
||||
await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA<StateError>()));
|
||||
// 옵션 칸에서도 미리 빨갛게 (Windows 만)
|
||||
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1));
|
||||
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty);
|
||||
expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty);
|
||||
});
|
||||
|
||||
test('관리자 판단 (10/20): 목록 파일의 상대 경로는 막는다 (rsync 와 미리 보기가 다른 작업 폴더 기준으로 읽음) - 전체 경로 · 폴더마다 읽는 규칙은 그대로', () {
|
||||
for (final o in [
|
||||
'-a --exclude-from=list.txt',
|
||||
'-a --exclude-from list.txt',
|
||||
'-a --include-from=./a.txt',
|
||||
'-a --files-from=sub/files.txt',
|
||||
'-a --filter=". rules.txt"',
|
||||
'-a --filter="merge,- rules.txt"',
|
||||
'-a --filter ". w.txt"',
|
||||
'-a -f". y.txt"',
|
||||
'-avf ". z.txt"',
|
||||
'-a --exclude-from=~/list.txt',
|
||||
]) {
|
||||
expect(unreadableOptions(o, windows: true), [contains('목록 파일은 전체 경로로 적어 주세요')], reason: o);
|
||||
}
|
||||
for (final o in [
|
||||
r'-a --exclude-from=C:\lists\l.txt',
|
||||
'-a --exclude-from=C:/lists/l.txt',
|
||||
'-a --include-from=/cygdrive/c/l.txt',
|
||||
r'-a --files-from=\\nas\share\l.txt',
|
||||
'-a --filter=". /cygdrive/c/rules.txt"',
|
||||
r'-a -f". C:\r.txt"',
|
||||
'-a --filter=": .rsync-filter"',
|
||||
'-a --filter="dir-merge .x"',
|
||||
'-a -F',
|
||||
'-a --exclude=list.txt',
|
||||
// 받는 쪽 기준이라 그대로 (28 확인)
|
||||
'-a --backup-dir=old --partial-dir=.part --temp-dir=tmp --compare-dest=../base',
|
||||
r'-a --log-file=C:\logs\r.log --write-batch=/cygdrive/c/b/batch',
|
||||
]) {
|
||||
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
|
||||
}
|
||||
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 전체 경로만 (상대면 응답 파일 임시 폴더에 생겼다가 함께 지워짐)
|
||||
for (final o in ['-a --log-file=r.log', '-a --log-file r.log', '-a --write-batch=b', '-a --only-write-batch b', '-a --read-batch=./b']) {
|
||||
expect(unreadableOptions(o, windows: true), [contains('기록 · 배치 파일은 전체 경로로 적어 주세요')], reason: o);
|
||||
}
|
||||
});
|
||||
|
||||
test('관리자 지시 (10/20): Windows 는 드라이브 · UNC · /cygdrive/ 만 전체 경로 ("/" 는 MSYS 가 자기 폴더 기준으로 읽음), Android 는 / · 끝 문구', () {
|
||||
expect(unreadableOptions('-a --exclude-from=/lists/l.txt', windows: true), hasLength(1));
|
||||
expect(unreadableOptions('-a --exclude-from=/sdcard/l.txt', windows: false), isEmpty);
|
||||
expect(unreadableOptions('-a --exclude-from=C:/l.txt', windows: false), hasLength(1), reason: 'Android 에서는 / 로 시작해야');
|
||||
for (final o in [r'-a --exclude-from=\\nas\s\l.txt', '-a --exclude-from=//nas/s/l.txt', '-a --exclude-from=/cygdrive/c/l.txt']) {
|
||||
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
|
||||
}
|
||||
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: true), windows: true), r'C:\ 처럼 드라이브로 시작하는 전체 경로로');
|
||||
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: false), windows: false), '/ 로 시작하는 전체 경로로');
|
||||
});
|
||||
|
||||
test('관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\\ · C:/ · UNC 는 rsync 가 읽는 꼴로 바꿔 넘긴다 (막지 않음)', () {
|
||||
// UNC 는 \\서버\… 로 (28 · 15 확인 10/20: 목록 · 기록 파일은 //서버/… 를 열지 못함, exit 11)
|
||||
expect(
|
||||
rsyncFileOptionArgs(
|
||||
[r'--exclude-from=C:\l\a.txt', '--include-from', 'C:/l/b.txt', r'--files-from=\\nas\s\f.txt', '--exclude-from=//nas/s/g.txt'],
|
||||
windows: true),
|
||||
['--exclude-from=/cygdrive/c/l/a.txt', '--include-from', '/cygdrive/c/l/b.txt', r'--files-from=\\nas\s\f.txt', r'--exclude-from=\\nas\s\g.txt']);
|
||||
// 받는 쪽은 UNC 를 \\ 로 (//서버 는 change_dir 실패, exit 3) · 원본은 // 그대로 (toCygwinPath)
|
||||
expect(rsyncDestPath(r'\\nas\s\d'), r'\\nas\s\d');
|
||||
expect(rsyncDestPath('//nas/s/d'), r'\\nas\s\d');
|
||||
expect(rsyncDestPath(r'C:\d'), '/cygdrive/c/d');
|
||||
expect(rsyncDestPath(r'\\nas\s\d', windows: false), r'\\nas\s\d');
|
||||
expect(toCygwinPath(r'\\nas\s\src'), '//nas/s/src');
|
||||
final a = rsyncArgs(options: '-a', sources: [r'\\nas\s\src'], dest: r'\\nas\s\dst', contents: true);
|
||||
expect(a.sublist(a.length - 2), ['//nas/s/src/', r'\\nas\s\dst/']);
|
||||
expect(rsyncFileOptionArgs(['--filter=merge,- C:/r/m.txt', '--filter', '. C:/r/d.txt', '-f. C:/r/e.txt', '-avf', 'merge C:/r/g.txt'], windows: true),
|
||||
['--filter=merge,- /cygdrive/c/r/m.txt', '--filter', '. /cygdrive/c/r/d.txt', '-f. /cygdrive/c/r/e.txt', '-avf', 'merge /cygdrive/c/r/g.txt']);
|
||||
expect(rsyncFileOptionArgs(['--log-file=C:/logs/r.log', '--write-batch', r'D:\b\batch', '--only-write-batch=C:/b2', '--read-batch=C:/b3'], windows: true),
|
||||
['--log-file=/cygdrive/c/logs/r.log', '--write-batch', '/cygdrive/d/b/batch', '--only-write-batch=/cygdrive/c/b2', '--read-batch=/cygdrive/c/b3']);
|
||||
// 바꾸지 않는 것: 패턴 · 폴더마다 읽는 규칙 · 받는 쪽 기준 옵션 · 다른 값 받는 글자 · 이미 /cygdrive · Android
|
||||
final keep = ['--exclude=C:/x', '--filter=: .rsync-filter', '--filter=- C:/x', '--backup-dir=C:/b', '-e', 'C:/ssh.exe', '--exclude-from=/cygdrive/c/l.txt'];
|
||||
expect(rsyncFileOptionArgs(keep, windows: true), keep);
|
||||
expect(rsyncFileOptionArgs([r'--exclude-from=C:\l.txt'], windows: false), [r'--exclude-from=C:\l.txt']);
|
||||
});
|
||||
|
||||
test('관리자 지시 (10/20, 28 지적): 표준 입력 (-) 은 따로 된 문구 (하이픈이 겹치지 않게)', () {
|
||||
for (final o in ['-a --files-from=-', '-a --exclude-from=-', '-a --include-from -']) {
|
||||
final bad = optionProblems(o, windows: true);
|
||||
expect(bad.map((x) => x.text), ['표준 입력 (-) 은 쓸 수 없습니다'], reason: o);
|
||||
expect(optionProblemHint(bad, windows: true), '목록 파일의 전체 경로를 적어 주세요', reason: o);
|
||||
}
|
||||
});
|
||||
|
||||
group('실제 rsync', () {
|
||||
final rsync = sceneRsync;
|
||||
late Directory tmp;
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_'));
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
/// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름)
|
||||
(String, String) scene(String sn, String dn, List<String> sf, List<String> df) {
|
||||
final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true);
|
||||
final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true);
|
||||
for (final f in sf) {
|
||||
File(p.join(s.path, f))
|
||||
..parent.createSync(recursive: true)
|
||||
..writeAsStringSync('s:$f');
|
||||
}
|
||||
for (final f in df) {
|
||||
File(p.join(d.path, f))
|
||||
..parent.createSync(recursive: true)
|
||||
..writeAsStringSync('d:$f');
|
||||
}
|
||||
return (s.path, d.path);
|
||||
}
|
||||
|
||||
// (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상))
|
||||
final cases = <(String, String, String, String, List<String>, List<String>, List<String>, List<String>)>[
|
||||
("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'],
|
||||
["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']),
|
||||
("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']),
|
||||
("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']),
|
||||
("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']),
|
||||
("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'],
|
||||
['old.txt']),
|
||||
('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'],
|
||||
['keep.txt', 'n.log'], ['old.log', 'a.txt']),
|
||||
('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'],
|
||||
['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']),
|
||||
('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']),
|
||||
];
|
||||
|
||||
for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) {
|
||||
test('복사 작업 (TransferJob): $name', () async {
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final (s, d) = scene(sn, dn, sf, df);
|
||||
// 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다)
|
||||
final cwd = Directory.current;
|
||||
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true);
|
||||
await job.run();
|
||||
expect(job.error, isNull, reason: '${job.error}');
|
||||
final t = treeOf(d);
|
||||
for (final k in keep) {
|
||||
expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t');
|
||||
}
|
||||
for (final g in gone) {
|
||||
expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t');
|
||||
}
|
||||
expect(Directory.current.path, cwd.path);
|
||||
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
|
||||
}
|
||||
|
||||
test('28 확인 (10/20): 목록 · 기록 파일을 C:/ · C:\\ 로 적어도 rsync 가 연다 (예전: failed to open exclude file, exit 11) - keep.txt 를 지킴', () async {
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final lists = Directory(p.join(tmp.path, 'lists'))..createSync();
|
||||
File(p.join(lists.path, 'ex.txt')).writeAsStringSync('keep.txt\n');
|
||||
File(p.join(lists.path, 'rules.txt')).writeAsStringSync('- keep2.txt\n');
|
||||
final slash = lists.path.replaceAll(r'\', '/');
|
||||
for (final (name, opts) in [
|
||||
('--exclude-from=C:/…', '-a --delete --exclude-from=$slash/ex.txt --exclude=keep2.txt'),
|
||||
(r'--exclude-from=C:\…', '-a --delete --exclude-from="${lists.path}\\ex.txt" --exclude=keep2.txt'),
|
||||
('--exclude-from C:/… (다음 낱말)', '-a --delete --exclude-from $slash/ex.txt --exclude=keep2.txt'),
|
||||
('--filter="merge C:/…"', '-a --delete --exclude=keep.txt --filter="merge $slash/rules.txt"'),
|
||||
('-f". C:/…"', '-a --delete --exclude=keep.txt -f". $slash/rules.txt"'),
|
||||
]) {
|
||||
final (s, d) = scene('s_$name'.replaceAll(RegExp(r'[^\w]'), '_'), 'd_$name'.replaceAll(RegExp(r'[^\w]'), '_'), ['a.txt'],
|
||||
['keep.txt', 'keep2.txt', 'old.txt']);
|
||||
expect(unreadableOptions(opts), isEmpty, reason: '$name: 막지 않음');
|
||||
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true);
|
||||
await job.run();
|
||||
expect(job.error, isNull, reason: '$name: ${job.error}');
|
||||
expect(treeOf(d).keys.toSet(), {'keep.txt', 'keep2.txt', 'a.txt'}, reason: '$name: 목록의 것을 지키고 old.txt 만 지움');
|
||||
}
|
||||
// --log-file=C:/… 은 그 자리에 기록을 남긴다 (예전에는 열지 못해 실패)
|
||||
final (s, d) = scene('s_log', 'd_log', ['a.txt'], []);
|
||||
final job = TransferJob(
|
||||
sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a --log-file=$slash/r.log', rsyncExe: rsync, contents: true);
|
||||
await job.run();
|
||||
expect(job.error, isNull, reason: '${job.error}');
|
||||
expect(File(p.join(lists.path, 'r.log')).existsSync(), isTrue);
|
||||
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
|
||||
|
||||
test('28 · 15 확인 (10/20): 네트워크 공유 (UNC, 관리 공유 \\\\localhost\\C\$ 로) - 원본 · 받는 쪽 · 목록 파일 (\\\\ · //) 이 모두 된다', () async {
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
String unc(String local) => '\\\\localhost\\C\$${local.substring(2)}';
|
||||
if (!Directory(unc(tmp.path)).existsSync()) return markTestSkipped('관리 공유 \\\\localhost\\C\$ 를 쓸 수 없음');
|
||||
final list = File(p.join(tmp.path, 'ex.txt'))..writeAsStringSync('keep.txt\n');
|
||||
for (final (name, listArg) in [('목록 \\\\', unc(list.path)), ('목록 //', unc(list.path).replaceAll(r'\', '/'))]) {
|
||||
final (s, d) = scene('us_${name.length}', 'ud_${name.length}', ['a.txt'], ['keep.txt', 'old.txt']);
|
||||
// 사용자가 고른 그대로의 UNC 경로 (원본 · 받는 쪽 모두)
|
||||
final job = TransferJob(
|
||||
sources: [unc(s)], dest: unc(d), move: false, method: CopyMethod.rsync, options: '-a --delete --exclude-from=$listArg', rsyncExe: rsync, contents: true);
|
||||
await job.run();
|
||||
expect(job.error, isNull, reason: '$name: ${job.error}');
|
||||
expect(treeOf(d).keys.toSet(), {'keep.txt', 'a.txt'}, reason: name);
|
||||
}
|
||||
// 실시간 동기화 쌍의 받는 쪽도 UNC
|
||||
final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-a'
|
||||
..liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
await live.syncNow(c.settings.liveSyncPairs.single);
|
||||
expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.single)]!.$2, isNot(contains('실패')));
|
||||
expect(treeOf(d).keys.toSet(), {'a.txt'});
|
||||
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
|
||||
|
||||
test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async {
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
|
||||
..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
final pair = c.settings.liveSyncPairs.single;
|
||||
await live.syncNow(pair);
|
||||
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
|
||||
final t = treeOf(d);
|
||||
expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움');
|
||||
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
|
||||
|
||||
test('응답 파일은 끝나면 지운다', () async {
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final (s, d) = scene('s', 'd', ['a.txt'], []);
|
||||
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true);
|
||||
await job.run();
|
||||
expect(job.error, isNull);
|
||||
// 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다
|
||||
final mine = [
|
||||
for (final e in Directory.systemTemp.listSync())
|
||||
if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync())
|
||||
if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path,
|
||||
];
|
||||
expect(mine, isEmpty);
|
||||
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'package:jj_mkvmaker/core/webdav.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:path/path.dart' as p;
|
||||
|
||||
import 'support/dav_server.dart';
|
||||
import 'support/rsync_scenes.dart';
|
||||
|
||||
/// 관리자 지시 (10/20): H · S · P · R 과 고치개 ,s · ,r 을 rsync 와 똑같이 따른다 - 기준은 실제 rsync 를 돌린 (-n 아님) 결과 트리.
|
||||
/// 장면 · 옵션마다 ① 미리 보기 (previewSync 로 예측한 트리) ② 원격 맞추기 (WebDAV 시험 서버) ③ 실시간 동기화 (로컬 rsync 쌍:
|
||||
/// 묻는 목록 = rsync 가 지운 것 · 맞춘 뒤 맞출 것 없음) ④ 실시간 동기화 (WebDAV 쌍) 가 모두 실제 rsync 와 같아야 한다.
|
||||
/// 하나라도 다르면 그 규칙은 미리 볼 수 없는 것 (unsupported) 으로 남겨야 한다.
|
||||
|
||||
/// 보내는 쪽 (양쪽에 같은 이름이 많게 - H · S 는 보내기, P · R 은 지우기에 걸리게)
|
||||
const _src = [
|
||||
'a.txt', 'b.log', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/y.log', 'build/x.o', 'build/sub/y.o',
|
||||
'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log', 'tmp/t.txt', '한글 폴더/가.txt', 'docs/r.md', 'z.txt', 'keep/k.txt',
|
||||
];
|
||||
|
||||
/// 받는 쪽 (같은 이름은 크기가 달라 늘 바뀜 · 받는 쪽에만 있는 것은 지우기 대상)
|
||||
const _dst = [
|
||||
'a.txt', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/onlyd.log', 'shared/onlyd.txt', 'build/old.o',
|
||||
'old/keep.log', 'old/gone.txt', 'old2/deep/x/keep.log', 'old2/deep/x/g.txt', 'old2/e.txt', 'tmp/old.txt', 'extra.txt',
|
||||
'extra.log', 'sub/deep/z.txt', '한글 폴더/나.txt', 'onlydst/a.bak',
|
||||
// 28 확인 (10/20): 원본에도 있는 폴더 안의, 받는 쪽에만 있는 파일 · 하위 폴더 ("P keep/***" 가 안쪽까지 지키는지)
|
||||
'keep/extra.txt', 'keep/sub/deep.txt',
|
||||
];
|
||||
|
||||
/// 규칙 장면 (지우기 포함이 많게 - 지우기가 데이터 쪽 위험)
|
||||
const phsrOptions = [
|
||||
'-a --delete --filter="H *.log"',
|
||||
'-a --delete --filter="-,s *.log"',
|
||||
'-a --delete --filter="P *.log"',
|
||||
'-a --delete --filter="-,r *.log"',
|
||||
'-a --delete --filter="P old/"',
|
||||
'-a --delete --filter="R *.log" --exclude=*.log',
|
||||
'-a --delete --exclude=*.log --filter="R keep.log"',
|
||||
'-a --delete --filter="R keep.log" --exclude=*.log',
|
||||
'-a --delete --exclude=*.log --delete-excluded --filter="P keep.log"',
|
||||
'-a --delete --filter="P keep.log" --exclude=*.log --delete-excluded',
|
||||
'-a --delete --filter="S a.txt" --filter="H *.txt"',
|
||||
'-a --delete --filter="H *.txt" --filter="S a.txt"',
|
||||
'-a --delete --filter="H build/"',
|
||||
'-a --delete --filter="P build/"',
|
||||
'-a --delete --filter="-,sr *.log"',
|
||||
'-a --delete --filter="-,sr *.log" --delete-excluded',
|
||||
'-a --delete --filter="hide *.log"',
|
||||
'-a --delete --filter="protect /old2/"',
|
||||
'-a --delete --filter="risk *.txt" --filter="P *"',
|
||||
'-a --delete --filter="P *" --filter="R *.txt"',
|
||||
'-a --delete -f"H deep/" -f"P z.txt"',
|
||||
'-a --delete --filter="H tmp/" --filter="P tmp/old.txt"',
|
||||
'-a --delete --filter="+,r old/***" --exclude=old/',
|
||||
'-a --delete --filter="H 한글*/"',
|
||||
'-a --delete --filter="S *.log" --exclude=*.log',
|
||||
'-a --delete --filter="+,s *.log" --exclude=*.log',
|
||||
'-a --delete --filter="show shared/" --filter="hide shared/*"',
|
||||
'-a --delete --filter="H *.log" --delete-excluded',
|
||||
'-a --delete --filter="P shared/onlyd.*" --filter="H shared/"',
|
||||
'-a --filter="H *.log"',
|
||||
'-a --filter="P *.log" --exclude=*.txt',
|
||||
// 28 확인 (10/20): "이름/***" 이 안쪽 (받는 쪽에만 있는 하위 폴더) 까지
|
||||
'-a --delete --filter="R keep/extra.txt" --filter="P keep/***"',
|
||||
'-a --delete --filter="P keep/***"',
|
||||
'-a --delete --exclude=keep/***',
|
||||
'-a --delete --filter="H keep/***"',
|
||||
];
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final rsync = sceneRsync;
|
||||
late Directory tmp, remote;
|
||||
late TestDavServer server;
|
||||
const id = 'nas';
|
||||
DavServer dav() => DavServer(id: id, name: '시험', url: server.url, user: 'user', password: 'pass');
|
||||
|
||||
setUp(() async {
|
||||
// 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 시험 WebDAV 서버 (127.0.0.1) 에 실제로 묻는다
|
||||
HttpOverrides.global = null;
|
||||
tmp = Directory.systemTemp.createTempSync('jj_phsr_');
|
||||
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
|
||||
server = TestDavServer(remote);
|
||||
await server.start();
|
||||
DavRegistry.configure([dav()]);
|
||||
});
|
||||
tearDown(() async {
|
||||
DavRegistry.configure([]);
|
||||
await server.stop();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('이 규칙들은 이제 미리 볼 수 있다 (unsupported 없음) · 그 밖의 고치개 · merge 는 그대로 알림', () {
|
||||
for (final o in phsrOptions) {
|
||||
expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o);
|
||||
}
|
||||
for (final o in ['--filter="-,! a"', '--filter="-,p a"', '--filter="-,x a"', '--filter="H,r a"', '--filter=". rules"', '--filter="merge x"', '--filter="!"']) {
|
||||
expect(RsyncFilter.parse(o).unsupported, isNotEmpty, reason: o);
|
||||
}
|
||||
// 쪽: H 는 보내기에만 (받는 쪽을 지키지 않음) · P 는 지우기에만 (보내기는 그대로)
|
||||
final h = RsyncFilter.parse('--filter="H *.log"');
|
||||
expect((h.excluded('a.log', isDir: false), h.protects('a.log', isDir: false)), (true, false));
|
||||
final pr = RsyncFilter.parse('--filter="P *.log"');
|
||||
expect((pr.excluded('a.log', isDir: false), pr.protects('a.log', isDir: false)), (false, true));
|
||||
// --delete-excluded: 쪽을 적지 않은 규칙은 보내기에만 · ,sr 로 적은 것은 양쪽
|
||||
final de = RsyncFilter.parse('--exclude=*.log --filter="-,sr *.bak" --delete-excluded');
|
||||
expect((de.excluded('a.log', isDir: false), de.protects('a.log', isDir: false)), (true, false));
|
||||
expect((de.excluded('a.bak', isDir: false), de.protects('a.bak', isDir: false)), (true, true));
|
||||
});
|
||||
|
||||
for (final (i, opts) in phsrOptions.indexed) {
|
||||
test('${i + 1}: $opts - 미리 보기 · 원격 맞추기 · 실시간 동기화 (로컬 · 원격) = 실제 rsync', () async {
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync');
|
||||
writeScene(src, want, srcFiles: _src, dstFiles: _dst);
|
||||
final before = treeOf(want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
|
||||
final delete = optionsDelete(opts);
|
||||
final filter = RsyncFilter.parse(opts);
|
||||
final why = <String>[];
|
||||
|
||||
// ① 미리 보기: 받는 쪽 트리 + 미리 보기 줄 → 예측한 트리
|
||||
final pv = p.join(tmp.path, 'pv');
|
||||
writeScene(src, pv, srcFiles: _src, dstFiles: _dst);
|
||||
final items = await previewSync(src, pv, toRight: true, delete: delete, filter: filter);
|
||||
final guess = Map.of(before);
|
||||
for (final x in items) {
|
||||
if (x.action == PreviewAction.delete) {
|
||||
guess.removeWhere((k, _) => k == x.rel || k.startsWith('${x.rel}/'));
|
||||
}
|
||||
}
|
||||
for (final x in items) {
|
||||
if (x.action == PreviewAction.add || x.action == PreviewAction.update) {
|
||||
guess[x.rel] = x.isDir ? '/' : File(p.joinAll([src, ...x.rel.split('/')])).readAsStringSync();
|
||||
}
|
||||
}
|
||||
for (final d in treeDiff(w, guess)) {
|
||||
why.add('미리 보기: $d');
|
||||
}
|
||||
|
||||
// ② 원격 맞추기 (WebDAV)
|
||||
final got = p.join(remote.path, 'm');
|
||||
writeScene(src, got, srcFiles: _src, dstFiles: _dst);
|
||||
await FileOps().mirror(src, 'dav://$id/m', delete: delete, filter: filter);
|
||||
for (final d in treeDiff(w, treeOf(got))) {
|
||||
why.add('원격 맞추기: $d');
|
||||
}
|
||||
|
||||
// ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤)
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = opts
|
||||
..webdavServers = [dav()];
|
||||
for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) {
|
||||
final dir = local ? target : p.join(remote.path, 'l');
|
||||
writeScene(src, dir, srcFiles: _src, dstFiles: _dst);
|
||||
final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
final k = LiveSync.keyOf(pair);
|
||||
await live.syncNow(pair);
|
||||
if (live.status[k]!.$2.contains('실패')) why.add('$label 상태: ${live.status[k]!.$2}');
|
||||
if (live.noAutoDelete[k] != null) why.add('$label: 미리 볼 수 없다고 함 ${live.noAutoDelete[k]}');
|
||||
final asked = live.toDelete[k] ?? const <String>[];
|
||||
for (final g in gone) {
|
||||
if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('$label: rsync 는 지우는데 묻지 않음: $g');
|
||||
}
|
||||
for (final a in asked) {
|
||||
if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a');
|
||||
}
|
||||
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
|
||||
for (final d in treeDiff(w, treeOf(dir))) {
|
||||
why.add('$label: $d');
|
||||
}
|
||||
if ((live.pending[k] ?? const []).isNotEmpty) why.add('$label: 맞춘 뒤에도 맞출 것 ${live.pending[k]}');
|
||||
live.dispose();
|
||||
}
|
||||
expect(why, isEmpty, reason: '$opts\n ${why.join('\n ')}');
|
||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import 'explorer_test.dart' show act, settle;
|
||||
import 'support/dav_server.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||
|
||||
/// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음)
|
||||
void main() {
|
||||
|
|
@ -78,7 +79,7 @@ void main() {
|
|||
expect(find.textContaining('-avPog -u --delete'), findsNothing);
|
||||
expect(find.textContaining('rsync -avPog -u'), findsWidgets);
|
||||
expect(runButton(tester).onPressed, isNotNull);
|
||||
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget);
|
||||
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
|
||||
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty,
|
||||
rounds: 400);
|
||||
|
|
@ -101,12 +102,12 @@ void main() {
|
|||
expect(find.textContaining('rsync -avPog --delete'), findsNothing);
|
||||
expect(find.textContaining('− b.txt'), findsNothing);
|
||||
// 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다
|
||||
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget);
|
||||
await waitCompare(tester);
|
||||
expect(find.textContaining('− b.txt'), findsOneWidget);
|
||||
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget);
|
||||
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget);
|
||||
await tester.tap(find.text('취소'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -118,7 +119,7 @@ void main() {
|
|||
await open(tester);
|
||||
await act(tester, () => tester.tap(find.text('좌 → 우')));
|
||||
await waitCompare(tester);
|
||||
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); // --del 도 지우기로 알아본다
|
||||
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); // --del 도 지우기로 알아본다
|
||||
await tester.tap(find.byTooltip('다시 비교'));
|
||||
await tester.pump();
|
||||
expect(runButton(tester).onPressed, isNull);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,20 @@ void main() {
|
|||
addTearDown(tester.view.reset);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: SettingsPage(c: c))));
|
||||
await tester.pump();
|
||||
// 관리자 지시 (10/20, 15): 부하가 크면 (여러 시험을 함께 돌림) 칸들이 시작한 진짜 입출력 (레지스트리 조회 · 구성 요소 목록 ·
|
||||
// 파일) 이 늦게 끝나 화면이 바뀌거나, 화면을 치운 뒤에야 끝나 예외가 났다 - 화면이 멈출 때까지 (위젯 수가 세 번 같음) 진짜 시간을 흘린다
|
||||
Future<void> settle() async {
|
||||
var last = -1, same = 0;
|
||||
for (var i = 0; i < 150 && same < 3; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
final n = find.byType(Widget).evaluate().length;
|
||||
same = n == last ? same + 1 : 0;
|
||||
last = n;
|
||||
}
|
||||
}
|
||||
|
||||
await settle();
|
||||
final tops = {for (final e in find.byType(ActionChip).evaluate()) tester.getRect(find.byWidget(e.widget)).top.round()};
|
||||
expect(tops, isNotEmpty);
|
||||
if (oneRow) {
|
||||
|
|
@ -31,7 +44,8 @@ void main() {
|
|||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await settle(); // 치운 뒤에도 남은 비동기 일이 이 시험 안에서 끝나게
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
});
|
||||
}, timeout: const Timeout(Duration(minutes: 2))); // 화면이 멈출 때까지 기다리므로 부하가 크면 30초를 넘을 수 있음
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import 'support/sftp_server.dart';
|
|||
|
||||
/// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막.
|
||||
/// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음)
|
||||
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
|
||||
const sftpPw = 'sftp-Pw-3Lm8vZ2w';
|
||||
|
||||
void main() {
|
||||
final hasServer = sftpTestServerAvailable;
|
||||
late Directory tmp;
|
||||
|
|
@ -26,10 +29,10 @@ void main() {
|
|||
if (!hasServer) return;
|
||||
tmp = Directory.systemTemp.createTempSync('jj_sftpnext_');
|
||||
root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
|
||||
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: sftpPw);
|
||||
server = s;
|
||||
SftpRegistry.configure([
|
||||
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: 'pw', hostKeyFingerprint: s.fingerprint),
|
||||
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: sftpPw, hostKeyFingerprint: s.fingerprint),
|
||||
]);
|
||||
Directory(p.join(root.path, '영상')).createSync();
|
||||
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
|
||||
|
|
@ -93,7 +96,10 @@ void main() {
|
|||
expect(u1, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게');
|
||||
expect(u1, isNot(equals(u2)));
|
||||
expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소');
|
||||
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
|
||||
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
|
||||
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
|
||||
expect(u1, isNot(contains(sftpPw)), reason: '주소에 비밀번호 없음');
|
||||
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
|
||||
|
||||
var (code, h, body) = await get(u1);
|
||||
expect((code, body), (200, '0123456789'));
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ void main() {
|
|||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||
|
||||
test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
|
||||
final rsync = bundledRsync;
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
await start();
|
||||
SftpRegistry.configure([srv(fp: fingerprint)]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ import 'package:path/path.dart' as p;
|
|||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'support/rsync_scenes.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
|
||||
import 'package:jj_mkvmaker/core/remote_stream.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/core/remote_idle.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫)
|
||||
class _FakeSmb {
|
||||
|
|
@ -20,6 +29,9 @@ class _FakeSmb {
|
|||
final calls = <String>[];
|
||||
String? failWith;
|
||||
bool encrypted = true;
|
||||
|
||||
/// 조각 읽기 · 쓰기에 답하지 않음 (NAS 가 멈춘 것처럼 - 원격 응답 기다리기 시험)
|
||||
bool stall = false;
|
||||
bool signed = false;
|
||||
Map<Object?, Object?> lastArgs = const {};
|
||||
final _open = <int, (String, bool)>{};
|
||||
|
|
@ -32,6 +44,7 @@ class _FakeSmb {
|
|||
final a = (call.arguments as Map?) ?? const {};
|
||||
lastArgs = a;
|
||||
if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith');
|
||||
if (stall && (call.method == 'readChunk' || call.method == 'writeChunk')) return Completer<Object?>().future;
|
||||
switch (call.method) {
|
||||
case 'info':
|
||||
return {'encrypted': encrypted, 'signed': signed, 'dialect': 'SMB_3_1_1'};
|
||||
|
|
@ -111,7 +124,8 @@ void main() {
|
|||
SmbRegistry.configure(const []);
|
||||
});
|
||||
|
||||
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw');
|
||||
// 길고 드문 비밀번호 - 다리 주소의 무작위 표에 우연히 들어가지 않게 (관리자 지시 10/20, 28 이 찾음)
|
||||
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'smb-Pw-5Qh9kT4r');
|
||||
|
||||
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
|
||||
SmbRegistry.configure([server]);
|
||||
|
|
@ -183,10 +197,10 @@ void main() {
|
|||
final secrets = MemorySecretStore();
|
||||
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
||||
await store.save(AppSettings()..smbServers = [server]);
|
||||
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"')));
|
||||
expect(secrets.values['smb.pw.m1'], 'pw');
|
||||
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r')));
|
||||
expect(secrets.values['smb.pw.m1'], 'smb-Pw-5Qh9kT4r');
|
||||
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
|
||||
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw'));
|
||||
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'smb-Pw-5Qh9kT4r'));
|
||||
});
|
||||
|
||||
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
|
||||
|
|
@ -263,4 +277,95 @@ void main() {
|
|||
expect(fake.files.containsKey(r'dst\old.txt'), isFalse);
|
||||
expect((fake.files.containsKey(r'dst\gone\g.tmp'), fake.files.containsKey(r'dst\gone\g.txt')), (true, false));
|
||||
});
|
||||
test('관리자 지시 (10/20): SMB 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개, 가짜 채널)', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
SmbRegistry.configure(const [server]);
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_smbscene_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), seed = p.join(tmp.path, 'seed');
|
||||
// 가짜 공유의 scene 폴더 → 트리 (treeOf 와 같은 모양)
|
||||
Map<String, String> fakeTree() => {
|
||||
for (final e in fake.files.entries)
|
||||
if (e.key.startsWith(r'scene\'))
|
||||
e.key.substring(6).replaceAll(r'\', '/'): e.value == null ? '/' : utf8.decode(e.value!),
|
||||
};
|
||||
final bad = <String>[];
|
||||
for (final opts in sceneOptions) {
|
||||
writeScene(src, want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
writeScene(src, seed);
|
||||
fake.files
|
||||
..clear()
|
||||
..['scene'] = null;
|
||||
for (final e in treeOf(seed).entries) {
|
||||
fake.files['scene\\${e.key.replaceAll('/', r'\')}'] = e.value == '/' ? null : utf8.encode(e.value);
|
||||
}
|
||||
await FileOps().mirror(src, 'smb://m1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
||||
final d = treeDiff(w, fakeTree());
|
||||
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
|
||||
}
|
||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||
}, timeout: const Timeout(Duration(minutes: 5))); // 실제 rsync 28번 - 여러 시험을 함께 돌리면 기본 30초를 넘을 수 있음
|
||||
|
||||
test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
|
||||
SmbRegistry.configure(const [server]);
|
||||
// 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 이 시험은 이 기기 안의 다리 (127.0.0.1) 에 실제로 묻는다
|
||||
final saved = HttpOverrides.current;
|
||||
HttpOverrides.global = null;
|
||||
addTearDown(() => HttpOverrides.global = saved);
|
||||
fake.files.addAll({
|
||||
'영상': null,
|
||||
'영상\\1화.mkv': utf8.encode('0123456789'),
|
||||
'영상\\2화.mkv': utf8.encode('abcdefghij'),
|
||||
'영상\\2화.ko.srt': utf8.encode('1\n00:00:01,000 --> 00:00:02,000\n둘째\n'),
|
||||
'영상\\메모.txt': utf8.encode('t'),
|
||||
});
|
||||
addTearDown(RemoteStreamBridge.close);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.playlistMode = PlaylistMode.folder;
|
||||
final plan = await c.preparePlayback(['smb://m1/영상/1화.mkv']);
|
||||
expect(plan!.$1, ['smb://m1/영상/1화.mkv', 'smb://m1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');
|
||||
|
||||
final bridge = await RemoteStreamBridge.get();
|
||||
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv');
|
||||
expect(u1, startsWith('http://127.0.0.1:'));
|
||||
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
|
||||
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
|
||||
expect(u1, isNot(contains(server.password)), reason: '주소에 비밀번호 없음');
|
||||
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
|
||||
Future<(int, String)> get(String url, [String? range]) async {
|
||||
final http = HttpClient();
|
||||
try {
|
||||
final req = await http.getUrl(Uri.parse(url));
|
||||
if (range != null) req.headers.set('Range', range);
|
||||
final res = await req.close();
|
||||
return (res.statusCode, await res.transform(utf8.decoder).join());
|
||||
} finally {
|
||||
http.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
expect(await get(u1), (200, '0123456789'));
|
||||
expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터');
|
||||
expect(await get(u1, 'bytes=7-'), (206, '789'));
|
||||
expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로');
|
||||
final subs = await c.externalSubtitlesFor('smb://m1/영상/2화.mkv');
|
||||
expect(subs.map(p.basename), ['2화.ko.srt']);
|
||||
expect(File(subs.single).readAsStringSync(), contains('둘째'));
|
||||
});
|
||||
|
||||
test('관리자 판단 (10/20): SMB 조각 읽기 · 쓰기에 NAS 가 답하지 않으면 정한 시간 뒤 "서버가 답하지 않습니다" (가짜 채널)', () async {
|
||||
SmbRegistry.configure(const [server]);
|
||||
RemoteIdle.limit = const Duration(seconds: 1);
|
||||
addTearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds));
|
||||
fake.files['v.bin'] = List<int>.filled(3000, 1);
|
||||
fake.stall = true;
|
||||
final c = SmbRegistry.client('m1');
|
||||
final sw = Stopwatch()..start();
|
||||
await expectLater((await c.openReadFrom('v.bin')).drain<void>(), throwsA(isA<RemoteIdleTimeout>()));
|
||||
await expectLater(c.write('w.bin', Stream.value(List<int>.filled(3000, 2))), throwsA(isA<RemoteIdleTimeout>()));
|
||||
expect(sw.elapsed, lessThan(const Duration(seconds: 10)));
|
||||
expect(fake.calls.where((m) => m == 'closeHandle'), hasLength(2), reason: '열린 것은 닫는다');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,14 +54,15 @@ const sceneOptions = [
|
|||
];
|
||||
|
||||
/// 앱에 들어 있는 rsync (Windows). 없으면 null
|
||||
String? get bundledRsync {
|
||||
String? get sceneRsync {
|
||||
final f = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
return Platform.isWindows && File(f).existsSync() ? f : null;
|
||||
}
|
||||
|
||||
/// 장면을 만든다 (있던 것은 지우고). 보내는 쪽 글은 "s:경로", 받는 쪽은 "d:경로"
|
||||
void writeScene(String src, String dst) {
|
||||
for (final (dir, list, tag) in [(src, sceneSrc, 's'), (dst, sceneDst, 'd')]) {
|
||||
/// [srcFiles] · [dstFiles] 로 다른 장면도 (받는 쪽 글은 "dd:경로" 로 - 양쪽에 같은 이름이 있으면 크기가 달라 늘 바뀜으로 보이게)
|
||||
void writeScene(String src, String dst, {List<String> srcFiles = sceneSrc, List<String>? dstFiles}) {
|
||||
for (final (dir, list, tag) in [(src, srcFiles, 's'), (dst, dstFiles ?? sceneDst, dstFiles == null ? 'd' : 'dd')]) {
|
||||
final d = Directory(dir);
|
||||
if (d.existsSync()) d.deleteSync(recursive: true);
|
||||
d.createSync(recursive: true);
|
||||
|
|
|
|||
|
|
@ -10,9 +10,19 @@ final _later = <Directory>[];
|
|||
Directory tempDirUntilEnd(String prefix) {
|
||||
final d = Directory.systemTemp.createTempSync(prefix);
|
||||
_later.add(d);
|
||||
expectInTaggedTemp(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): tool/test_temp_check.ps1 이 TEMP · TMP 를 실행 폴더 (%TEMP%\t_jjrun_<표>) 로 돌리고 표를 JJ_TEST_TMP_TAG 로 넘긴다.
|
||||
/// 표가 있는데 임시 폴더가 그 밖에 생기면 (환경 변수가 시험 · 앱까지 오지 않음) 다른 세션 시험과 섞이므로 시험을 실패시킨다
|
||||
void expectInTaggedTemp(Directory dir) {
|
||||
final tag = Platform.environment['JJ_TEST_TMP_TAG'] ?? '';
|
||||
if (tag.isEmpty) return;
|
||||
expect(dir.absolute.path.toLowerCase(), contains('t_jjrun_$tag'.toLowerCase()),
|
||||
reason: 'TEMP 가 시험 실행 폴더로 오지 않음 (JJ_TEST_TMP_TAG=$tag)');
|
||||
}
|
||||
|
||||
/// main() 에서 한 번 부른다: 파일의 시험이 다 끝나면 [tempDirUntilEnd] 로 만든 폴더를 지운다
|
||||
/// (늦게 끝난 저장이 다시 만들 수 있어 잠깐씩 기다리며 몇 번 본다)
|
||||
void deleteTempDirsAtEnd() {
|
||||
|
|
|
|||
|
|
@ -50,4 +50,16 @@ void main() {
|
|||
expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget);
|
||||
expect(find.textContaining('(받는 쪽이 더 새것 - 건너뜀)'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('28 확인 (10/20): keepOptionsTogether - 글 안의 옵션 덩어리만 묶고 (괄호째 · 따로 쓴 것) 나머지는 그대로', () {
|
||||
const wj = '\u2060', nb = '\u00A0';
|
||||
expect(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'), '원본 파일 지우기 (-$wj-${wj}remove-${wj}source-${wj}files)');
|
||||
expect(keepOptionsTogether('Delete source files (--remove-source-files)'), 'Delete source files (-$wj-${wj}remove-${wj}source-${wj}files)',
|
||||
reason: '앞 글의 빈칸은 그대로 (거기서는 줄을 바꿀 수 있음)');
|
||||
expect(keepOptionsTogether('지워집니다 (--delete, 되돌릴 수 없음)'), '지워집니다 (-$wj-${wj}delete,$nb되돌릴$nb수$nb없음)');
|
||||
expect(keepOptionsTogether('rsync --delete · robocopy /PURGE. 끄면'), 'rsync -$wj-${wj}delete · robocopy /${wj}PURGE. 끄면');
|
||||
expect(keepOptionsTogether('(rsync 의 -u, robocopy 의 /XO 와 같음)'), '(rsync 의 -${wj}u, robocopy 의 /${wj}XO 와 같음)');
|
||||
// 낱말 가운데의 - · / 는 그대로 (real-time · a/b)
|
||||
expect(keepOptionsTogether('real-time 동기화 · a/b · WebDAV'), 'real-time 동기화 · a/b · WebDAV');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'explorer_test.dart' show act, calm, settle;
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||
|
||||
/// 68 · 70 · 71
|
||||
void main() {
|
||||
|
|
@ -204,7 +205,7 @@ void main() {
|
|||
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
|
||||
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
|
||||
// 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐
|
||||
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget);
|
||||
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
|
||||
expect(find.textContaining('− only_r.txt'), findsOneWidget);
|
||||
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull);
|
||||
await tester.tap(find.text('취소'));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
|
||||
|
||||
/// 관리자 지시 (10/20, 28 지적): 경고 글 안의 옵션 (--filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리던 것
|
||||
void main() {
|
||||
double widest(String s, {bool min = true}) {
|
||||
final tp = TextPainter(text: TextSpan(text: s, style: const TextStyle(fontSize: 12)), textDirection: TextDirection.ltr)..layout();
|
||||
final w = min ? tp.minIntrinsicWidth : tp.maxIntrinsicWidth;
|
||||
tp.dispose();
|
||||
return w;
|
||||
}
|
||||
|
||||
test('옵션 글은 줄바꿈 자리가 없다 (가장 좁게 접어도 한 줄 폭)', () {
|
||||
for (final o in ['--filter=H a.txt', '-f"- *.log"', '--files-from=list/a b.txt', '-C']) {
|
||||
final u = unbreakableOption(o);
|
||||
expect(widest(u), closeTo(widest(u, min: false), 0.01), reason: o);
|
||||
expect(u.replaceAll('\u2060', '').replaceAll('\u00A0', ' '), o, reason: '보이는 글은 그대로');
|
||||
}
|
||||
// 묶지 않은 글은 줄이 갈릴 수 있다 (시험이 실제로 무언가를 보는지)
|
||||
expect(widest('--filter=H a.txt'), lessThan(widest('--filter=H a.txt', min: false)));
|
||||
});
|
||||
|
||||
// 관리자 지시 (10/20): 묶은 글을 복사해 옵션 칸에 붙여 넣어도 rsync 가 같은 인수로 읽는다
|
||||
test('묶은 옵션 글 · 다른 곳의 글을 붙여 넣어도 인수는 원래대로', () {
|
||||
const o = '-a --delete --filter="H a.txt" -f"- *.log"';
|
||||
final pasted = unbreakableOption(o);
|
||||
expect(pasted, isNot(o));
|
||||
expect(cleanOptionText(pasted), o);
|
||||
expect(splitOptions(pasted), splitOptions(o));
|
||||
expect(splitOptions(pasted), ['-a', '--delete', '--filter=H a.txt', '-f- *.log']);
|
||||
// 따옴표 안의 줄바꿈 없는 빈칸도 보통 빈칸 (거르기 규칙 "H a.txt" 가 그대로)
|
||||
expect(RsyncFilter.parse(pasted).unsupported, RsyncFilter.parse(o).unsupported);
|
||||
expect(RsyncFilter.parse(pasted).excluded('old.log', isDir: false), isTrue);
|
||||
// 웹 · 문서에서 온 글 (U+202F · U+200B · U+FEFF)
|
||||
expect(splitOptions('\uFEFF-av\u202F--delete\u200B'), ['-av', '--delete']);
|
||||
});
|
||||
|
||||
testWidgets('옵션 칸 (OptionTextFormatter): 붙여 넣은 묶은 글을 고쳐 받고 커서는 끝에', (tester) async {
|
||||
final ctl = TextEditingController();
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(body: TextField(controller: ctl, inputFormatters: const [OptionTextFormatter()]))));
|
||||
final pasted = unbreakableOption('--filter=H a.txt');
|
||||
await tester.enterText(find.byType(TextField), pasted);
|
||||
await tester.pump();
|
||||
expect(ctl.text, '--filter=H a.txt');
|
||||
expect(ctl.selection.baseOffset, ctl.text.length);
|
||||
ctl.dispose();
|
||||
});
|
||||
}
|
||||
|
|
@ -150,7 +150,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('최신 확인 → 받기 · SHA256 확인 · 압축 풀기', () async {
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, versionOverride: '1.0.1');
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, versionOverride: '1.0.1', tempRoot: tmp.path);
|
||||
final r = (await up.latest())!;
|
||||
expect(r.isNewerThan(await up.currentVersion()), isTrue);
|
||||
final progress = <double>[];
|
||||
|
|
@ -160,7 +160,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('SHA256 이 다르면 설치 거부', () async {
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path);
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, tempRoot: tmp.path);
|
||||
final good = (await up.latest())!;
|
||||
final bad = ReleaseInfo(
|
||||
version: good.version, tag: good.tag, name: '', notes: '', pageUrl: '',
|
||||
|
|
@ -178,7 +178,7 @@ void main() {
|
|||
Directory(p.join(app.path, 'models')).createSync();
|
||||
File(p.join(app.path, 'models', 'm.bin')).writeAsStringSync('model');
|
||||
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: app.path);
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: app.path, tempRoot: tmp.path);
|
||||
final dir = await up.download((await up.latest())!, (_) {});
|
||||
// 이미 끝난 프로세스를 "앱" 으로 두고 실행 (바로 교체 진행)
|
||||
final dummy = await Process.start('cmd', ['/c', 'exit']);
|
||||
|
|
@ -192,6 +192,9 @@ void main() {
|
|||
expect(File(p.join(app.path, 'old_only.txt')).readAsStringSync(), 'keep me');
|
||||
expect(File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).readAsStringSync(), 'my download');
|
||||
expect(File(p.join(app.path, 'models', 'm.bin')).readAsStringSync(), 'model');
|
||||
// 기록 · 스크립트는 스크립트 옆 (실제로는 %TEMP%, 시험은 시험 폴더 - 시험이 %TEMP% 에 남기지 않음)
|
||||
expect(File(p.join(tmp.path, 'jj_mkvmaker_update.log')).existsSync(), isTrue);
|
||||
expect(File(p.join(tmp.path, 'jj_mkvmaker_update.ps1')).existsSync(), isTrue);
|
||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'package:path/path.dart' as p;
|
|||
import 'support/dav_server.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
import 'support/rsync_scenes.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmp, remote, local;
|
||||
|
|
@ -151,6 +152,8 @@ void main() {
|
|||
lf('live/sub/b.txt', 'bb');
|
||||
rf('mirror/old.txt', 'o');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
// 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도
|
||||
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
|
||||
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
|
|
@ -302,4 +305,87 @@ void main() {
|
|||
expect(Directory(real('cache')).existsSync(), isFalse);
|
||||
expect(Directory(real('gone')).existsSync(), isFalse);
|
||||
});
|
||||
test('관리자 지시 (10/20): WebDAV 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
final src = p.join(local.path, 'scene_src'), want = p.join(local.path, 'scene_rsync'), got = p.join(remote.path, 'scene');
|
||||
final bad = <String>[];
|
||||
for (final opts in sceneOptions) {
|
||||
writeScene(src, want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
writeScene(src, got);
|
||||
await FileOps().mirror(src, 'dav://$id/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
||||
final d = treeDiff(w, treeOf(got));
|
||||
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
|
||||
}
|
||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||
}, timeout: const Timeout(Duration(minutes: 5)));
|
||||
|
||||
test('관리자 지시 (10/20): 실시간 동기화 (WebDAV rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
final src = p.join(local.path, 'live_src'), want = p.join(local.path, 'live_rsync'), got = p.join(remote.path, 'live');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
// 지우기를 정하면 설정을 저장하며 서버 목록을 설정에서 다시 읽는다 - 시험 서버를 설정에도
|
||||
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
|
||||
final bad = <String>[];
|
||||
for (final opts in sceneOptions) {
|
||||
writeScene(src, want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
writeScene(src, got);
|
||||
final before = treeOf(got);
|
||||
// rsync 가 지운 것 (받는 쪽에 있다가 없어진 것)
|
||||
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
|
||||
c.settings.rsyncOptions = opts;
|
||||
// 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다
|
||||
final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts));
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
final k = LiveSync.keyOf(pair);
|
||||
final why = <String>[];
|
||||
await live.syncNow(pair);
|
||||
if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}');
|
||||
final asked = live.toDelete[k] ?? const <String>[];
|
||||
// 묻는 목록 = rsync 가 지우는 것 (폴더는 한 줄로 - 그 안의 것은 그 폴더에 든 것으로)
|
||||
for (final g in gone) {
|
||||
if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('rsync 는 지우는데 묻지 않음: $g');
|
||||
}
|
||||
for (final a in asked) {
|
||||
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
|
||||
}
|
||||
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
|
||||
why.addAll(treeDiff(w, treeOf(got)));
|
||||
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
|
||||
live.dispose();
|
||||
if (why.isNotEmpty) bad.add('$opts\n ${why.join('\n ')}');
|
||||
}
|
||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||
}, timeout: const Timeout(Duration(minutes: 5)));
|
||||
|
||||
test('관리자 3a 판단 (10/20): 원격 (WebDAV) 쌍도 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다', () async {
|
||||
lf('rq/a.txt', 'a');
|
||||
rf('rqd/only.tmp', 'o');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]
|
||||
..rsyncOptions = '-av --exclude=*.tmp'
|
||||
..liveSyncPairs = [
|
||||
LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',
|
||||
method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
|
||||
];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
LiveSyncPair pair() => c.settings.liveSyncPairs.single;
|
||||
final k = LiveSync.keyOf(pair());
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], isNull);
|
||||
c.settings.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||
await live.syncNow(pair());
|
||||
expect(live.toDelete[k], ['only.tmp']);
|
||||
expect(live.reasked, contains(k));
|
||||
expect(File(p.join(remote.path, 'rqd', 'only.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
|
||||
await live.decideDelete(pair(), delete: true);
|
||||
expect(File(p.join(remote.path, 'rqd', 'only.tmp')).existsSync(), isFalse);
|
||||
expect(pair().confirmedOptions, '-av --exclude=*.tmp --delete-excluded');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
|
||||
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
|
||||
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
|
||||
# - versionCode 의 모양 (관리자 지시 10/20, 15 제안): versionCode = 판 이름 날짜 (2026.10.18 → 261018) × 1000 + 순번 (000~999).
|
||||
# 987 을 --build-number 261018987 로 빌드해 versionCode 522036987 (날짜가 두 번 더해짐) 이 나왔는데 통과했다 - 사용자 기기에 깔리면
|
||||
# 그 뒤의 정식 판 (261018xxx) 이 "낮은 버전" 이라 업데이트가 막힌다. build.gradle.kts 가 날짜를 붙이므로 APK 의 --build-number 는 순번만 (예 987).
|
||||
# 정식 판 (-Release) 은 순번 < 900, 시험판은 순번 >= 900.
|
||||
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Apk,
|
||||
|
|
@ -74,6 +78,17 @@ $same = @(foreach ($t in $tags) {
|
|||
if ($t -match '^v(\d+)\.(\d+)\.(\d+)_(\d+)$' -and [long]$Matches[4] -lt 900 -and
|
||||
([long]$Matches[1] % 100) * 10000000 + [long]$Matches[2] * 100000 + [long]$Matches[3] * 1000 + [long]$Matches[4] -eq $code) { $t }
|
||||
})
|
||||
# versionCode 의 모양: 판 이름의 날짜 × 1000 + 순번 (build.gradle.kts 와 같은 셈)
|
||||
if ($vname -match '^(\d+)\.(\d+)\.(\d+)') {
|
||||
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
|
||||
if ([math]::Floor($code / 1000) -ne $day) {
|
||||
$bad += "versionCode $code 가 판 이름 $vname 의 날짜 ($day × 1000 + 순번) 와 맞지 않음 - APK 는 --build-number 에 순번만 (예 987, 날짜를 붙이지 않음)"
|
||||
} elseif (-not $Release -and ($code % 1000) -lt 900) {
|
||||
$bad += "시험판인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판은 --build-number 9xx (정식 판이면 -Release)"
|
||||
}
|
||||
} else {
|
||||
$bad += "판 이름을 읽지 못함: '$vname' (년.월.일 이어야 함)"
|
||||
}
|
||||
if ($Release -and ($code % 1000) -ge 900) {
|
||||
$bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
# - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게).
|
||||
# - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19).
|
||||
# 이 검사는 git 태그를 보지 않으므로 시험판 태그를 단 뒤 다시 검사해도 거짓 실패가 없다.
|
||||
# - 시험판 번호의 날짜는 판 이름 (pubspec) 의 날짜 (관리자 지시 10/20, d3 판단): 2026.10.18+261018987 은 맞고 +261020985 (빌드한 날) 는 실패.
|
||||
# 근거: ① APK versionCode (판 이름 날짜 × 1000 + 순번) 와 같은 번호가 되어 두 판을 같은 이름으로 견줄 수 있다.
|
||||
# ② 앱의 판 견주기 (compareVersions) 는 + 뒤 번호 전체를 견준다 - 날짜가 섞이면 같은 판 이름에서 985 (261020985) 가
|
||||
# 987 (261018987) 보다 새것으로 보인다. 순번은 900 이상 (시험판).
|
||||
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_windows_build.ps1 -Dir <배포 폴더 (Lib 가 있는 곳)> [-Release]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Dir,
|
||||
|
|
@ -21,6 +25,12 @@ if ($Release) {
|
|||
if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" }
|
||||
} elseif ($build -lt 1000) {
|
||||
throw ("시험판인데 정식 판 번호입니다: $ver (화면 v$shown - 정식 판과 같게 보임).`n" +
|
||||
"flutter build windows --release --build-number <년월일><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
|
||||
"flutter build windows --release --build-number <판 이름 날짜 6자리><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
|
||||
} else {
|
||||
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
|
||||
if ([math]::Floor($build / 1000) -ne $day -or ($build % 1000) -lt 900) {
|
||||
throw ("시험판 번호가 판 이름과 맞지 않습니다: $ver - 번호는 판 이름의 날짜 ($day) + 순번 900~999 여야 합니다 (빌드한 날이 아님).`n" +
|
||||
"flutter build windows --release --build-number $day<순번 3자리> (예: ${day}987) 로 다시 빌드하세요")
|
||||
}
|
||||
}
|
||||
"통과: $ver (화면 v$shown) · $(if ($Release) { '정식 판' } else { '시험판 - 정식 판 번호와 다름' })"
|
||||
|
|
|
|||
|
|
@ -8,15 +8,22 @@
|
|||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
out = os.path.join(tempfile.gettempdir(), 'jj_l10n_keys.json')
|
||||
out2 = os.path.join(tempfile.gettempdir(), 'jj_l10n_unwrapped.json')
|
||||
subprocess.run([sys.executable, os.path.join(here, 'extract_keys.py'), out, out2], check=True)
|
||||
keys = json.load(open(out, encoding='utf-8'))
|
||||
# 관리자 지시 (10/20): 예전에는 %TEMP% 에 jj_l10n_keys.json · jj_l10n_unwrapped.json 을 남겼다 - 임시 폴더를 만들고 끝에 지운다
|
||||
work = tempfile.mkdtemp(prefix='jj_l10n_check_')
|
||||
try:
|
||||
out = os.path.join(work, 'keys.json')
|
||||
out2 = os.path.join(work, 'unwrapped.json')
|
||||
subprocess.run([sys.executable, os.path.join(here, 'extract_keys.py'), out, out2], check=True)
|
||||
keys = json.load(open(out, encoding='utf-8'))
|
||||
unwrapped = json.load(open(out2, encoding='utf-8'))
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
missing = 0
|
||||
for lang in ['en', 'ja', 'zh-Hans']:
|
||||
d = json.load(open(os.path.join('assets', 'l10n', lang + '.json'), encoding='utf-8'))
|
||||
|
|
@ -26,7 +33,7 @@ for lang in ['en', 'ja', 'zh-Hans']:
|
|||
print('%s 사전에 없음: %r' % (lang, k))
|
||||
# 60: tr 로 감싸지 않은 한글 글. 사전에 있으면 tr(변수) 로 번역되는 것으로 본다 - 없으면 번역이 빠진 것
|
||||
en = json.load(open(os.path.join('assets', 'l10n', 'en.json'), encoding='utf-8'))
|
||||
loose = [(p, n, t) for p, n, t in json.load(open(out2, encoding='utf-8')) if t not in en]
|
||||
loose = [(p, n, t) for p, n, t in unwrapped if t not in en]
|
||||
for p, n, t in loose:
|
||||
print('번역 안 된 글 (tr 로 감싸거나 사전에 넣기, 자료면 // l10n-skip): %s:%d %r' % (p, n, t[:80]))
|
||||
missing += len(loose)
|
||||
|
|
|
|||
|
|
@ -1,66 +1,70 @@
|
|||
# 전체 시험 + 임시 폴더 흔적 검사 (관리자 지시 10/17): 시험이 %TEMP%\jj_* 폴더를 남기지 않는지.
|
||||
# - 시험 전 · 뒤의 %TEMP% 를 비교해, 새로 남은 폴더 중 이름 앞부분이 시험 소스 (test\*.dart) 의 createTemp('jj…') 와 같은 것만 본다
|
||||
# (앱 · 다른 프로그램이 만든 jj_mkvmaker 등은 세지 않음). 하나라도 남으면 이름을 보이고 실패 (exit 2).
|
||||
# - 시험 파일들은 동시에 돌아 파일마다 스스로 확인할 수 없어 (누가 만든 폴더인지 모름) 전체 실행 앞뒤로 본다.
|
||||
# - 다른 worktree · 세션이 같은 시험을 동시에 돌리면 그쪽 폴더가 섞일 수 있다 (이름으로 확인).
|
||||
# - 이미 있던 폴더는 건드리지 않는다 (지우지 않음).
|
||||
# 전체 시험 + 임시 폴더 흔적 검사 (관리자 지시 10/17): 시험이 임시 폴더 (jj_*) 를 남기지 않는지.
|
||||
# - 관리자 지시 (10/20): 실행마다 %TEMP%\t_jjrun_<표> 를 만들고 TEMP · TMP 를 그쪽으로 돌려 flutter test 를 돌린다.
|
||||
# 그 폴더 안만 보므로 다른 worktree · 세션이 같은 %TEMP% 에서 동시에 돌린 시험의 폴더가 섞이지 않는다
|
||||
# (예전에는 앞뒤 %TEMP% 를 비교해 다른 세션의 jj_explorer_ · jj_sync_ · jj_fake_recycle_ 가 섞여 거짓 실패).
|
||||
# - 앱 코드 (lib) 가 시험 중에 만든 임시 폴더 (jj_up_ · jj_7z_ 등) 도 같은 곳에 생기므로 함께 잡힌다.
|
||||
# - 표는 환경 변수 JJ_TEST_TMP_TAG 로도 시험에 넘긴다: 시험 도우미 (test/support/temp_dirs.dart · integration_test/support/temp_dir.dart)
|
||||
# 가 임시 폴더가 그 안에 생기는지 확인한다 (통합 시험 -d windows 에서 앱까지 환경 변수가 가는지).
|
||||
# - 끝나면 남은 것을 보이고 실행 폴더를 통째로 지운다. jj 로 시작하는 것 (폴더 · 파일, 앱 고정 이름 포함) 이 남으면 실패 (exit 2)
|
||||
# (앱이 끝날 때까지 잡힌 jj_held_ 와 jj 가 아닌 것은 참고로만).
|
||||
# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\test_temp_check.ps1 [flutter test 인자…]
|
||||
# 통합 시험 · 캡처도 (관리자 지시 10/19, 28 이 찾음): … test_temp_check.ps1 integration_test\windows_b6_shot_test.dart -d windows
|
||||
# - 폴더 이름 앞부분은 test 와 integration_test 두 곳의 시험 소스에서 모은다
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$temp = [System.IO.Path]::GetTempPath()
|
||||
|
||||
# 시험 소스가 쓰는 임시 폴더 이름 앞부분 (createTemp('jj_x_') · createTempSync('jj_x_'))
|
||||
# 넘겨받은 시험 파일 (.dart) 이 있으면 그 파일들과 support 폴더만 본다 - 다른 세션 · worktree 가 같은 때 돌린 시험의 폴더를 세지 않게
|
||||
# (10/19: b6 캡처 앞뒤에 다른 세션의 단위 시험 폴더 jj_explorer_ · jj_sync_ 가 섞여 거짓 실패)
|
||||
$named = @($args | Where-Object { "$_" -like '*.dart' } | ForEach-Object { Join-Path $app "$_" } | Where-Object { Test-Path $_ })
|
||||
$sources = if ($named.Count -gt 0) {
|
||||
# 그 파일들이 가져다 쓰는 support 파일만 함께 (import 'support/…') - 다른 세션의 시험과 이름이 겹치지 않게 (10/19: jj_fake_recycle_)
|
||||
$support = @($named | ForEach-Object {
|
||||
$dir = Split-Path $_
|
||||
[regex]::Matches((Get-Content $_ -Raw -Encoding utf8), "import '(support/[^']+\.dart)'") |
|
||||
ForEach-Object { Join-Path $dir $_.Groups[1].Value } })
|
||||
@($named) + @($support | Where-Object { Test-Path $_ })
|
||||
} else {
|
||||
@(@('test', 'integration_test') | ForEach-Object { Join-Path $app $_ } | Where-Object { Test-Path $_ } |
|
||||
ForEach-Object { Get-ChildItem $_ -Recurse -Filter *.dart | ForEach-Object { $_.FullName } })
|
||||
}
|
||||
# 이름이 하나도 없으면 (임시 폴더를 만들지 않는 시험) 새 jj 폴더는 참고로만 보인다
|
||||
# 이름 앞부분: createTemp('jj_x_') · createTempSync('jj_x_') · testTempDir('jj_x_') (integration_test/support/temp_dir.dart)
|
||||
$prefixes = @($sources |
|
||||
ForEach-Object { [regex]::Matches((Get-Content $_ -Raw -Encoding utf8), '(?:createTemp(?:Sync)?|testTempDir)\(''(jj[^''$]*)''') } |
|
||||
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||
if ($prefixes.Count -eq 0 -and $named.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' }
|
||||
# 표: 짧게 (긴 경로 시험이 경로 길이에 민감하다) - 이름이 jj 로 시작하지 않게 (예전 판 검사가 이 폴더를 시험 폴더로 세지 않게)
|
||||
$tag = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
|
||||
$run = Join-Path $temp "t_jjrun_$tag"
|
||||
New-Item -ItemType Directory -Path $run | Out-Null
|
||||
$saved = @{ TEMP = $env:TEMP; TMP = $env:TMP; JJ_TEST_TMP_TAG = $env:JJ_TEST_TMP_TAG }
|
||||
Write-Host "시험 임시 폴더: $run"
|
||||
|
||||
function Get-TestTemp {
|
||||
@(Get-ChildItem $temp -Directory -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name })
|
||||
}
|
||||
|
||||
$before = @{}
|
||||
foreach ($n in Get-TestTemp) { $before[$n] = $true }
|
||||
$env:TEMP = $run
|
||||
$env:TMP = $run
|
||||
$env:JJ_TEST_TMP_TAG = $tag
|
||||
Push-Location $app
|
||||
try {
|
||||
flutter test @args
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
Pop-Location
|
||||
$env:TEMP = $saved.TEMP
|
||||
$env:TMP = $saved.TMP
|
||||
$env:JJ_TEST_TMP_TAG = $saved.JJ_TEST_TMP_TAG
|
||||
}
|
||||
# 시험이 끝난 뒤 지우기가 늦게 끝나는 것 (백신 · 색인) 을 잠깐 기다린다
|
||||
Start-Sleep -Seconds 3
|
||||
$left = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) } | Where-Object {
|
||||
$n = $_
|
||||
@($prefixes | Where-Object { $n.StartsWith($_) }).Count -gt 0
|
||||
})
|
||||
# 참고: 시험 소스의 이름이 아닌 새 jj 폴더 (앱 코드가 만든 것일 수 있음 - 실패로 세지 않고 보이기만)
|
||||
$other = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) -and $left -notcontains $_ })
|
||||
$items = @(Get-ChildItem -LiteralPath $run -Force -ErrorAction SilentlyContinue)
|
||||
# jj_held_ 는 앱이 끝날 때까지 놓지 않는 폴더 (integration_test/support/temp_dir.dart 의 heldByApp - Edge 사용자 데이터) - 보이기만 하고 아래에서 함께 지운다
|
||||
$held = @($items | Where-Object { $_.Name -like 'jj_held_*' } | ForEach-Object { $_.Name })
|
||||
# 실패로 세는 것: jj 로 시작하는 것 모두 (폴더 · 파일 - 시험 소스 이름 · 앱 코드의 고정 이름 jj_mkvmaker · jj_mkvmaker_update_* 등).
|
||||
# 관리자 판단 (10/20): 앱 고정 이름도 사용자 PC 의 실제 %TEMP% 에 쌓이던 것이라 새면 잡는다
|
||||
$left = @($items | Where-Object { $_.Name -like 'jj*' -and $_.Name -notlike 'jj_held_*' } | ForEach-Object { $_.Name })
|
||||
# 참고: jj 가 아닌 것 (flutter 도구 · MSBuild 등 - 실패로 세지 않고 보이기만, 실행 폴더와 함께 지움)
|
||||
$other = @($items | Where-Object { $_.Name -notlike 'jj*' } | ForEach-Object { $_.Name })
|
||||
if ($other.Count -gt 0) {
|
||||
Write-Host ("참고 - 시험 소스에 없는 이름의 새 jj 폴더 {0}개:" -f $other.Count)
|
||||
Write-Host ("참고 - jj 가 아닌 남은 것 {0}개 (실행 폴더와 함께 지움):" -f $other.Count)
|
||||
$other | Sort-Object | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($held.Count -gt 0) {
|
||||
Write-Host ("참고 - 앱이 끝날 때까지 잡혀 있던 것 {0}개 (실행 폴더와 함께 지움):" -f $held.Count)
|
||||
$held | Sort-Object | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($left.Count -gt 0) {
|
||||
Write-Host ("시험이 남긴 임시 폴더 {0}개 (%TEMP%):" -f $left.Count)
|
||||
Write-Host ("시험이 남긴 임시 폴더 {0}개 ({1}):" -f $left.Count, $run)
|
||||
$left | Sort-Object | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
# 실행 폴더는 이 스크립트가 만든 것이라 통째로 지운다 (긴 경로는 \\?\ 로).
|
||||
# 관리자 지시 (10/20): Edge (WebView2) 는 시험 앱이 끝난 뒤에도 몇 초 사용자 데이터 (jj_held_) 를 잡고 있다 - 놓을 때까지 몇 번 다시 해 본다.
|
||||
# rd 의 오류는 cmd 안에서 버린다 (PowerShell 쪽 2>$null 은 $ErrorActionPreference = 'Stop' 에서 오류가 되어 시험이 다 통과해도 exit 1 이었다).
|
||||
# 지우는 것은 이 실행 폴더 안뿐이다 (사용자 폴더 · 다른 세션의 %TEMP% 는 건드리지 않음)
|
||||
for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $run); $i++) {
|
||||
if ($i -gt 0) { Start-Sleep -Seconds 2 }
|
||||
cmd /c "rd /s /q `"\\?\$run`" 2>nul"
|
||||
}
|
||||
if (Test-Path -LiteralPath $run) { Write-Host "실행 폴더를 다 지우지 못함 (1분 기다림): $run" }
|
||||
if ($left.Count -gt 0) {
|
||||
if ($code -eq 0) { exit 2 }
|
||||
} else {
|
||||
Write-Host '임시 폴더 흔적: 새로 남은 것 0'
|
||||
|
|
|
|||
Loading…
Reference in New Issue