WIP 177 보충 3 (15 검토 F1~F4): 고치개 · H · P · merge · exclude-from 의 ! 등은 적용하지 않고 "미리 볼 수 없음" · 그런 규칙이 있으면 빨간 경고, --delete 와 함께면 체크해야 [실행] · 받는 쪽 받다 만 파일도 지울 목록에 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next3
RyuJongJin 2026-10-19 22:14:38 +09:00
parent 86a994690b
commit 63ef362f6d
10 changed files with 125 additions and 7 deletions

View File

@ -1305,6 +1305,7 @@
"받다 만 AI 모델": "Unfinished AI models", "받다 만 AI 모델": "Unfinished AI models",
"받다 만 다운로드": "Unfinished downloads", "받다 만 다운로드": "Unfinished downloads",
"받다 만 조각 · {0}": "Partial download · {0}", "받다 만 조각 · {0}": "Partial download · {0}",
"받다 만 파일 {0}개도 지워집니다": "{0} partially downloaded files are deleted too",
"받아서 열기": "Download and open", "받아서 열기": "Download and open",
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "Downloaded AI models ({0}) - Download/JJ_MKVMaker/AI 모델 보관", "받은 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)", "받은 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)",
@ -1716,6 +1717,7 @@
"안쪽 파일을 확인하는 중 …": "Checking files inside …", "안쪽 파일을 확인하는 중 …": "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 - 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", "안쪽에 받는 쪽이 더 새 파일 {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)", "알림이 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}", "암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
"암호로 묶기": "Make password ZIP", "암호로 묶기": "Make password ZIP",
@ -2023,6 +2025,7 @@
"이 언어 지우기": "Remove this language", "이 언어 지우기": "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 설정에서 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}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, 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.", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button", "이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",

View File

@ -1305,6 +1305,7 @@
"받다 만 AI 모델": "ダウンロード途中の AI モデル", "받다 만 AI 모델": "ダウンロード途中の AI モデル",
"받다 만 다운로드": "ダウンロード途中のファイル", "받다 만 다운로드": "ダウンロード途中のファイル",
"받다 만 조각 · {0}": "途中までのダウンロード · {0}", "받다 만 조각 · {0}": "途中までのダウンロード · {0}",
"받다 만 파일 {0}개도 지워집니다": "途中までの受信ファイル {0} 件も削除されます",
"받아서 열기": "ダウンロードして開く", "받아서 열기": "ダウンロードして開く",
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "ダウンロードした AI モデル ({0}) - Download/JJ_MKVMaker/AI 모델 보관", "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "ダウンロードした AI モデル ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "ダウンロードした AI モデル ({0}) を Download/JJ_MKVMaker にコピーし、再インストール後に復元する (一時的に {0} 余分に必要)", "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "ダウンロードした AI モデル ({0}) を Download/JJ_MKVMaker にコピーし、再インストール後に復元する (一時的に {0} 余分に必要)",
@ -1716,6 +1717,7 @@
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …", "안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "理解した上で実行します (削除リストが実際と異なる場合があります)",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}", "암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
"암호로 묶기": "パスワード付きでまとめる", "암호로 묶기": "パスワード付きでまとめる",
@ -2023,6 +2025,7 @@
"이 언어 지우기": "この言語を削除", "이 언어 지우기": "この言語を削除",
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします", "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",

View File

@ -1305,6 +1305,7 @@
"받다 만 AI 모델": "未下载完的 AI 模型", "받다 만 AI 모델": "未下载完的 AI 模型",
"받다 만 다운로드": "未完成的下载", "받다 만 다운로드": "未完成的下载",
"받다 만 조각 · {0}": "未完成的下载 · {0}", "받다 만 조각 · {0}": "未完成的下载 · {0}",
"받다 만 파일 {0}개도 지워집니다": "{0} 个未完成的文件也会被删除",
"받아서 열기": "下载后打开", "받아서 열기": "下载后打开",
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "已下载的 AI 模型 ({0}) - Download/JJ_MKVMaker/AI 모델 보관", "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "已下载的 AI 模型 ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "将已下载的 AI 模型 ({0}) 复制到 Download/JJ_MKVMaker,重新安装后恢复 (暂时需要额外 {0})", "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "将已下载的 AI 模型 ({0}) 复制到 Download/JJ_MKVMaker,重新安装后恢复 (暂时需要额外 {0})",
@ -1716,6 +1717,7 @@
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …", "안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "我已了解并执行 (删除列表可能与实际不同)",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}", "암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
"암호로 묶기": "加密压缩", "암호로 묶기": "加密压缩",
@ -2023,6 +2025,7 @@
"이 언어 지우기": "删除此语言", "이 언어 지우기": "删除此语言",
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密", "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮", "이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",

View File

@ -46,6 +46,11 @@ class RsyncFilter {
for (final l in lines) { for (final l in lines) {
final s = l.trimRight(); final s = l.trimRight();
if (s.isEmpty || s.startsWith('#') || s.startsWith(';')) continue; 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('+ ')) { if (s.startsWith('+ ')) {
rules.add(RsyncFilterRule(s.substring(2), include: true)); rules.add(RsyncFilterRule(s.substring(2), include: true));
@ -58,7 +63,10 @@ class RsyncFilter {
} }
void filterRule(String r) { void filterRule(String r) {
final m = RegExp(r'^(\+|-|include|exclude)(?:,[a-z!]*)?\s+(.+)$').firstMatch(r.trim()); // 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) { if (m == null) {
unsupported.add('--filter=$r'); unsupported.add('--filter=$r');
return; return;

View File

@ -115,7 +115,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
for (final x in items) { for (final x in items) {
if (out.length >= limit) return; if (out.length >= limit) return;
final name = vBasename(x.path); final name = vBasename(x.path);
if (isPartialFile(name) || isAppTempName(name)) continue; if (isAppTempName(name)) continue;
final r = '$rel/$name'; final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) continue; if (filter.excluded(r, isDir: x.isDir)) continue;
if (x.isDir && await _hasExcluded(x.path, r, filter)) { if (x.isDir && await _hasExcluded(x.path, r, filter)) {
@ -179,7 +179,8 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
} }
for (final e in there.values) { for (final e in there.values) {
final name = vBasename(e.path); final name = vBasename(e.path);
if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue; // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음)
if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue;
final r = rel.isEmpty ? name : '$rel/$name'; final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: e.isDir)) { if (filter.excluded(r, isDir: e.isDir)) {
// 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다) // 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다)

View File

@ -3375,6 +3375,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에) // 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에)
List<PreviewItem>? preview; List<PreviewItem>? preview;
Object? previewError; Object? previewError;
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
var filterAck = false;
List<String> filterLimits() => _viaDav([l, r]) ? const [] : {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t))); bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
@ -3450,6 +3453,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
], ],
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
const SizedBox(height: 8), const SizedBox(height: 8),
if (filterLimits().isNotEmpty)
FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView( SyncPreviewView(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')),
@ -3507,7 +3512,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0;
return FilledButton( return FilledButton(
style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, 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: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨).
// 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다 // 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다
child: Text(removeSource ? tr('이동') : tr('실행')), child: Text(removeSource ? tr('이동') : tr('실행')),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/settings.dart' show CopyTask; 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/rsync_filter.dart';
import '../core/sync_preview.dart'; import '../core/sync_preview.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
@ -98,6 +98,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
int n(PreviewAction a, [bool? right]) => int n(PreviewAction a, [bool? right]) =>
items.where((x) => x.action == a && (right == null || x.toRight == right)).length; items.where((x) => x.action == a && (right == null || x.toRight == right)).length;
final dels = n(PreviewAction.delete); final dels = n(PreviewAction.delete);
final partials = items.where((x) => x.action == PreviewAction.delete && isPartialFile(x.rel)).length;
final overwrites = n(PreviewAction.update); final overwrites = n(PreviewAction.update);
final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length; final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length;
final skipped = n(PreviewAction.skipNewer); final skipped = n(PreviewAction.skipNewer);
@ -124,6 +125,10 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
if (dels > 0) if (dels > 0)
Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]), Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)), 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: 내용이 다른 같은 이름은 받는 쪽 파일이 바뀐다 - 목록의 "~" 만으로는 알아채기 어려웠다 // 211: 내용이 다른 같은 이름은 받는 쪽 파일이 바뀐다 - 목록의 "~" 만으로는 알아채기 어려웠다
if (overwrites > 0) if (overwrites > 0)
Text(trf('받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)', [overwrites]), Text(trf('받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)', [overwrites]),
@ -206,6 +211,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
if (!deletes && !moves) return true; if (!deletes && !moves) return true;
List<PreviewItem>? preview; List<PreviewItem>? preview;
Object? error; Object? error;
var filterAck = false;
final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav); final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav);
// robocopy 는 /XO (옵션 칸 또는 환경 설정) 가 -u 와 같다 // robocopy 는 /XO (옵션 칸 또는 환경 설정) 가 -u 와 같다
final robo = t.method == 'robocopy' && !direct; final robo = t.method == 'robocopy' && !direct;
@ -242,6 +248,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
if (filter.unsupported.isNotEmpty)
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView( SyncPreviewView(
compute: () async => [ compute: () async => [
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update,
@ -279,7 +288,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton( FilledButton(
style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, 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: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게) child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게)
), ),
], ],
@ -289,3 +298,28 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
); );
return ok == true; 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)),
),
]);
}

View File

@ -219,6 +219,38 @@ void main() {
await tester.pumpAndSettle(); 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 { testWidgets('236: 모니터링 카드 [실행] 뒤 옵션 칸에 포커스가 남지 않는다 (키보드가 저절로 올라와 끝 알림을 가리지 않게)', skip: !Platform.isWindows, (tester) async {
final src = p.join(tmp.path, 'cp'), dst = p.join(tmp.path, 'cpd'); final src = p.join(tmp.path, 'cp'), dst = p.join(tmp.path, 'cpd');
File(p.join(src, 'a.txt')) File(p.join(src, 'a.txt'))

View File

@ -71,6 +71,18 @@ void main() {
expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue); expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue);
expect(ex('--exclude=**/deep/**', 'deep', dir: true), isFalse); 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('거르기가 없으면 아무것도 거르지 않음 · 묶은 짧은 옵션은 거르기가 아님', () { test('거르기가 없으면 아무것도 거르지 않음 · 묶은 짧은 옵션은 거르기가 아님', () {
final f = RsyncFilter.parse('-avPog --delete'); final f = RsyncFilter.parse('-avPog --delete');
expect(f.isEmpty, isTrue); expect(f.isEmpty, isTrue);
@ -187,6 +199,18 @@ void main() {
}); });
} }
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 { test('--exclude-from 파일', () async {
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음'); 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'); final list = File(p.join(tmp.path, 'excl.txt'))..writeAsStringSync('# 빼기\n+ keep.log\n*.log\nbuild/\n');

View File

@ -63,7 +63,10 @@ void main() {
expect(r['only_b.txt'], PreviewAction.delete); expect(r['only_b.txt'], PreviewAction.delete);
// 229: 건너뛰는 것도 목록에 "받는 쪽이 더 새것 - 건너뜀" 으로 (예전에는 빠져 있었다) // 229: 건너뛰는 것도 목록에 "받는 쪽이 더 새것 - 건너뜀" 으로 (예전에는 빠져 있었다)
expect(r['leftold.txt'], PreviewAction.skipNewer); 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 { test('⇄: 한쪽에만 있는 것은 서로 건너가고, 다른 파일은 새것 쪽에서 (한 번만)', () async {