Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>remote_filter
commit
c0695b02d8
|
|
@ -356,6 +356,7 @@
|
|||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept",
|
||||
"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 · 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 를 따름).": "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).",
|
||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?",
|
||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV / SFTP can't be added to the MKV list. Copy them to this device first.",
|
||||
|
|
@ -1315,6 +1316,7 @@
|
|||
"받다 만 AI 모델": "Unfinished AI models",
|
||||
"받다 만 다운로드": "Unfinished downloads",
|
||||
"받다 만 조각 · {0}": "Partial download · {0}",
|
||||
"받다 만 파일 {0}개도 지워집니다": "{0} partially downloaded files are deleted too",
|
||||
"받아서 열기": "Download and open",
|
||||
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "Downloaded AI models ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
|
||||
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "Copy downloaded AI models ({0}) to Download/JJ_MKVMaker and restore them after reinstalling (needs {0} more space for a while)",
|
||||
|
|
@ -1530,6 +1532,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "Can't connect to the server ({0}): {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "The connection to the server ({0}) was lost",
|
||||
"서버 · 서비스 (기기 밖)": "Servers · services (off device)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "Server lists (WebDAV, SFTP, SMB, FTP) with their passwords and API keys, and file explorer favorites, are deleted too. The video list, web bookmarks and files are kept",
|
||||
"서버 설정 고치기": "Edit server settings",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server settings and the password, private key and trusted server key saved on this device. Files on the server stay as they are. Syncs and copies that use this server won't connect until you add it again.",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "Deletes the server settings and the password and trusted certificate saved on this device. Files on the server are not touched.",
|
||||
|
|
@ -1727,6 +1730,7 @@
|
|||
"안쪽 파일을 확인하는 중 …": "Checking files inside …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "I understand - run anyway (the delete list may differ from what actually happens)",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
|
||||
"암호로 묶기": "Make password ZIP",
|
||||
|
|
@ -2036,6 +2040,7 @@
|
|||
"이 언어 지우기": "Remove this language",
|
||||
"이 연결은 서명도 암호화도 되지 않습니다 ({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}",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · 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?",
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@
|
|||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
||||
"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 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
|
||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?",
|
||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
|
||||
|
|
@ -1315,6 +1316,7 @@
|
|||
"받다 만 AI 모델": "ダウンロード途中の AI モデル",
|
||||
"받다 만 다운로드": "ダウンロード途中のファイル",
|
||||
"받다 만 조각 · {0}": "途中までのダウンロード · {0}",
|
||||
"받다 만 파일 {0}개도 지워집니다": "途中までの受信ファイル {0} 件も削除されます",
|
||||
"받아서 열기": "ダウンロードして開く",
|
||||
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "ダウンロードした AI モデル ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
|
||||
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "ダウンロードした AI モデル ({0}) を Download/JJ_MKVMaker にコピーし、再インストール後に復元する (一時的に {0} 余分に必要)",
|
||||
|
|
@ -1530,6 +1532,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "サーバー ({0}) との接続が切れました",
|
||||
"서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "サーバー一覧 (WebDAV・SFTP・SMB・FTP) とそのパスワード・API キー、ファイルエクスプローラーのお気に入りも消えます。動画リスト・ウェブのお気に入り・ファイルはそのままです",
|
||||
"서버 설정 고치기": "サーバー設定を修正",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定と、この端末に保存されたパスワード・秘密鍵・信頼したサーバー鍵を削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再登録するまで接続されません。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "サーバー設定と、この端末に保存したパスワード · 信頼した証明書を削除します。サーバーのファイルはそのままです。",
|
||||
|
|
@ -1727,6 +1730,7 @@
|
|||
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "理解した上で実行します (削除リストが実際と異なる場合があります)",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
|
||||
"암호로 묶기": "パスワード付きでまとめる",
|
||||
|
|
@ -2036,6 +2040,7 @@
|
|||
"이 언어 지우기": "この言語を削除",
|
||||
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
|
||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
|
||||
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
||||
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@
|
|||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。",
|
||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?",
|
||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。",
|
||||
|
|
@ -1315,6 +1316,7 @@
|
|||
"받다 만 AI 모델": "未下载完的 AI 模型",
|
||||
"받다 만 다운로드": "未完成的下载",
|
||||
"받다 만 조각 · {0}": "未完成的下载 · {0}",
|
||||
"받다 만 파일 {0}개도 지워집니다": "{0} 个未完成的文件也会被删除",
|
||||
"받아서 열기": "下载后打开",
|
||||
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "已下载的 AI 模型 ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
|
||||
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "将已下载的 AI 模型 ({0}) 复制到 Download/JJ_MKVMaker,重新安装后恢复 (暂时需要额外 {0})",
|
||||
|
|
@ -1530,6 +1532,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "与服务器 ({0}) 的连接已断开",
|
||||
"서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "服务器列表 (WebDAV · SFTP · SMB · FTP) 及其密码 · API 密钥、文件管理器收藏也会删除。视频列表 · 网页书签 · 文件保持不变",
|
||||
"서버 설정 고치기": "修改服务器设置",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置以及本设备保存的密码、私钥和已信任的服务器密钥。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加前无法连接。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "删除服务器设置以及本设备上保存的密码和已信任的证书。服务器上的文件不受影响。",
|
||||
|
|
@ -1727,6 +1730,7 @@
|
|||
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "我已了解并执行 (删除列表可能与实际不同)",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
|
||||
"암호로 묶기": "加密压缩",
|
||||
|
|
@ -2036,6 +2040,7 @@
|
|||
"이 언어 지우기": "删除此语言",
|
||||
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
|
||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
|
||||
"이 이동 버튼 지우기": "删除此移动按钮",
|
||||
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,76 @@ void main() {
|
|||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('177: 옵션에 --exclude 가 있으면 실행 전 비교 목록도 실제 rsync 와 같게 (뺀 것 · 받는 쪽의 뺀 것은 지우지 않음)', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsyncexcl_');
|
||||
final a = p.join(tmp.path, 'exSrc'), b = p.join(tmp.path, 'exDst');
|
||||
void f(String dir, String rel) => File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
for (final r in ['a.txt', 'b.log', 'build/x.o', 'sub/c.txt', 'sub/deep/d.txt']) {
|
||||
f(a, r);
|
||||
}
|
||||
for (final r in ['extra.txt', 'keep.log', 'build/old.o']) {
|
||||
f(b, r);
|
||||
}
|
||||
await tester.runAsync(findBundledRsync);
|
||||
final c = AppController(PlatformServices.create());
|
||||
c.settings
|
||||
..rsyncPaths = [tmp.path, tmp.path]
|
||||
..rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/';
|
||||
final key = GlobalKey();
|
||||
tester.view.physicalSize = const Size(1500, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
Future<void> wait(int ms) async {
|
||||
for (var i = 0; i < ms ~/ 100; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
builder: (_, child) => RepaintBoundary(key: key, child: child),
|
||||
home: ExplorerPage(c: c, rsync: true),
|
||||
));
|
||||
await wait(2500);
|
||||
await tester.tap(find.text('exSrc').first);
|
||||
await wait(600);
|
||||
await tester.tap(find.text('exDst').last);
|
||||
await wait(600);
|
||||
await tester.tap(find.text('좌 → 우'));
|
||||
await wait(2500); // 비교
|
||||
Finder inDialog(String t) => find.descendant(of: find.byType(Dialog), matching: find.textContaining(t));
|
||||
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = await tester.runAsync(() => boundary.toImage());
|
||||
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
|
||||
File('$out/rsync_exclude_confirm.png').writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
// 비교 목록: 보낼 것 · 지울 것만 (뺀 것은 없음)
|
||||
expect(inDialog('a.txt'), findsWidgets);
|
||||
expect(inDialog('c.txt'), findsWidgets);
|
||||
expect(inDialog('extra.txt'), findsWidgets, reason: '--delete 로 지워짐');
|
||||
for (final t in ['b.log', 'keep.log', 'x.o', 'old.o', 'd.txt']) {
|
||||
expect(inDialog(t), findsNothing, reason: '$t 는 걸러짐 (실제 rsync 도 건드리지 않음)');
|
||||
}
|
||||
await tester.tap(find.text('실행'));
|
||||
for (var i = 0; i < 100 && File(p.join(b, 'extra.txt')).existsSync(); i++) {
|
||||
await wait(200);
|
||||
}
|
||||
await wait(1500);
|
||||
// 실제 rsync 결과가 비교 목록과 같다
|
||||
expect(File(p.join(b, 'a.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(b, 'sub', 'c.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(b, 'extra.txt')).existsSync(), isFalse);
|
||||
expect(File(p.join(b, 'b.log')).existsSync(), isFalse);
|
||||
expect(File(p.join(b, 'keep.log')).existsSync(), isTrue, reason: '받는 쪽의 뺀 것은 지우지 않음');
|
||||
expect(File(p.join(b, 'build', 'old.o')).existsSync(), isTrue);
|
||||
expect(File(p.join(b, 'build', 'x.o')).existsSync(), isFalse);
|
||||
expect(Directory(p.join(b, 'sub', 'deep')).existsSync(), isFalse);
|
||||
tester.view.reset();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = testTempDir('jj_rsyncmove_');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
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/master_lock.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/services/secret_store.dart';
|
||||
import 'package:jj_mkvmaker/ui/master_prompt.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 145 (관리자 지시 10/19): 124 초기화 창 (최상 비밀번호 - 초기화 · 정말 초기화할까요? · 마스터를 잊었을 때) 을
|
||||
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 아무것도 지우지 않는다 (가짜 저장소, [초기화] 는 누르지 않음).
|
||||
/// 실행: flutter test integration_test\windows_reset_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_reset');
|
||||
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 lock = MasterLock(MemorySecretStore());
|
||||
await t.runAsync(() async {
|
||||
await lock.load();
|
||||
await lock.setMaster('시험-마스터');
|
||||
});
|
||||
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())),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
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, 'reset_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle();
|
||||
}
|
||||
|
||||
// ① 최상 비밀번호 - 초기화 (무엇을 초기화할지) - 셋 다 체크해 긴 설명까지 보이게
|
||||
unawaited(showSuperReset(navKey.currentContext!, c, lock));
|
||||
await settle();
|
||||
await shot('1_super');
|
||||
for (final cb in find.byType(CheckboxListTile).evaluate().toList()) {
|
||||
final tile = cb.widget as CheckboxListTile;
|
||||
if (tile.value != true && tile.onChanged != null) tile.onChanged!(true);
|
||||
}
|
||||
await settle();
|
||||
await shot('2_super_all');
|
||||
// ② 정말 초기화할까요? (한 번 더 확인) - 여기서는 [취소]
|
||||
await t.tap(find.byType(FilledButton).last);
|
||||
await settle();
|
||||
await shot('3_sure');
|
||||
await closeAll();
|
||||
|
||||
// ③ 마스터를 잊었을 때 (저장된 비밀번호를 지우고 마스터 초기화) - 확인 창만
|
||||
unawaited(forgotMaster(navKey.currentContext!, c, lock));
|
||||
await settle();
|
||||
await shot('4_forgot');
|
||||
await closeAll();
|
||||
expect(lock.hasMaster, isTrue, reason: '찍기만 하고 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import 'dart:convert' show utf8;
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'sync_tools.dart' show splitOptions;
|
||||
|
||||
/// 177: rsync 옵션의 거르기 규칙 (--exclude · --include · --exclude-from · --include-from · --filter "- …" / "+ …")
|
||||
/// 을 실행 전 비교 (미리 보기 · 지울 목록) 에도 똑같이 적용한다. 예전에는 미리 보기가 이 규칙을 보지 않아,
|
||||
/// 빼기로 한 폴더가 "새로" · "지워짐" 으로 나왔다 (실제 rsync 는 따름).
|
||||
///
|
||||
/// rsync 의 규칙 (man rsync "FILTER RULES") 그대로:
|
||||
/// - 위에서부터 처음 맞는 규칙이 이긴다. 맞는 것이 없으면 포함.
|
||||
/// - `*` = `/` 를 뺀 아무 글자, `**` = `/` 까지 아무 글자, `?` = `/` 가 아닌 한 글자, `[…]` = 글자 묶음.
|
||||
/// - 끝이 `/` 면 폴더에만 맞는다. 앞이 `/` 면 옮기는 맨 위 (원본/ 의 안) 기준.
|
||||
/// - `/` (끝 빼고) 나 `**` 가 있으면 경로 전체의 끝부분에, 없으면 이름에만 맞춘다.
|
||||
/// - `이름/***` 은 그 폴더와 안의 모든 것.
|
||||
/// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다).
|
||||
/// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다).
|
||||
class RsyncFilter {
|
||||
final List<RsyncFilterRule> rules;
|
||||
|
||||
/// --delete-excluded: 받는 쪽의 거른 것도 지운다 (--delete 와 함께일 때)
|
||||
final bool deleteExcluded;
|
||||
|
||||
/// 미리 보기가 따르지 못하는 거르기 옵션 (-C · -F · merge 규칙 · 읽지 못한 파일) - 화면에서 알릴 것
|
||||
final List<String> unsupported;
|
||||
|
||||
const RsyncFilter(this.rules, {this.deleteExcluded = false, this.unsupported = const []});
|
||||
|
||||
static const none = RsyncFilter([]);
|
||||
|
||||
bool get isEmpty => rules.isEmpty && !deleteExcluded;
|
||||
|
||||
/// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈)
|
||||
factory RsyncFilter.parse(String options, {List<String>? Function(String path)? readLines}) {
|
||||
final read = readLines ?? _readLines;
|
||||
final t = splitOptions(options);
|
||||
final rules = <RsyncFilterRule>[];
|
||||
final unsupported = <String>[];
|
||||
var deleteExcluded = false;
|
||||
void fromFile(String path, bool include) {
|
||||
final lines = read(path);
|
||||
if (lines == null) {
|
||||
unsupported.add('${include ? '--include-from' : '--exclude-from'}=$path');
|
||||
return;
|
||||
}
|
||||
for (final l in lines) {
|
||||
final s = l.trimRight();
|
||||
if (s.isEmpty || s.startsWith('#') || s.startsWith(';')) continue;
|
||||
// 15 검토 F3: "!" (목록 지우기) · 다른 규칙 머리 (P · H · R · S · 고치개 · merge) 는 미리 볼 수 없다 - 적용하지 않고 알린다
|
||||
if (s == '!' || RegExp(r'^([PHRS]|[-+],[^ ]*|[.:]|merge|dir-merge)(\s|$)').hasMatch(s)) {
|
||||
unsupported.add('${include ? '--include-from' : '--exclude-from'}: $s');
|
||||
continue;
|
||||
}
|
||||
// 파일 안에서도 "+ " · "- " 머리를 따른다
|
||||
if (s.startsWith('+ ')) {
|
||||
rules.add(RsyncFilterRule(s.substring(2), include: true));
|
||||
} else if (s.startsWith('- ')) {
|
||||
rules.add(RsyncFilterRule(s.substring(2), include: false));
|
||||
} else {
|
||||
rules.add(RsyncFilterRule(s, include: include));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
if (m == null) {
|
||||
unsupported.add('--filter=$r');
|
||||
return;
|
||||
}
|
||||
rules.add(RsyncFilterRule(m.group(2)!, include: m.group(1) == '+' || m.group(1) == 'include'));
|
||||
}
|
||||
|
||||
for (var i = 0; i < t.length; i++) {
|
||||
final o = t[i];
|
||||
String? value(String name) {
|
||||
if (o.startsWith('$name=')) return o.substring(name.length + 1);
|
||||
if (o == name && i + 1 < t.length) return t[++i];
|
||||
return null;
|
||||
}
|
||||
|
||||
String? v;
|
||||
if ((v = value('--exclude')) != null) {
|
||||
rules.add(RsyncFilterRule(v!, include: false));
|
||||
} else if ((v = value('--include')) != null) {
|
||||
rules.add(RsyncFilterRule(v!, include: true));
|
||||
} else if ((v = value('--exclude-from')) != null) {
|
||||
fromFile(v!, false);
|
||||
} else if ((v = value('--include-from')) != null) {
|
||||
fromFile(v!, true);
|
||||
} else if ((v = value('--filter')) != null) {
|
||||
filterRule(v!);
|
||||
} else if (o == '-f' && i + 1 < t.length) {
|
||||
filterRule(t[++i]);
|
||||
} else if (o == '--delete-excluded') {
|
||||
deleteExcluded = true;
|
||||
} else if (o == '--cvs-exclude' || o == '-F' || (o.startsWith('-') && !o.startsWith('--') && (o.contains('C') || o.contains('F')))) {
|
||||
unsupported.add(o);
|
||||
}
|
||||
}
|
||||
return RsyncFilter(rules, deleteExcluded: deleteExcluded, unsupported: unsupported);
|
||||
}
|
||||
|
||||
static List<String>? _readLines(String path) {
|
||||
// Windows 의 rsync (MSYS2) 는 /cygdrive/c/… 로 적어야 읽는다 - 같은 파일을 여기서도 읽게
|
||||
final m = RegExp(r'^/cygdrive/([a-zA-Z])(/.*)?$').firstMatch(path);
|
||||
if (m != null && Platform.isWindows) path = '${m[1]!.toUpperCase()}:${m[2] ?? '/'}';
|
||||
try {
|
||||
return File(path).readAsLinesSync();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 걸러지는지
|
||||
bool excluded(String rel, {required bool isDir}) {
|
||||
for (final r in rules) {
|
||||
if (r.matches(rel, isDir: isDir)) return !r.include;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class RsyncFilterRule {
|
||||
final String pattern;
|
||||
final bool include;
|
||||
late final bool _dirOnly;
|
||||
late final RegExp _re;
|
||||
|
||||
RsyncFilterRule(this.pattern, {required this.include}) {
|
||||
var p = pattern;
|
||||
var all = false; // "이름/***" = 그 폴더와 안의 모든 것
|
||||
if (p.endsWith('/***')) {
|
||||
all = true;
|
||||
p = p.substring(0, p.length - 4);
|
||||
_dirOnly = false;
|
||||
} else {
|
||||
_dirOnly = p.endsWith('/') && p.length > 1;
|
||||
if (_dirOnly) p = p.substring(0, p.length - 1);
|
||||
}
|
||||
final anchored = p.startsWith('/');
|
||||
if (anchored) p = p.substring(1);
|
||||
final whole = anchored || p.contains('/') || p.contains('**');
|
||||
// rsync 의 wildmatch 는 바이트로 맞춘다 - 패턴 · 이름을 UTF-8 바이트 (한 글자 = 한 바이트) 로 바꿔 맞춘다
|
||||
final body = _glob(_bytes(p)) + (all ? '(?:/.*)?' : '');
|
||||
_re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$');
|
||||
_whole = whole;
|
||||
}
|
||||
|
||||
late final bool _whole;
|
||||
|
||||
bool matches(String rel, {required bool isDir}) {
|
||||
if (_dirOnly && !isDir) return false;
|
||||
if (_whole) return _re.hasMatch(_bytes(rel));
|
||||
final name = rel.contains('/') ? rel.substring(rel.lastIndexOf('/') + 1) : rel;
|
||||
return _re.hasMatch(_bytes(name));
|
||||
}
|
||||
|
||||
/// UTF-8 바이트를 한 글자씩 (0~255) 으로 - 정규식이 바이트 단위로 맞추게
|
||||
static String _bytes(String s) => String.fromCharCodes(utf8.encode(s));
|
||||
|
||||
/// rsync 의 wildmatch 를 정규식으로
|
||||
static String _glob(String p) {
|
||||
final b = StringBuffer();
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
final c = p[i];
|
||||
if (c == '*') {
|
||||
if (i + 1 < p.length && p[i + 1] == '*') {
|
||||
i++;
|
||||
while (i + 1 < p.length && p[i + 1] == '*') {
|
||||
i++;
|
||||
}
|
||||
// rsync wildmatch: 패턴 맨 앞의 "**/" 는 폴더 0개에도 맞는다 ("**/deep/**" 가 맨 위 deep/x 에도).
|
||||
// 가운데의 "/**/" 는 폴더 하나 이상 ("foo/**/bar" 는 foo/bar 에 맞지 않음 - 실제 rsync 확인)
|
||||
if (b.isEmpty && i + 1 < p.length && p[i + 1] == '/') {
|
||||
b.write('(?:.*/)?');
|
||||
i++;
|
||||
} else {
|
||||
b.write('.*');
|
||||
}
|
||||
} else {
|
||||
b.write('[^/]*');
|
||||
}
|
||||
} else if (c == '?') {
|
||||
b.write('[^/]');
|
||||
} else if (c == '[') {
|
||||
final end = p.indexOf(']', i + 2);
|
||||
if (end < 0) {
|
||||
b.write(r'\[');
|
||||
continue;
|
||||
}
|
||||
var set = p.substring(i + 1, end);
|
||||
if (set.startsWith('!')) set = '^${set.substring(1)}';
|
||||
b.write('[${set.replaceAll(r'\', r'\\')}]');
|
||||
i = end;
|
||||
} else if (c == r'\' && i + 1 < p.length) {
|
||||
b.write(RegExp.escape(p[++i]));
|
||||
} else {
|
||||
b.write(RegExp.escape(c));
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io' show FileSystemEntity;
|
||||
|
||||
import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException;
|
||||
import 'rsync_filter.dart';
|
||||
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption;
|
||||
|
||||
export 'sync_tools.dart' show isSourceRemovingOption;
|
||||
|
|
@ -110,10 +111,39 @@ bool optionsUpdate(String options) =>
|
|||
/// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다.
|
||||
/// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상").
|
||||
/// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)",
|
||||
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다
|
||||
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다.
|
||||
/// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다).
|
||||
/// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움)
|
||||
Future<List<PreviewItem>> previewSync(String src, String dst,
|
||||
{required bool toRight, bool delete = false, bool update = false, bool skipLinks = false, int limit = 2000}) async {
|
||||
{required bool toRight,
|
||||
bool delete = false,
|
||||
bool update = false,
|
||||
bool skipLinks = false,
|
||||
RsyncFilter filter = RsyncFilter.none,
|
||||
int limit = 2000}) async {
|
||||
final out = <PreviewItem>[];
|
||||
// 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김)
|
||||
Future<void> deleteInside(String dir, String rel) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final x in items) {
|
||||
if (out.length >= limit) return;
|
||||
final name = vBasename(x.path);
|
||||
if (isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.excluded(r, isDir: x.isDir)) continue;
|
||||
if (x.isDir && await _hasExcluded(x.path, r, filter)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: x.isDir, size: x.size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> walk(String s, String d, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
|
||||
|
|
@ -135,8 +165,10 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
if (out.length >= limit) return;
|
||||
final name = vBasename(e.path);
|
||||
if (isPartialFile(name) || isAppTempName(name)) continue;
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 177: 거른 것은 보내지 않는다 - 받는 쪽의 같은 이름은 아래에서 (지우지 않음 / --delete-excluded)
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue;
|
||||
names.add(name);
|
||||
final t = there[name];
|
||||
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) {
|
||||
if (skipLinks) {
|
||||
|
|
@ -176,8 +208,23 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
}
|
||||
for (final e in there.values) {
|
||||
final name = vBasename(e.path);
|
||||
if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue;
|
||||
out.add(PreviewItem(rel.isEmpty ? name : '$rel/$name', delete ? PreviewAction.delete : PreviewAction.onlyTarget,
|
||||
// 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 는 그것을 지키느라 폴더를 남기고
|
||||
// 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만
|
||||
if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) {
|
||||
await deleteInside(e.path, r);
|
||||
continue;
|
||||
}
|
||||
out.add(PreviewItem(r, delete ? PreviewAction.delete : PreviewAction.onlyTarget,
|
||||
toRight: toRight, isDir: e.isDir, size: e.size));
|
||||
}
|
||||
}
|
||||
|
|
@ -186,12 +233,31 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
return out;
|
||||
}
|
||||
|
||||
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다)
|
||||
Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
for (final x in items) {
|
||||
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 (x.isDir && await _hasExcluded(x.path, r, filter)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고,
|
||||
/// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만).
|
||||
/// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)
|
||||
Future<List<PreviewItem>> previewBoth(String left, String right, {bool delete = false, int limit = 2000}) async {
|
||||
final a = await previewSync(left, right, toRight: true, update: true, delete: delete, limit: limit);
|
||||
final b = await previewSync(right, left, toRight: false, update: true, limit: limit);
|
||||
Future<List<PreviewItem>> previewBoth(String left, String right,
|
||||
{bool delete = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async {
|
||||
final a = await previewSync(left, right, toRight: true, update: true, delete: delete, filter: filter, limit: limit);
|
||||
final b = await previewSync(right, left, toRight: false, update: true, filter: filter, limit: limit);
|
||||
final gone = {for (final x in a) if (x.action == PreviewAction.delete) x.rel};
|
||||
final seen = <String>{};
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 204 (사용자 지적 10/11): Android 브라우저에서 화면 아래를 위로 밀면 아래에서 검은 화면이 올라왔다.
|
||||
/// 제스처 내비 · 삼성 태블릿의 작업 표시줄은 아래에서 밀면 잠깐 나타나고, 그동안 아래 시스템 막대 높이 (viewPadding) 가 커진다.
|
||||
/// 앱 맨 위의 SafeArea 가 그만큼 앱 전체를 줄여 그 자리에 어두운 바탕이 보이고, 텍스처로 그리는 웹 뷰는
|
||||
/// 새 크기로 다시 그릴 때까지 늦어 더 검게 보였다.
|
||||
///
|
||||
/// 이 위젯 안의 [child] (웹 뷰) 는 그렇게 잠깐 커진 아래 막대 때문에는 크기를 바꾸지 않는다: 원래 크기 그대로 두고
|
||||
/// 줄어든 만큼 아래로 넘쳐 (자르지 않고) 그 띠 위에 그린다. 막대가 들어가면 그대로 원래 자리.
|
||||
/// - 키보드가 열리면 (viewInsets) 지금처럼 줄인다 - 입력 칸이 키보드에 가리지 않게.
|
||||
/// - 커진 막대가 [settle] 넘게 그대로면 (작업 표시줄을 고정하는 등) 그 크기를 새 기준으로 받아들인다.
|
||||
/// - 폭이 바뀌면 (회전 · 창 크기) 기준을 다시 잡는다.
|
||||
class BottomInsetHold extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Duration settle;
|
||||
const BottomInsetHold({super.key, required this.child, this.settle = const Duration(seconds: 3)});
|
||||
|
||||
@override
|
||||
State<BottomInsetHold> createState() => _BottomInsetHoldState();
|
||||
}
|
||||
|
||||
class _BottomInsetHoldState extends State<BottomInsetHold> {
|
||||
/// 이 폭에서 기준으로 삼는 아래 시스템 막대 높이 (논리 픽셀)
|
||||
double? _base;
|
||||
double _width = -1;
|
||||
Timer? _accept;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accept?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||
// 앱 위 SafeArea 가 MediaQuery 의 여백을 지우므로 창 값을 직접 본다. 막대가 바뀌면 SafeArea 가 크기를 바꿔
|
||||
// 이 builder 가 다시 불리므로 여기서 읽는다 (밖에서 읽으면 예전 값)
|
||||
final view = View.of(context);
|
||||
final dpr = view.devicePixelRatio;
|
||||
final pad = view.viewPadding.bottom / dpr;
|
||||
final keyboard = view.viewInsets.bottom / dpr;
|
||||
if (box.maxWidth != _width) {
|
||||
_width = box.maxWidth;
|
||||
_base = pad;
|
||||
}
|
||||
if (pad <= _base!) {
|
||||
_base = pad;
|
||||
_accept?.cancel();
|
||||
_accept = null;
|
||||
}
|
||||
final extra = keyboard > 0 ? 0.0 : pad - _base!;
|
||||
if (extra <= 0 || !box.hasBoundedHeight) return widget.child;
|
||||
// 오래 그대로면 새 기준으로 (한 번 다시 배치)
|
||||
_accept ??= Timer(widget.settle, () {
|
||||
if (!mounted) return;
|
||||
setState(() => _base = View.of(context).viewPadding.bottom / View.of(context).devicePixelRatio);
|
||||
_accept = null;
|
||||
});
|
||||
final h = box.maxHeight + extra;
|
||||
return OverflowBox(alignment: Alignment.topCenter, minHeight: h, maxHeight: h, child: widget.child);
|
||||
});
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import 'key_hint.dart';
|
|||
import 'settings_page.dart';
|
||||
import 'tor_page.dart';
|
||||
import 'theme.dart';
|
||||
import 'bottom_inset_hold.dart';
|
||||
import 'work_panel.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
|
|
@ -1012,6 +1013,9 @@ class _SplitHandle extends StatelessWidget {
|
|||
|
||||
// ───────── 앱 안 Edge 웹뷰 ─────────
|
||||
|
||||
/// 204: Android 만 (Windows 창에는 아래 시스템 막대가 없음)
|
||||
Widget _holdBottom(Widget webView) => Platform.isAndroid ? BottomInsetHold(child: webView) : webView;
|
||||
|
||||
/// Edge · Android 웹뷰 설정 (처음 만들 때와 JavaScript 켜기 · 끄기에 같이 쓴다)
|
||||
InAppWebViewSettings _webSettings({required bool javaScript}) => _JjWebSettings(javaScript: javaScript);
|
||||
|
||||
|
|
@ -1125,7 +1129,8 @@ class _EdgeViewState extends State<_EdgeView> with WebProxyVisibility {
|
|||
} catch (_) {}
|
||||
}
|
||||
|
||||
return InAppWebView(
|
||||
// 204: 아래 시스템 막대가 잠깐 커질 때 (제스처 · 작업 표시줄) 웹 뷰 크기를 바꾸지 않게 (검은 띠가 올라오던 것)
|
||||
return _holdBottom(InAppWebView(
|
||||
keepAlive: _keepAlive,
|
||||
webViewEnvironment: snap.data,
|
||||
initialUrlRequest: URLRequest(url: WebUri(normalizeAddress(widget.initialUrl))),
|
||||
|
|
@ -1170,7 +1175,7 @@ class _EdgeViewState extends State<_EdgeView> with WebProxyVisibility {
|
|||
onReceivedError: (c, req, err) {
|
||||
if (req.isForMainFrame ?? true) h.onMainFrameError?.call(req.url.toString(), err.description);
|
||||
},
|
||||
);
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import 'folder_picker.dart' show pickFolderOrDav;
|
|||
import 'trash_page.dart';
|
||||
import 'dav_external.dart';
|
||||
import '../platform/windows/recycle_bin.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
import '../core/sync_preview.dart';
|
||||
import 'path_label.dart';
|
||||
import 'sync_preview_view.dart';
|
||||
|
|
@ -3413,6 +3414,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
// 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에)
|
||||
List<PreviewItem>? preview;
|
||||
Object? previewError;
|
||||
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
|
||||
var filterAck = false;
|
||||
List<String> filterLimits() => _viaDav([l, r]) ? const [] : {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
|
||||
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
|
||||
bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
|
||||
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
|
||||
|
|
@ -3488,11 +3492,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
],
|
||||
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
|
||||
const SizedBox(height: 8),
|
||||
if (filterLimits().isNotEmpty)
|
||||
FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)),
|
||||
SyncPreviewView(
|
||||
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
|
||||
key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')),
|
||||
// 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음)
|
||||
compute: () async => both
|
||||
? previewBoth(l, r, delete: deletes())
|
||||
? previewBoth(l, r, delete: deletes(), filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(tasks.first)))
|
||||
: [
|
||||
for (final t in tasks)
|
||||
...await previewSync(t.sources.first, t.dest,
|
||||
|
|
@ -3500,7 +3507,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
delete: deletes(),
|
||||
// 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다
|
||||
update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer),
|
||||
skipLinks: _viaDav([l, r])),
|
||||
skipLinks: _viaDav([l, r]),
|
||||
filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(t))),
|
||||
],
|
||||
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
|
||||
onStart: () => set(() {
|
||||
|
|
@ -3550,7 +3558,9 @@ 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) ? null : () => Navigator.pop(ctx, true),
|
||||
onPressed: nestedDelete() || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck)
|
||||
? null
|
||||
: () => Navigator.pop(ctx, true),
|
||||
// 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨).
|
||||
// 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다
|
||||
child: Text(removeSource ? tr('이동') : tr('실행')),
|
||||
|
|
|
|||
|
|
@ -197,13 +197,15 @@ Future<void> showSuperReset(BuildContext context, AppController c, MasterLock lo
|
|||
value: secrets,
|
||||
onChanged: (v) => set(() => secrets = v!),
|
||||
title: Text(tr('저장된 실제 비밀번호 · API 키')),
|
||||
subtitle: Text(tr('WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다')),
|
||||
// 145: 10/18 에 생긴 SFTP · SMB · FTP 비밀번호 · 키도 지운다 (clearStoredSecrets)
|
||||
subtitle: Text(tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다')),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: settings,
|
||||
onChanged: (v) => set(() => settings = v!),
|
||||
title: Text(tr('앱 설정 전체 (처음 상태로)')),
|
||||
subtitle: Text(tr('WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다')),
|
||||
// 145: 파일 탐색기 즐겨찾기 (73) 는 설정 안에 있어 함께 지워진다 - 예전 글은 "즐겨찾기는 그대로"
|
||||
subtitle: Text(tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/settings.dart' show CopyTask;
|
||||
import '../core/file_ops.dart' show formatSize;
|
||||
import '../core/file_ops.dart' show formatSize, isPartialFile;
|
||||
import '../core/rsync_filter.dart';
|
||||
import '../core/sync_preview.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
|
|
@ -97,6 +98,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
|
|||
int n(PreviewAction a, [bool? right]) =>
|
||||
items.where((x) => x.action == a && (right == null || x.toRight == right)).length;
|
||||
final dels = n(PreviewAction.delete);
|
||||
final partials = items.where((x) => x.action == PreviewAction.delete && isPartialFile(x.rel)).length;
|
||||
final overwrites = n(PreviewAction.update);
|
||||
final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length;
|
||||
final skipped = n(PreviewAction.skipNewer);
|
||||
|
|
@ -125,6 +127,10 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
|
|||
if (dels > 0)
|
||||
Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로
|
||||
if (partials > 0)
|
||||
Text(trf('받다 만 파일 {0}개도 지워집니다', [partials]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||||
// 211: 내용이 다른 같은 이름은 받는 쪽 파일이 바뀐다 - 목록의 "~" 만으로는 알아채기 어려웠다
|
||||
if (overwrites > 0)
|
||||
Text(trf('받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)', [overwrites]),
|
||||
|
|
@ -210,6 +216,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
if (!deletes && !moves) return true;
|
||||
List<PreviewItem>? preview;
|
||||
Object? error;
|
||||
var filterAck = false;
|
||||
final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav);
|
||||
// robocopy 는 /XO (옵션 칸 또는 환경 설정) 가 -u 와 같다
|
||||
final robo = t.method == 'robocopy' && !direct;
|
||||
|
|
@ -217,6 +224,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO')
|
||||
: optionsUpdate(t.options) || (direct && skipNewer);
|
||||
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
|
||||
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
|
||||
final filter = t.method == 'rsync' && !direct ? RsyncFilter.parse(t.options) : RsyncFilter.none;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -251,11 +260,15 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
|
||||
if (filter.unsupported.isNotEmpty)
|
||||
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
|
||||
SyncPreviewView(
|
||||
compute: () async => [
|
||||
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update,
|
||||
// robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면)
|
||||
skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options)))),
|
||||
skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options))),
|
||||
filter: filter),
|
||||
],
|
||||
onStart: () => set(() {
|
||||
preview = null;
|
||||
|
|
@ -287,7 +300,7 @@ 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 ? null : () => Navigator.pop(ctx, true),
|
||||
onPressed: preview == null || (deletes && filter.unsupported.isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true),
|
||||
child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게)
|
||||
),
|
||||
],
|
||||
|
|
@ -297,3 +310,28 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
);
|
||||
return ok == true;
|
||||
}
|
||||
|
||||
/// 15 검토 F2 (177): rsync 옵션에 미리 볼 수 없는 거르기 규칙 (고치개 · H · P · merge · -F · -C · 읽지 못한 파일 등) 이 있으면
|
||||
/// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게)
|
||||
class FilterLimitNote extends StatelessWidget {
|
||||
final List<String> unsupported;
|
||||
final bool deletes;
|
||||
final bool ack;
|
||||
final ValueChanged<bool> onAck;
|
||||
const FilterLimitNote({super.key, required this.unsupported, required this.deletes, required this.ack, required this.onAck});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(trf('이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}', [unsupported.join(' · ')]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
if (deletes)
|
||||
CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: ack,
|
||||
onChanged: (v) => onAck(v ?? false),
|
||||
title: Text(tr('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'), style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/ui/bottom_inset_hold.dart';
|
||||
|
||||
/// 204: 아래 시스템 막대 (제스처 · 작업 표시줄) 가 잠깐 커져도 웹 뷰 자리의 크기는 그대로 (검은 띠가 올라오지 않게)
|
||||
void main() {
|
||||
const box = Key('web');
|
||||
Future<void> app(WidgetTester t) async {
|
||||
t.view.physicalSize = const Size(800, 1000);
|
||||
t.view.devicePixelRatio = 1;
|
||||
t.view.padding = const FakeViewPadding(bottom: 20);
|
||||
t.view.viewPadding = const FakeViewPadding(bottom: 20);
|
||||
addTearDown(t.view.reset);
|
||||
// 앱처럼: 맨 위 SafeArea (아래 막대만큼 앱을 줄임) 안에 웹 뷰 자리
|
||||
await t.pumpWidget(const MaterialApp(
|
||||
home: SafeArea(
|
||||
child: Column(children: [
|
||||
SizedBox(height: 100),
|
||||
Expanded(child: BottomInsetHold(child: ColoredBox(key: box, color: Colors.white))),
|
||||
]),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
double h(WidgetTester t) => t.getSize(find.byKey(box)).height;
|
||||
|
||||
testWidgets('아래 막대가 잠깐 커지면 크기 그대로 (넘쳐서 그 띠 위에) · 들어가면 그대로 원래', (t) async {
|
||||
await app(t);
|
||||
expect(h(t), 1000 - 20 - 100);
|
||||
// 작업 표시줄이 나타남 (아래 막대 20 → 76)
|
||||
t.view.padding = const FakeViewPadding(bottom: 76);
|
||||
t.view.viewPadding = const FakeViewPadding(bottom: 76);
|
||||
await t.pump();
|
||||
expect(h(t), 880, reason: '웹 뷰 크기를 바꾸지 않음');
|
||||
expect(t.getRect(find.byKey(box)).bottom, 980, reason: '줄어든 자리 아래로 넘쳐 그 띠 위에 그림');
|
||||
// 들어감
|
||||
t.view.padding = const FakeViewPadding(bottom: 20);
|
||||
t.view.viewPadding = const FakeViewPadding(bottom: 20);
|
||||
await t.pump(const Duration(seconds: 5));
|
||||
expect(h(t), 880);
|
||||
});
|
||||
|
||||
testWidgets('커진 막대가 3초 넘게 그대로면 (작업 표시줄 고정 등) 새 크기로', (t) async {
|
||||
await app(t);
|
||||
t.view.padding = const FakeViewPadding(bottom: 76);
|
||||
t.view.viewPadding = const FakeViewPadding(bottom: 76);
|
||||
await t.pump();
|
||||
expect(h(t), 880);
|
||||
await t.pump(const Duration(seconds: 3));
|
||||
await t.pump();
|
||||
expect(h(t), 1000 - 76 - 100);
|
||||
});
|
||||
|
||||
testWidgets('키보드가 열리면 지금처럼 줄인다 (입력 칸이 가리지 않게)', (t) async {
|
||||
await app(t);
|
||||
t.view.padding = const FakeViewPadding(bottom: 0);
|
||||
t.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await t.pump();
|
||||
// SafeArea 는 키보드만큼 줄이지 않으므로 (Scaffold 가 한다) 여기서는 아래 여백이 0 이 된 만큼만 - 넘치지 않음
|
||||
expect(h(t), 1000 - 100);
|
||||
expect(t.getRect(find.byKey(box)).bottom, 1000);
|
||||
});
|
||||
}
|
||||
|
|
@ -219,6 +219,38 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
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'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('a');
|
||||
File(p.join(dst, 'old.txt'))
|
||||
..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"');
|
||||
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);
|
||||
FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행'));
|
||||
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
|
||||
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));
|
||||
await tester.pump();
|
||||
expect(run().onPressed, isNotNull);
|
||||
await tester.tap(find.text('취소').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(r, isFalse);
|
||||
// 따를 수 있는 규칙만 있으면 경고 · 체크 없음
|
||||
final ok = CopyTask(id: '2', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --exclude=*.log');
|
||||
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, ok));
|
||||
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
|
||||
expect(find.textContaining('미리 볼 수 없습니다'), findsNothing);
|
||||
expect(run().onPressed, isNotNull);
|
||||
await tester.tap(find.text('취소').last);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('236: 모니터링 카드 [실행] 뒤 옵션 칸에 포커스가 남지 않는다 (키보드가 저절로 올라와 끝 알림을 가리지 않게)', skip: !Platform.isWindows, (tester) async {
|
||||
final src = p.join(tmp.path, 'cp'), dst = p.join(tmp.path, 'cpd');
|
||||
File(p.join(src, 'a.txt'))
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import 'package:jj_mkvmaker/ui/storage_page.dart';
|
|||
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;
|
||||
|
||||
/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게)
|
||||
class _Shell extends NoopShell {
|
||||
|
|
@ -1319,6 +1320,39 @@ void main() {
|
|||
await calm(tester);
|
||||
});
|
||||
|
||||
testWidgets('83 (28 지적): Total Commander 모양 F8 = 지우기 - 확인 창 [취소] 면 그대로, [휴지통으로] 면 휴지통으로', (tester) async {
|
||||
final bin = recycleBinForTest;
|
||||
if (bin is! FakeRecycleBin) return markTestSkipped('실제 휴지통 (JJ_TEST_REAL_RECYCLE=1) 에서는 건너뜀');
|
||||
if (!Platform.isWindows) return markTestSkipped('휴지통은 Windows 만 (Android 는 앱 휴지통 시험)');
|
||||
final victim = File(p.join(left, 'tcdel.txt'))..writeAsStringSync('x');
|
||||
c.settings.explorerStyle = 'totalcmd';
|
||||
await open(tester);
|
||||
await settle(tester, () => find.text('tcdel').evaluate().isNotEmpty);
|
||||
await act(tester, () => tester.tap(find.text('tcdel')));
|
||||
await settle(tester, () => false, rounds: 15);
|
||||
|
||||
// F8 → 확인 창 → [취소]: 아무것도 지우지 않음
|
||||
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8));
|
||||
await calm(tester);
|
||||
expect(find.text('휴지통으로 보내기'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget, reason: '처음 값은 휴지통 (영구 삭제 아님)');
|
||||
await tester.tap(find.text('취소').last);
|
||||
await calm(tester);
|
||||
expect(victim.existsSync(), isTrue);
|
||||
expect(bin.items, isEmpty);
|
||||
|
||||
// F8 → [휴지통으로]: 휴지통 길로 (영구 삭제가 아님)
|
||||
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8));
|
||||
await calm(tester);
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로')));
|
||||
await settle(tester, () => !victim.existsSync() && find.byType(SnackBar).evaluate().isNotEmpty);
|
||||
expect(victim.existsSync(), isFalse);
|
||||
expect(bin.items.keys, [victim.path], reason: '휴지통으로 보냄 (되돌릴 수 있음)');
|
||||
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue, reason: '고른 것만');
|
||||
expect(find.text('되돌리기'), findsOneWidget);
|
||||
await calm(tester);
|
||||
});
|
||||
|
||||
testWidgets('83: Windows 탐색기 모양 단축키 - Ctrl+A 모두 표시 · Ctrl+C 담기 · Ctrl+V 붙여넣기', (tester) async {
|
||||
c.settings.explorerStyle = 'windows';
|
||||
await open(tester);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,312 @@
|
|||
import 'dart:convert' show utf8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.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:path/path.dart' as p;
|
||||
|
||||
/// 177: Rsync 실행 전 비교 (미리 보기 · 지울 목록) 가 옵션의 거르기 규칙을 실제 rsync 와 같게 따른다
|
||||
void main() {
|
||||
group('규칙 맞추기 (man rsync FILTER RULES)', () {
|
||||
bool ex(String opts, String rel, {bool dir = false}) => RsyncFilter.parse(opts).excluded(rel, isDir: dir);
|
||||
|
||||
test('* 는 이름에 (어느 깊이든) · / 가 없으면 이름만', () {
|
||||
expect(ex('--exclude=*.log', 'a.log'), isTrue);
|
||||
expect(ex('--exclude=*.log', 'sub/deep/a.log'), isTrue);
|
||||
expect(ex('--exclude=*.log', 'a.log.txt'), isFalse);
|
||||
expect(ex('--exclude *.log', 'x/b.log'), isTrue, reason: '띄어 쓴 값');
|
||||
});
|
||||
test('끝 / = 폴더에만', () {
|
||||
expect(ex('--exclude=build/', 'build', dir: true), isTrue);
|
||||
expect(ex('--exclude=build/', 'build'), isFalse, reason: '같은 이름의 파일은 아님');
|
||||
expect(ex('--exclude=build/', 'docs/build', dir: true), isTrue);
|
||||
});
|
||||
test('앞 / = 옮기는 맨 위 기준', () {
|
||||
expect(ex('--exclude=/build', 'build', dir: true), isTrue);
|
||||
expect(ex('--exclude=/build', 'docs/build', dir: true), isFalse);
|
||||
});
|
||||
test('/ 가 든 패턴은 경로 끝부분에 · ** 는 / 를 넘는다', () {
|
||||
expect(ex('--exclude=sub/*.txt', 'sub/c.txt'), isTrue);
|
||||
expect(ex('--exclude=sub/*.txt', 'x/sub/c.txt'), isTrue);
|
||||
expect(ex('--exclude=sub/*.txt', 'sub/deep/e.txt'), isFalse, reason: '* 는 / 를 넘지 않음');
|
||||
expect(ex('--exclude=sub/**.txt', 'sub/deep/e.txt'), isTrue);
|
||||
expect(ex('--exclude=**/deep', 'sub/deep', dir: true), isTrue);
|
||||
});
|
||||
test('이름/*** = 폴더와 안의 모든 것', () {
|
||||
expect(ex('--exclude=/cache/***', 'cache', dir: true), isTrue);
|
||||
expect(ex('--exclude=/cache/***', 'cache/a/b.txt'), isTrue);
|
||||
expect(ex('--exclude=/cache/***', 'cachex', dir: true), isFalse);
|
||||
});
|
||||
test('처음 맞는 규칙이 이긴다 (--include 먼저)', () {
|
||||
const o = '--include=keep.log --exclude=*.log';
|
||||
expect(ex(o, 'keep.log'), isFalse);
|
||||
expect(ex(o, 'b.log'), isTrue);
|
||||
expect(ex('--exclude=*.log --include=keep.log', 'keep.log'), isTrue, reason: '뒤의 include 는 늦음');
|
||||
});
|
||||
test('? · [..] · 따옴표 · --filter / -f', () {
|
||||
expect(ex('--exclude=a?.txt', 'ab.txt'), isTrue);
|
||||
expect(ex('--exclude=a?.txt', 'a/.txt'), isFalse);
|
||||
expect(ex('--exclude=[ab]*.tmp', 'b1.tmp'), isTrue);
|
||||
expect(ex('--exclude=[!ab]*.tmp', 'b1.tmp'), isFalse);
|
||||
expect(ex('--exclude="My Folder/"', 'My Folder', dir: true), isTrue);
|
||||
expect(ex('--filter="- *.bak"', 'x.bak'), isTrue);
|
||||
expect(ex('-f "+ keep.bak" -f "- *.bak"', 'keep.bak'), isFalse);
|
||||
});
|
||||
test('--exclude-from 파일 (빈 줄 · # · + / - 머리) · 못 읽으면 알림 목록', () {
|
||||
final f = RsyncFilter.parse('--exclude-from=list.txt',
|
||||
readLines: (path) => path == 'list.txt' ? ['# 주석', '', '+ keep.log', '*.log', '- tmp/'] : null);
|
||||
expect(f.excluded('keep.log', isDir: false), isFalse);
|
||||
expect(f.excluded('a.log', isDir: false), isTrue);
|
||||
expect(f.excluded('tmp', isDir: true), isTrue);
|
||||
final g = RsyncFilter.parse('--exclude-from=없음.txt -C --filter=": .rules"', readLines: (_) => null);
|
||||
expect(g.unsupported, hasLength(3));
|
||||
});
|
||||
test('28 확인: 바이트 단위 (? 는 UTF-8 한 바이트) · **/ 는 폴더 0개에도', () {
|
||||
expect(ex('--exclude=?.txt', '가.txt'), isFalse, reason: '"가" 는 3바이트');
|
||||
expect(ex('--exclude=???.txt', '가.txt'), isTrue);
|
||||
expect(ex('--exclude=[가]*', '가.txt'), isTrue, reason: '[가] 는 첫 바이트에 맞음 (rsync 와 같음)');
|
||||
expect(ex('--exclude=**/deep/**', 'deep/x', dir: true), isTrue);
|
||||
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"']) {
|
||||
final f = RsyncFilter.parse(o);
|
||||
expect(f.unsupported, isNotEmpty, reason: o);
|
||||
expect(f.excluded('a.txt', isDir: false), isFalse, reason: '$o 는 지키지 않음 (미리 보기가 더 적게 지우게 되지 않게)');
|
||||
}
|
||||
final g = RsyncFilter.parse('--exclude-from=x',
|
||||
readLines: (_) => ['*.log', '!', 'P keep.txt', 'H a.txt', '-,s b.txt', '. more', '- c.txt']);
|
||||
expect(g.unsupported, hasLength(5));
|
||||
expect(g.excluded('c.txt', isDir: false), isTrue, reason: '보통 줄은 그대로');
|
||||
});
|
||||
test('거르기가 없으면 아무것도 거르지 않음 · 묶은 짧은 옵션은 거르기가 아님', () {
|
||||
final f = RsyncFilter.parse('-avPog --delete');
|
||||
expect(f.isEmpty, isTrue);
|
||||
expect(f.unsupported, isEmpty);
|
||||
expect(f.excluded('a.log', isDir: false), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('미리 보기 = 실제 rsync (--dry-run)', () {
|
||||
late Directory tmp;
|
||||
late String src, dst;
|
||||
void f(String dir, String rel) => File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_rsfilter_');
|
||||
src = p.join(tmp.path, 'src');
|
||||
dst = p.join(tmp.path, 'dst');
|
||||
for (final r in ['a.txt', 'b.log', 'keep.log', 'top.tmp', 'sub/c.txt', 'sub/d.log', 'sub/deep/e.txt', 'build/x.o',
|
||||
'build/y.txt', 'cache/z.txt', 'docs/build/q.txt', 'docs/r.txt', '가.txt', 'ab.bak']) {
|
||||
f(src, r);
|
||||
}
|
||||
// 받는 쪽에만 있는 것 (--delete 로 지워지거나, 걸러져 남는 것)
|
||||
// 28 확인: 받는 쪽에만 있는 폴더 안에 거른 것 (old/keep.log · onlydst/a.bak · deep/x/keep.log) 이 있으면 폴더는 남는다
|
||||
for (final r in ['extra.txt', 'extra.log', 'old.tmp', 'build/old.o', 'cache/stale.txt', 'sub/gone.txt', 'sub/gone.log',
|
||||
'old/keep.log', 'old/gone.txt', 'onlydst/a.bak', 'onlydst/b.txt', 'deep/x/keep.log', 'deep/x/y.txt', 'deep/z.txt', 'plain/p.txt']) {
|
||||
f(dst, r);
|
||||
}
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
// 앱에 들어 있는 rsync (Windows). 없으면 건너뜀
|
||||
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
|
||||
/// 실제 rsync -r --delete -n -i 의 결과: 상대 경로 → 'add' · 'update' · 'delete' (지워지는 폴더 안의 것은 폴더 하나로)
|
||||
Future<Map<String, String>> real(String opts) async {
|
||||
// -8: 한글 이름을 \#ooo 로 바꾸지 않게 · 출력은 UTF-8
|
||||
final r = await Process.run(rsync, ['-r', '-8', '--delete', '-n', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'],
|
||||
stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||
final out = <String, String>{};
|
||||
for (final l in (r.stdout as String).split('\n')) {
|
||||
final s = l.trimRight();
|
||||
if (s.length < 13) continue;
|
||||
final code = s.substring(0, 11), path = s.substring(12).replaceAll(RegExp(r'/$'), '');
|
||||
if (path == '.' || path.isEmpty) continue;
|
||||
if (code.startsWith('*deleting')) {
|
||||
out[path] = 'delete';
|
||||
} else if (code[2] == '+' || code.contains('+++++')) {
|
||||
out[path] = 'add';
|
||||
} else if (code[0] == '>' || code[0] == '<') {
|
||||
out[path] = 'update';
|
||||
}
|
||||
}
|
||||
// -n 은 실제로는 못 지우는 폴더 (안에 거른 것이 남음) 도 "*deleting 폴더/" 로 적는다 - 바로 그 폴더를
|
||||
// "cannot delete non-empty directory" 로 알린다. 실제 실행에서는 남으므로 지워짐에서 뺀다 (실제 결과 기준)
|
||||
for (final l in '${r.stdout}\n${r.stderr}'.split('\n')) {
|
||||
final m = RegExp(r'cannot delete non-empty directory: (.+)$').firstMatch(l.trimRight());
|
||||
if (m != null) out.remove(m[1]);
|
||||
}
|
||||
// 지워지는 폴더 안의 것은 빼고 폴더 하나로 (미리 보기처럼)
|
||||
final dirs = [for (final e in out.entries) if (e.value == 'delete') e.key];
|
||||
out.removeWhere((k, v) => v == 'delete' && dirs.any((d) => k.startsWith('$d/')));
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<Map<String, String>> preview(String opts) async {
|
||||
final items = await previewSync(src, dst, toRight: true, delete: true, filter: RsyncFilter.parse(opts));
|
||||
return {
|
||||
for (final x in items)
|
||||
if (x.action != PreviewAction.onlyTarget && x.action != PreviewAction.skipLink)
|
||||
x.rel: switch (x.action) {
|
||||
PreviewAction.add => 'add',
|
||||
PreviewAction.update => 'update',
|
||||
PreviewAction.delete => 'delete',
|
||||
_ => '${x.action}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (final opts in [
|
||||
'',
|
||||
'--exclude=*.log',
|
||||
'--exclude=build/',
|
||||
'--exclude=/build',
|
||||
'--exclude=build',
|
||||
'--exclude=sub/*.txt',
|
||||
'--exclude=**/deep/**',
|
||||
'--exclude=sub/deep/',
|
||||
'--exclude=/cache/***',
|
||||
'--include=keep.log --exclude=*.log',
|
||||
'--exclude=*.tmp --delete-excluded',
|
||||
'--exclude=*.log --delete-excluded',
|
||||
'--filter="- *.txt"',
|
||||
'--exclude=[bk]*.log',
|
||||
'--exclude=?.txt',
|
||||
// 28 확인 (10/19): 바이트 단위 ? (가.txt 는 3바이트라 ?.txt 에 맞지 않음) · 대상에만 있는 폴더 안의 거른 것
|
||||
'--exclude=??.bak',
|
||||
'--exclude=???.txt',
|
||||
'--exclude=[가]*',
|
||||
'--include=*/ --include=*.txt --exclude=*',
|
||||
'--exclude=keep.log',
|
||||
'--exclude=*.bak',
|
||||
'--exclude=deep/x/keep.log',
|
||||
'--exclude=/deep/x/',
|
||||
'--exclude=*.log --exclude=old/',
|
||||
'--exclude=keep.log --delete-excluded',
|
||||
]) {
|
||||
test('패턴 "$opts"', () async {
|
||||
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
final want = await real(opts);
|
||||
final got = await preview(opts);
|
||||
expect(got, want);
|
||||
});
|
||||
}
|
||||
|
||||
for (final opts in ['--filter="-,s a.txt"', '--filter="H a.txt"']) {
|
||||
test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기는 그 규칙을 "미리 볼 수 없음" 으로 알림', () async {
|
||||
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
File(p.join(dst, 'a.txt')).writeAsStringSync('받는 쪽');
|
||||
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: '미리 보기는 이 규칙을 따르지 못한다고 알려야 함');
|
||||
});
|
||||
}
|
||||
|
||||
test('--exclude-from 파일', () async {
|
||||
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final list = File(p.join(tmp.path, 'excl.txt'))..writeAsStringSync('# 빼기\n+ keep.log\n*.log\nbuild/\n');
|
||||
// Windows rsync 는 /cygdrive 경로로 읽는다 (앱의 거르기도 같은 글을 읽음)
|
||||
final opts = '--exclude-from=${toCygwinPath(list.path)}';
|
||||
expect(await preview(opts), await real(opts));
|
||||
});
|
||||
});
|
||||
|
||||
// 28 (10/19) 이 비교에 쓴 장면과 옵션 19개 그대로 - 옵션을 rsync 에 그대로 (+ -8 -n -i), 미리 보기는 그 옵션의 거르기 · 지우기
|
||||
group('28 의 장면 · 옵션 19개 = 실제 rsync', () {
|
||||
late Directory tmp;
|
||||
late String s, d;
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_rsfilter28_');
|
||||
s = p.join(tmp.path, 's');
|
||||
d = p.join(tmp.path, 'd');
|
||||
for (final r in ['a.txt', 'b.log', 'build/x.o', 'build/sub/y.o', 'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log',
|
||||
'node_modules/m/i.js', 'src/node_modules/n.js', 'docs/readme.md', 'docs/img/p.png', '한글 폴더/가.txt', 'space dir/s.bak',
|
||||
'Keep.TXT', 'tmp/t.txt', 'x/tmp/t2.txt', 'foo/bar', 'foo/q/bar']) {
|
||||
File(p.join(s, r))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('s:$r');
|
||||
}
|
||||
for (final r in ['extra.txt', 'keep.log', 'build/old.o', 'old/keep.log', 'old/gone.txt', 'sub/deep/z.txt', 'tmp/old.txt',
|
||||
'onlydst/a.bak', 'old2/deep/x/keep.log', 'old2/deep/x/g.txt', 'old2/deep/y/h.txt', 'old2/e.txt']) {
|
||||
File(p.join(d, r))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('d:$r');
|
||||
}
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
|
||||
for (final (i, opts) in [
|
||||
'-a --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/',
|
||||
'-a --delete --exclude=*.log',
|
||||
'-a --delete --include=*/ --include=*.txt --exclude=*',
|
||||
'-a --delete --exclude=node_modules/',
|
||||
'-a --delete --exclude=/tmp/',
|
||||
'-a --delete --exclude=tmp/',
|
||||
'-a --delete --exclude=sub/deep',
|
||||
'-a --delete --exclude=docs/***',
|
||||
'-a --delete --exclude=foo/**/bar',
|
||||
'-a --delete --exclude=**/bar',
|
||||
'-a --delete --exclude=*.[lb][oa][gk]',
|
||||
'-a --delete --exclude="space dir/"',
|
||||
'-a --delete --exclude=한글*/',
|
||||
'-a --delete --exclude=*.bak --delete-excluded',
|
||||
'-a --delete --exclude=*.log --delete-excluded',
|
||||
'-a --delete --filter="- *.o" --filter="+ build/"',
|
||||
'-a --delete --include=sub/deep/d.txt --exclude=sub/deep/*',
|
||||
'-a --delete --exclude=?.txt',
|
||||
'-a --delete --exclude=*.TXT',
|
||||
// 28 이 실제 실행으로 더 확인한 6개
|
||||
'-a --delete --exclude=deep/',
|
||||
'-a --delete --exclude=/old2/deep/x/keep.log',
|
||||
'-a --del',
|
||||
'-a --delete-after --exclude=*.log',
|
||||
'-a --delete --exclude=**/x/',
|
||||
'-a --delete --include=*.txt --exclude=*.log --exclude=*/',
|
||||
].indexed) {
|
||||
test('${i + 1}: $opts', () async {
|
||||
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
final r = await Process.run(rsync, ['-8', '-n', '-i', ...splitOptions(opts), '${toCygwinPath(s)}/', '${toCygwinPath(d)}/'],
|
||||
stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||
final want = <String, String>{};
|
||||
for (final l in (r.stdout as String).split('\n')) {
|
||||
final x = l.trimRight();
|
||||
if (x.length < 13) continue;
|
||||
final code = x.substring(0, 11), path = x.substring(12).replaceAll(RegExp(r'/$'), '');
|
||||
if (path == '.' || path.isEmpty) continue;
|
||||
if (code.startsWith('*deleting')) {
|
||||
want[path] = 'delete';
|
||||
} else if (code.contains('+++++')) {
|
||||
want[path] = 'add';
|
||||
} else if (code[0] == '>' || code[0] == '<') {
|
||||
want[path] = 'update';
|
||||
}
|
||||
}
|
||||
// -n 이 적는 "*deleting 폴더/" 중 실제로는 못 지우는 것 (안에 거른 것이 남음) 은 빼고, 지워지는 폴더 안의 것은 폴더 하나로
|
||||
for (final l in '${r.stdout}\n${r.stderr}'.split('\n')) {
|
||||
final m = RegExp(r'cannot delete non-empty directory: (.+)$').firstMatch(l.trimRight());
|
||||
if (m != null) want.remove(m[1]);
|
||||
}
|
||||
final gone = [for (final e in want.entries) if (e.value == 'delete') e.key];
|
||||
want.removeWhere((k, v) => v == 'delete' && gone.any((g) => k.startsWith('$g/')));
|
||||
final items = await previewSync(s, d, toRight: true, delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
||||
final got = {
|
||||
for (final x in items)
|
||||
if (x.action == PreviewAction.add || x.action == PreviewAction.update || x.action == PreviewAction.delete)
|
||||
x.rel: x.action == PreviewAction.add ? 'add' : x.action == PreviewAction.update ? 'update' : 'delete',
|
||||
};
|
||||
expect(got, want);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -63,7 +63,10 @@ void main() {
|
|||
expect(r['only_b.txt'], PreviewAction.delete);
|
||||
// 229: 건너뛰는 것도 목록에 "받는 쪽이 더 새것 - 건너뜀" 으로 (예전에는 빠져 있었다)
|
||||
expect(r['leftold.txt'], PreviewAction.skipNewer);
|
||||
expect(r.containsKey('half.mkv.jjpart'), isFalse);
|
||||
// 15 검토 F4 (10/19): 받는 쪽에만 있는 받다 만 파일도 --delete 로 지워지므로 지울 목록에 (예전에는 빠져 적게 보였다)
|
||||
expect(r['half.mkv.jjpart'], PreviewAction.delete);
|
||||
final keep = m(await previewSync(a, b, toRight: true, update: true));
|
||||
expect(keep.containsKey('half.mkv.jjpart'), isFalse, reason: '지우지 않으면 셀 것 없음');
|
||||
});
|
||||
|
||||
test('⇄: 한쪽에만 있는 것은 서로 건너가고, 다른 파일은 새것 쪽에서 (한 번만)', () async {
|
||||
|
|
|
|||
Loading…
Reference in New Issue