Compare commits
10 Commits
7cd8eb8b15
...
fe34a8b556
| Author | SHA1 | Date |
|---|---|---|
|
|
fe34a8b556 | |
|
|
c0695b02d8 | |
|
|
63ef362f6d | |
|
|
86a994690b | |
|
|
09fbe0337a | |
|
|
cda2a30664 | |
|
|
45e1a95df0 | |
|
|
1396ba41c8 | |
|
|
cfed8fa355 | |
|
|
722edc8ed1 |
|
|
@ -356,6 +356,8 @@
|
|||
"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 · 거르기 (--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).",
|
||||
"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 +1317,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 +1533,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 +1731,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 +2041,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,8 @@
|
|||
"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 · 거르기 (--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 に従う)。",
|
||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?",
|
||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
|
||||
|
|
@ -1315,6 +1317,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 +1533,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "サーバー ({0}) との接続が切れました",
|
||||
"서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "サーバー一覧 (WebDAV・SFTP・SMB・FTP) とそのパスワード・API キー、ファイルエクスプローラーのお気に入りも消えます。動画リスト・ウェブのお気に入り・ファイルはそのままです",
|
||||
"서버 설정 고치기": "サーバー設定を修正",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定と、この端末に保存されたパスワード・秘密鍵・信頼したサーバー鍵を削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再登録するまで接続されません。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "サーバー設定と、この端末に保存したパスワード · 信頼した証明書を削除します。サーバーのファイルはそのままです。",
|
||||
|
|
@ -1727,6 +1731,7 @@
|
|||
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "理解した上で実行します (削除リストが実際と異なる場合があります)",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
|
||||
"암호로 묶기": "パスワード付きでまとめる",
|
||||
|
|
@ -2036,6 +2041,7 @@
|
|||
"이 언어 지우기": "この言語を削除",
|
||||
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
|
||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
|
||||
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}",
|
||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
|
||||
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
||||
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
|
||||
|
|
|
|||
|
|
@ -356,6 +356,8 @@
|
|||
"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 · 거르기 (--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)。",
|
||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?",
|
||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。",
|
||||
|
|
@ -1315,6 +1317,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 +1533,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "与服务器 ({0}) 的连接已断开",
|
||||
"서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "服务器列表 (WebDAV · SFTP · SMB · FTP) 及其密码 · API 密钥、文件管理器收藏也会删除。视频列表 · 网页书签 · 文件保持不变",
|
||||
"서버 설정 고치기": "修改服务器设置",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置以及本设备保存的密码、私钥和已信任的服务器密钥。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加前无法连接。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "删除服务器设置以及本设备上保存的密码和已信任的证书。服务器上的文件不受影响。",
|
||||
|
|
@ -1727,6 +1731,7 @@
|
|||
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "我已了解并执行 (删除列表可能与实际不同)",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
|
||||
"암호로 묶기": "加密压缩",
|
||||
|
|
@ -2036,6 +2041,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: '찍기만 하고 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import '../l10n/tr.dart';
|
|||
import '../platform/windows/rsync_installer.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'settings.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
|
||||
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
|
||||
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
|
||||
|
|
@ -149,8 +150,12 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
|
||||
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit);
|
||||
/// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다.
|
||||
/// 로컬 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);
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -194,7 +199,7 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
||||
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500}) async {
|
||||
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -215,20 +220,28 @@ class LiveSync extends ChangeNotifier {
|
|||
if (out.length >= limit) return;
|
||||
final name = vBasename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue; // 관리자 지시 (10/19): 맞추기 (_mirrorV) 와 같은 거르기
|
||||
names.add(name);
|
||||
if (e.isDir) {
|
||||
await walk(e.path, vJoin(dst, name), r);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
// M1: 받는 쪽이 같은 이름의 폴더면 지우기 포함일 때만 바뀐다 (아니면 건너뜀 - 기다리는 것으로 세지 않음)
|
||||
if (t != null && t.isDir && !x.delete) continue;
|
||||
if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) {
|
||||
out.add(r);
|
||||
}
|
||||
}
|
||||
if (x.delete) {
|
||||
for (final name in there.keys) {
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -248,7 +261,7 @@ class LiveSync extends ChangeNotifier {
|
|||
problems[k] = SourceUnreadableException(x.source, cause: e);
|
||||
}
|
||||
try {
|
||||
pending[k] = await diff(x);
|
||||
pending[k] = await diff(x, filter: _filterOf(x));
|
||||
await _countOnlyTarget(x);
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
|
|
@ -265,7 +278,7 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
// 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것
|
||||
try {
|
||||
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000)) if (d.startsWith('− ')) d].length;
|
||||
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000, filter: _filterOf(x))) if (d.startsWith('− ')) d].length;
|
||||
} catch (_) {
|
||||
onlyTarget.remove(k); // 세지 못하면 예전 글
|
||||
}
|
||||
|
|
@ -401,7 +414,7 @@ class LiveSync extends ChangeNotifier {
|
|||
try {
|
||||
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
|
||||
if (x.delete && !x.deleteConfirmed) {
|
||||
final del = [for (final d in await diff(x, limit: 100000)) if (d.startsWith('− ')) d.substring(2)];
|
||||
final del = [for (final d in await diff(x, limit: 100000, filter: _filterOf(x))) if (d.startsWith('− ')) d.substring(2)];
|
||||
if (del.isEmpty) {
|
||||
await _setPair(x, x.copyWith(deleteConfirmed: true));
|
||||
} else {
|
||||
|
|
@ -419,7 +432,7 @@ class LiveSync extends ChangeNotifier {
|
|||
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
|
||||
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps);
|
||||
final n = switch (method) {
|
||||
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty),
|
||||
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)),
|
||||
CopyMethod.rsync => await _rsync(x, s),
|
||||
CopyMethod.robocopy => await _robocopy(x, s),
|
||||
};
|
||||
|
|
@ -430,7 +443,7 @@ class LiveSync extends ChangeNotifier {
|
|||
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
|
||||
problems.remove(k);
|
||||
emptyDeletes.remove(k);
|
||||
pending[k] = await diff(asked);
|
||||
pending[k] = await diff(asked, filter: _filterOf(asked));
|
||||
await _countOnlyTarget(asked);
|
||||
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
|
||||
} on SourceUnreadableException catch (e) {
|
||||
|
|
@ -446,7 +459,7 @@ class LiveSync extends ChangeNotifier {
|
|||
);
|
||||
if (e.empty) {
|
||||
try {
|
||||
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000)) if (d.startsWith('− ')) d.substring(2)];
|
||||
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000, filter: _filterOf(asked))) if (d.startsWith('− ')) d.substring(2)];
|
||||
} catch (_) {}
|
||||
} else {
|
||||
emptyDeletes.remove(k);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../core/sync_preview.dart' show isRsyncDeleteOption;
|
|||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
|
||||
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
|
||||
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
|
||||
|
|
@ -343,7 +344,12 @@ class TransferJob extends ChangeNotifier {
|
|||
if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
|
||||
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도
|
||||
settled.clear();
|
||||
await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move);
|
||||
await _ops!.mirror(sources[i], dest,
|
||||
update: _update || skipNewer,
|
||||
delete: _delete,
|
||||
strict: move,
|
||||
// 관리자 지시 (10/19): rsync 를 고른 작업 (원격이라 앱이 맞춤) 은 옵션의 거르기도 미리 보기와 같게
|
||||
filter: method == CopyMethod.rsync ? RsyncFilter.parse(options) : RsyncFilter.none);
|
||||
dirConflicts.addAll(_ops!.dirConflicts);
|
||||
_ops!.dirConflicts.clear();
|
||||
if (move && !cancelled) await _deleteSettled();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../l10n/tr.dart';
|
||||
import 'playlist.dart' show naturalCompare;
|
||||
import 'rsync_filter.dart';
|
||||
import 'vfs.dart';
|
||||
import 'webdav.dart' show DavException, DavLockedException;
|
||||
|
||||
|
|
@ -631,11 +632,12 @@ class FileOps {
|
|||
///
|
||||
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게).
|
||||
/// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다
|
||||
/// [filter] (관리자 지시 10/19, M2 의 본 고침): rsync 옵션의 거르기 - 원격 (앱이 직접 맞춤) 도 미리 보기와 같은 규칙을 따른다
|
||||
Future<int> mirror(String srcDir, String dstDir,
|
||||
{bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false}) async {
|
||||
{bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다)
|
||||
if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource);
|
||||
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict);
|
||||
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter);
|
||||
var n = 0;
|
||||
Future<void> walk(String src, String dst) async {
|
||||
_check();
|
||||
|
|
@ -919,9 +921,42 @@ class FileOps {
|
|||
return empty;
|
||||
}
|
||||
|
||||
Future<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false}) async {
|
||||
Future<int> _mirrorV(String srcDir, String dstDir,
|
||||
{bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
var n = 0;
|
||||
Future<void> walk(String src, String dst) async {
|
||||
// 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다
|
||||
// (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다
|
||||
Future<bool> hasExcluded(String dir, String rel) 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 r = '$rel/${vBasename(x.path)}';
|
||||
if (filter.excluded(r, isDir: x.isDir)) return true;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> deleteInside(String dir, String rel) async {
|
||||
for (final x in await vList(dir)) {
|
||||
_check();
|
||||
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 (x.isDir && await hasExcluded(x.path, r)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
await vDelete(x.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
_check();
|
||||
await vMkdirs(dst);
|
||||
final there = {for (final e in await vList(dst)) vBasename(e.path): e};
|
||||
|
|
@ -935,12 +970,15 @@ class FileOps {
|
|||
for (final e in items) {
|
||||
final name = vBasename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded)
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue;
|
||||
names.add(name);
|
||||
final target = vJoin(dst, name);
|
||||
// 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게)
|
||||
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue;
|
||||
if (e.isDir) {
|
||||
await walk(e.path, target);
|
||||
await walk(e.path, target, r);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
|
|
@ -968,13 +1006,25 @@ class FileOps {
|
|||
onSettled?.call(e.path, e.size, e.modified);
|
||||
}
|
||||
if (delete) {
|
||||
for (final name in there.keys) {
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) await vDelete(vJoin(dst, name));
|
||||
for (final t in there.entries) {
|
||||
final name = t.key;
|
||||
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)) {
|
||||
await deleteInside(t.value.path, r);
|
||||
continue;
|
||||
}
|
||||
await vDelete(t.value.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(srcDir, dstDir);
|
||||
await walk(srcDir, dstDir, '');
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
// 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에)
|
||||
List<PreviewItem>? preview;
|
||||
Object? previewError;
|
||||
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
|
||||
var filterAck = false;
|
||||
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
|
||||
List<String> filterLimits() => {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 +3493,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: RsyncFilter.parse(opts(tasks.first)))
|
||||
: [
|
||||
for (final t in tasks)
|
||||
...await previewSync(t.sources.first, t.dest,
|
||||
|
|
@ -3500,7 +3508,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: RsyncFilter.parse(opts(t))),
|
||||
],
|
||||
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
|
||||
onStart: () => set(() {
|
||||
|
|
@ -3533,15 +3542,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
if (_viaDav([l, r]))
|
||||
Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'),
|
||||
Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
||||
// M2 (관리자 지시 10/19): 원격이고 옵션에 거르기가 있으면 - 앱 맞추기는 거르기를 따르지 않는다 (뺀 것도 복사 · 지움)
|
||||
if (_viaDav([l, r]) && tasks.any((t) => optionsFilter(opts(t))))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -3550,7 +3552,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,9 @@ 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 는 이 규칙을 쓰지 않음)
|
||||
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
|
||||
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -236,13 +246,6 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
|||
],
|
||||
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
|
||||
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||
// M2 (관리자 지시 10/19): 원격 (앱이 직접 맞춤) 인데 옵션에 거르기가 있으면 - 거르기를 따르지 않는다
|
||||
if (direct && t.method == 'rsync' && optionsFilter(t.options))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
|
||||
if (t.move)
|
||||
Padding(
|
||||
|
|
@ -251,11 +254,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 +294,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 +304,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);
|
||||
|
|
|
|||
|
|
@ -206,8 +206,9 @@ void main() {
|
|||
expect(find.textContaining('→ + inner.txt'), findsOneWidget);
|
||||
expect(find.textContaining('= note.txt'), findsOneWidget);
|
||||
expect(find.textContaining('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget);
|
||||
// M2: 옵션에 거르기가 없으면 거르기 경고는 없다
|
||||
expect(find.textContaining('거르기 (--exclude'), findsNothing);
|
||||
// 관리자 지시 (10/19): 원격도 거르기를 따른다 - 안내에 적고, "따르지 않습니다" 경고 (M2) 는 없다
|
||||
expect(find.textContaining('거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름'), findsOneWidget);
|
||||
expect(find.textContaining('거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다'), findsNothing);
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
|
||||
final got = File(p.join(remote.path, '원격', 'inner.txt'));
|
||||
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import 'package:path/path.dart' as p;
|
|||
import 'support/sftp_server.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||
|
||||
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
|
||||
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
|
||||
|
|
@ -183,6 +184,31 @@ void main() {
|
|||
expect((off! as SftpException).kind, SftpFailure.unreachable);
|
||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||
|
||||
test('관리자 지시 (10/19): SFTP 맞추기도 거르기를 따름 (보내지 않기 · 받는 쪽 지키기 · 거른 것이 든 폴더)', () async {
|
||||
await start();
|
||||
SftpRegistry.configure([srv(fp: fingerprint)]);
|
||||
final src = Directory(p.join(tmp.path, 'local', 'fsrc'))..createSync(recursive: true);
|
||||
File(p.join(src.path, 'a.txt')).writeAsStringSync('a');
|
||||
File(p.join(src.path, 'skip.tmp')).writeAsStringSync('s');
|
||||
Directory(p.join(root.path, 'fdst', 'gone')).createSync(recursive: true);
|
||||
File(p.join(root.path, 'fdst', 'only.tmp')).writeAsStringSync('o');
|
||||
File(p.join(root.path, 'fdst', 'old.txt')).writeAsStringSync('old');
|
||||
File(p.join(root.path, 'fdst', 'gone', 'g.tmp')).writeAsStringSync('g');
|
||||
File(p.join(root.path, 'fdst', 'gone', 'g.txt')).writeAsStringSync('g');
|
||||
const dst = 'sftp://s1/fdst';
|
||||
final filter = RsyncFilter.parse('-av --exclude=*.tmp');
|
||||
final pv = await previewSync(src.path, dst, toRight: true, delete: true, filter: filter);
|
||||
expect({for (final x in pv) if (x.action == PreviewAction.delete) x.rel}, {'old.txt', 'gone/g.txt'});
|
||||
await FileOps().mirror(src.path, dst, delete: true, filter: filter);
|
||||
String real(String rel) => p.joinAll([root.path, 'fdst', ...rel.split('/')]);
|
||||
expect(File(real('a.txt')).existsSync(), isTrue);
|
||||
expect(File(real('skip.tmp')).existsSync(), isFalse);
|
||||
expect(File(real('only.tmp')).existsSync(), isTrue);
|
||||
expect(File(real('old.txt')).existsSync(), isFalse);
|
||||
expect(File(real('gone/g.tmp')).existsSync(), isTrue);
|
||||
expect(File(real('gone/g.txt')).existsSync(), isFalse);
|
||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||
|
||||
test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async {
|
||||
await start();
|
||||
SftpRegistry.configure([srv(fp: fingerprint)]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,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';
|
||||
|
||||
void main() {
|
||||
late Directory tmp, remote, local;
|
||||
|
|
@ -217,4 +218,88 @@ void main() {
|
|||
expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file');
|
||||
expect(ops2.dirConflicts, isEmpty);
|
||||
});
|
||||
/// 관리자 지시 (10/19): 원격 맞추기 (_mirrorV) 가 rsync 옵션의 거르기를 미리 보기와 같게 따르는지 -
|
||||
/// 미리 보기의 "새로 · 바뀜" 은 실제로 생기고, "지워짐" 은 실제로 없어지고, 그 밖의 받는 쪽 것은 그대로
|
||||
Future<void> checkSame(String src, String dst, String Function(String rel) real, String options, {required bool delete}) async {
|
||||
final filter = RsyncFilter.parse(options);
|
||||
final before = <String>{};
|
||||
void scan(Directory d, String rel) {
|
||||
for (final e in d.listSync()) {
|
||||
final r = rel.isEmpty ? p.basename(e.path) : '$rel/${p.basename(e.path)}';
|
||||
before.add(r);
|
||||
if (e is Directory) scan(e, r);
|
||||
}
|
||||
}
|
||||
final root = Directory(real(''));
|
||||
if (root.existsSync()) scan(root, '');
|
||||
final pv = await previewSync(src, dst, toRight: true, delete: delete, filter: filter);
|
||||
await FileOps().mirror(src, dst, delete: delete, filter: filter);
|
||||
for (final x in pv) {
|
||||
final there = FileSystemEntity.typeSync(real(x.rel)) != FileSystemEntityType.notFound;
|
||||
switch (x.action) {
|
||||
case PreviewAction.add || PreviewAction.update:
|
||||
expect(there, isTrue, reason: '미리 보기 + / ~ ${x.rel} → 실제로 있음 ($options)');
|
||||
case PreviewAction.delete when !x.replacedByFile:
|
||||
expect(there, isFalse, reason: '미리 보기 − ${x.rel} → 실제로 지워짐 ($options)');
|
||||
default:
|
||||
}
|
||||
}
|
||||
final shownDeleted = {for (final x in pv) if (x.action == PreviewAction.delete) x.rel};
|
||||
for (final r in before) {
|
||||
final gone = FileSystemEntity.typeSync(real(r)) == FileSystemEntityType.notFound;
|
||||
final underShown = shownDeleted.any((d) => r == d || r.startsWith('$d/'));
|
||||
if (gone) expect(underShown, isTrue, reason: '실제로 지워진 $r 는 미리 보기 지울 목록에 ($options)');
|
||||
}
|
||||
}
|
||||
|
||||
test('관리자 지시 (10/19): WebDAV 맞추기가 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따르고 미리 보기와 같음', () async {
|
||||
void fresh() {
|
||||
for (final d in [Directory(p.join(local.path, 'src')), Directory(p.join(remote.path, 'dst'))]) {
|
||||
if (d.existsSync()) d.deleteSync(recursive: true);
|
||||
}
|
||||
lf('src/a.txt', 'a');
|
||||
lf('src/skip.tmp', 's');
|
||||
lf('src/cache/c.bin', 'c');
|
||||
lf('src/keep/x.txt', 'x');
|
||||
lf('src/keep/y.log', 'y');
|
||||
lf('src/한글 폴더/가.txt', '가');
|
||||
rf('dst/only.tmp', 'o'); // 받는 쪽에만 있고 거른 것
|
||||
rf('dst/old.txt', 'old'); // 받는 쪽에만 있음
|
||||
rf('dst/cache/remote.bin', 'r'); // 거른 폴더
|
||||
rf('dst/gone/g.tmp', 'g'); // 받는 쪽에만 있는 폴더 안의 거른 것
|
||||
rf('dst/gone/g.txt', 'g');
|
||||
}
|
||||
|
||||
final src = p.join(local.path, 'src');
|
||||
const dst = 'dav://$id/dst';
|
||||
String real(String rel) => p.joinAll([remote.path, 'dst', ...rel.split('/').where((s) => s.isNotEmpty)]);
|
||||
for (final (opts, delete) in [
|
||||
('-av --exclude=*.tmp --exclude=cache/', true),
|
||||
('-av --exclude=*.tmp --exclude=cache/ --delete-excluded', true),
|
||||
('-av --include=keep/ --include=keep/*.txt --exclude=keep/* --exclude=*.tmp', true),
|
||||
('-av --filter="- *.log" --filter="- /cache"', true),
|
||||
('-av --exclude=*.tmp', false),
|
||||
]) {
|
||||
fresh();
|
||||
await checkSame(src, dst, real, opts, delete: delete);
|
||||
}
|
||||
// 하나를 자세히: 거른 것은 보내지 않고 받는 쪽의 거른 것은 지키며, 거른 것이 든 폴더는 남기고 안의 것만 지움
|
||||
fresh();
|
||||
await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/'));
|
||||
expect(File(real('a.txt')).existsSync(), isTrue);
|
||||
expect(File(real('skip.tmp')).existsSync(), isFalse, reason: '거른 것은 보내지 않음');
|
||||
expect(File(real('cache/c.bin')).existsSync(), isFalse, reason: '거른 폴더 안으로 내려가지 않음');
|
||||
expect(File(real('only.tmp')).readAsStringSync(), 'o', reason: '받는 쪽의 거른 것은 지키기');
|
||||
expect(File(real('cache/remote.bin')).existsSync(), isTrue);
|
||||
expect(File(real('old.txt')).existsSync(), isFalse, reason: '거르지 않은 것은 --delete 로 지움');
|
||||
expect(File(real('gone/g.tmp')).existsSync(), isTrue, reason: '거른 것이 든 폴더는 남기고');
|
||||
expect(File(real('gone/g.txt')).existsSync(), isFalse, reason: '그 안의 거르지 않은 것만 지움');
|
||||
expect(File(real('한글 폴더/가.txt')).readAsStringSync(), '가');
|
||||
// --delete-excluded: 받는 쪽의 거른 것도 지움
|
||||
fresh();
|
||||
await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/ --delete-excluded'));
|
||||
expect(File(real('only.tmp')).existsSync(), isFalse);
|
||||
expect(Directory(real('cache')).existsSync(), isFalse);
|
||||
expect(Directory(real('gone')).existsSync(), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue