rc: 목록 · 기록 파일의 C:/ · C:\ · UNC 를 rsync 가 여는 꼴로 넘김 · Windows 는 드라이브 경로만 · 막힐 때는 이유 한 줄만 · ---delete 문구 (검토 대기)

관리자 지시 (10/20, 28 이 985 에서 확인):
1. --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · merge · --log-file · --write-batch · --read-batch ·
   --only-write-batch 의 값이 C:/… 이면 앱은 통과시키는데 rsync (MSYS2) 가 "failed to open exclude file" (exit 11) 로 실패했다
   (지운 것은 없음). 관리자 판단: 막지 말고 바르게 넘긴다 - rsyncFileOptionArgs 가 그 값의 C:\ · C:/ · UNC 를 toCygwinPath 로
   (rsyncArgs · 실시간 동기화 rsyncArgsFor). 패턴 · ": 이름" · 받는 쪽 기준 옵션 (--backup-dir 등) · -e 의 값은 그대로.
2. Windows 는 드라이브 (C:\ · C:/) · UNC · /cygdrive/ 만 전체 경로 - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 막는다.
   끝 문구: Windows "C:\ 처럼 드라이브로 시작하는 전체 경로로", Android "/ 로 시작하는 전체 경로로".
3. [실행] 이 막힐 때 (알아볼 수 없음 · 상대 경로 등) 는 "미리 볼 수 없습니다" 줄과 [알고 실행합니다] 체크를 숨기고 막히는
   이유 한 줄만 (Rsync 화면 확인 창 · 지우기 포함 실행 확인 창).
4. "원본 파일 지우기 (--remove-source-files)" 가 412 ko · ja 에서 옵션 중간에 갈리던 것: rc 에 든 dfed3fa (keepOptionsTogether)
   로 이미 고쳐짐 - 캡처 shots_rsync_narrow (ko · ja · en 412) 로 확인.
5. "---delete" 의 끝 문구는 "옵션은 - 나 -- 로 시작합니다".
시험: rsync_msys_args_test (실제 rsync - C:/ · C:\ · 다음 낱말 · merge · -f. 로 적은 목록의 keep.txt 를 지킴, --log-file=C:/… 에
기록이 생김 · 바꿔 넘기기 단위 · Windows 의 / 막기 · 끝 문구) · dismiss_test (막힐 때 미리 볼 수 없음 · 체크 숨김) ·
option_paste_test (---delete 문구). 전체 시험 1140개 통과 (17 건너뜀) · analyze 문제 없음 · l10n 빠진 번역 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next11
RyuJongJin 2026-10-20 18:24:35 +09:00
parent 86b8ddd603
commit ed459a0229
10 changed files with 161 additions and 18 deletions

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- detected", "- 감지한 언어": "- detected",
"-1 은 아무렇게나": "-1 = random", "-1 은 아무렇게나": "-1 = random",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
"/ 로 시작하는 전체 경로로": "use a full path starting with /",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
"0 으로": "Reset to 0", "0 으로": "Reset to 0",
"1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image", "1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)",
"Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open", "Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open",
"BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "use a full path starting with a drive, like C:\\",
"Chrome (내려받기)": "Chrome (download)", "Chrome (내려받기)": "Chrome (download)",
"Chrome (내장)": "Chrome (built-in)", "Chrome (내장)": "Chrome (built-in)",
"Chrome 에서 가져오기": "Import from Chrome", "Chrome 에서 가져오기": "Import from Chrome",
@ -1127,7 +1129,6 @@
"뒤로": "Back", "뒤로": "Back",
"뒤로 보내고 계속": "Keep running in background", "뒤로 보내고 계속": "Keep running in background",
"뒤에서 계속": "Continue in background", "뒤에서 계속": "Continue in background",
"드라이브 (C:) 나 / 로 시작하는 경로로": "Use a path that starts with a drive (C:) or /",
"드라이브 경로": "Drive path", "드라이브 경로": "Drive path",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted", "드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
"등록": "Register", "등록": "Register",

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- 検出した言語", "- 감지한 언어": "- 検出した言語",
"-1 은 아무렇게나": "-1 はランダム", "-1 은 아무렇게나": "-1 はランダム",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"/ 로 시작하는 전체 경로로": "/ で始まるフルパスで",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
"0 으로": "0 に戻す", "0 으로": "0 に戻す",
"1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中", "1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間", "Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間",
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "C:\\ のようにドライブで始まるフルパスで",
"Chrome (내려받기)": "Chrome(ダウンロード)", "Chrome (내려받기)": "Chrome(ダウンロード)",
"Chrome (내장)": "Chrome(内蔵)", "Chrome (내장)": "Chrome(内蔵)",
"Chrome 에서 가져오기": "Chrome から取り込む", "Chrome 에서 가져오기": "Chrome から取り込む",
@ -1127,7 +1129,6 @@
"뒤로": "戻る", "뒤로": "戻る",
"뒤로 보내고 계속": "裏で続ける", "뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける", "뒤에서 계속": "バックグラウンドで続ける",
"드라이브 (C:) 나 / 로 시작하는 경로로": "ドライブ (C:) か / で始まるパスで",
"드라이브 경로": "ドライブのパス", "드라이브 경로": "ドライブのパス",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
"등록": "登録", "등록": "登録",

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- 检测到的语言", "- 감지한 언어": "- 检测到的语言",
"-1 은 아무렇게나": "-1 为随机", "-1 은 아무렇게나": "-1 为随机",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"/ 로 시작하는 전체 경로로": "请使用以 / 开头的完整路径",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
"0 으로": "归零", "0 으로": "归零",
"1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化", "1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间", "Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间",
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "请使用以驱动器开头的完整路径,如 C:\\",
"Chrome (내려받기)": "Chrome(下载)", "Chrome (내려받기)": "Chrome(下载)",
"Chrome (내장)": "Chrome(内置)", "Chrome (내장)": "Chrome(内置)",
"Chrome 에서 가져오기": "从 Chrome 导入", "Chrome 에서 가져오기": "从 Chrome 导入",
@ -1127,7 +1129,6 @@
"뒤로": "后退", "뒤로": "后退",
"뒤로 보내고 계속": "转到后台继续", "뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续", "뒤에서 계속": "在后台继续",
"드라이브 (C:) 나 / 로 시작하는 경로로": "请使用以盘符 (C:) 或 / 开头的路径",
"드라이브 경로": "驱动器路径", "드라이브 경로": "驱动器路径",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
"등록": "注册", "등록": "注册",

View File

@ -648,8 +648,11 @@ class LiveSync extends ChangeNotifier {
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97) /// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [ static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
...splitOptions(s.rsyncOptions) // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
.where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)), ...rsyncFileOptionArgs([
for (final o in splitOptions(s.rsyncOptions))
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
], windows: windows),
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
if (x.delete) '--delete', if (x.delete) '--delete',

View File

@ -219,7 +219,8 @@ class OptionProblem {
} }
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) /// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
String optionProblemHint(Iterable<OptionProblem> problems) { String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
windows ??= Platform.isWindows;
final kinds = {for (final x in problems) x.kind}; final kinds = {for (final x in problems) x.kind};
return [ return [
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
@ -227,7 +228,8 @@ String optionProblemHint(Iterable<OptionProblem> problems) {
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
if (kinds.contains(OptionProblemKind.relativePath)) tr('드라이브 (C:) 나 / 로 시작하는 경로로'), // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
].join(' · '); ].join(' · ');
} }
@ -256,8 +258,8 @@ List<OptionProblem> optionProblems(String options, {bool? windows}) {
continue; continue;
} }
if (t.startsWith('---')) { if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
bad.add(OptionProblem(OptionProblemKind.odd, t, t)); bad.add(OptionProblem(OptionProblemKind.path, t, t));
} else if (t.startsWith('--')) { } else if (t.startsWith('--')) {
value = !t.contains('=') && _rsyncLongWithValue.contains(t); value = !t.contains('=') && _rsyncLongWithValue.contains(t);
last = t; last = t;
@ -278,12 +280,12 @@ List<OptionProblem> optionProblems(String options, {bool? windows}) {
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
for (final f in listFileValues(parsed.words)) { for (final f in listFileValues(parsed.words)) {
if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
} }
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다. // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 // --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) { for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
} }
return bad; return bad;
} }
@ -332,8 +334,68 @@ List<String> listFileValues(List<String> words) {
return out; return out;
} }
/// 전체 경로 (C:\ · C:/ · \\서버 · / 로 시작 - /cygdrive/c/… 와 Android 경로 포함) /// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다.
bool _absolutePath(String f) => RegExp(r'^(?:[A-Za-z]:[\\/]|[\\/])').hasMatch(f); /// Android: / 로 시작
bool _absolutePath(String f, {required bool windows}) =>
windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
if (!windows) return words;
const opts = {
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
};
String conv(String v) => RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\)').hasMatch(v) ? toCygwinPath(v) : v;
String rule(String r) {
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
return m == null ? r : '${m[1]}${conv(m[2]!)}';
}
final out = <String>[];
for (var i = 0; i < words.length; i++) {
final w = words[i];
final next = i + 1 < words.length ? words[i + 1] : null;
final eq = w.indexOf('=');
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
} else if (opts.contains(w) && next != null) {
out
..add(w)
..add(conv(next));
i++;
} else if (w.startsWith('--filter=')) {
out.add('--filter=${rule(w.substring(9))}');
} else if (w == '--filter' && next != null) {
out
..add(w)
..add(rule(next));
i++;
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
final k = w.indexOf(RegExp('[BefMT@]'), 1);
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
} else if (k >= 0 && w[k] == 'f' && next != null) {
out
..add(w)
..add(rule(next));
i++;
} else {
out.add(w);
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
if (k == w.length - 1 && next != null) {
out.add(next);
i++;
}
}
} else {
out.add(w);
}
}
return out;
}
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). /// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. /// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
@ -364,7 +426,7 @@ List<String> rsyncArgs({
bool contents = false, bool contents = false,
}) { }) {
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
return [ return [
...opts, ...opts,
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',

View File

@ -3513,7 +3513,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
const SizedBox(height: 8), const SizedBox(height: 8),
if (badOptions().isNotEmpty) OptionProblemNote(badOptions()), if (badOptions().isNotEmpty) OptionProblemNote(badOptions()),
if (filterLimits().isNotEmpty) // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
if (badOptions().isEmpty && filterLimits().isNotEmpty)
FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView( SyncPreviewView(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교

View File

@ -258,7 +258,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
const SizedBox(height: 8), const SizedBox(height: 8),
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐) // 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
if (bad.isNotEmpty) OptionProblemNote(bad), if (bad.isNotEmpty) OptionProblemNote(bad),
if (filter.unsupported.isNotEmpty) // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
if (bad.isEmpty && filter.unsupported.isNotEmpty)
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)), FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView( SyncPreviewView(
compute: () async => [ compute: () async => [

View File

@ -219,6 +219,26 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
testWidgets('관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 - "미리 볼 수 없습니다" · [알고 실행합니다] 는 숨김', (tester) async {
final src = p.join(tmp.path, 'bs'), dst = p.join(tmp.path, 'bd');
File(p.join(src, 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('a');
Directory(dst).createSync(recursive: true);
bool? r;
// 미리 볼 수 없는 규칙 (merge) + 알아볼 수 없는 낱말 (delete) - 실행은 막힘
final t = CopyTask(id: '9', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt" delete');
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
expect(find.textContaining('미리 볼 수 없습니다'), findsNothing);
expect(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'), findsNothing);
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
await tester.tap(find.text('취소').last);
await tester.pumpAndSettle();
expect(r, isFalse);
});
testWidgets('177 · 15 검토 F2: 미리 볼 수 없는 거르기 규칙 + --delete 면 빨간 경고 · 체크해야 [실행] 이 켜진다', (tester) async { testWidgets('177 · 15 검토 F2: 미리 볼 수 없는 거르기 규칙 + --delete 면 빨간 경고 · 체크해야 [실행] 이 켜진다', (tester) async {
final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd'); final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd');
File(p.join(src, 'a.txt')) File(p.join(src, 'a.txt'))

View File

@ -117,7 +117,7 @@ void main() {
expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요'); expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요');
expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다'); expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다');
expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)'); expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)');
expect(hint('-a ---delete'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'); expect(hint('-a ---delete'), '옵션은 - 나 -- 로 시작합니다', reason: '관리자 지시 (10/20)');
expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'); expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)'); expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)');
expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다'); expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다');

View File

@ -56,7 +56,6 @@ void main() {
'-a --exclude-from=C:/lists/l.txt', '-a --exclude-from=C:/lists/l.txt',
'-a --include-from=/cygdrive/c/l.txt', '-a --include-from=/cygdrive/c/l.txt',
r'-a --files-from=\\nas\share\l.txt', r'-a --files-from=\\nas\share\l.txt',
'-a --exclude-from /sdcard/l.txt',
'-a --filter=". /cygdrive/c/rules.txt"', '-a --filter=". /cygdrive/c/rules.txt"',
r'-a -f". C:\r.txt"', r'-a -f". C:\r.txt"',
'-a --filter=": .rsync-filter"', '-a --filter=": .rsync-filter"',
@ -75,6 +74,30 @@ void main() {
} }
}); });
test('관리자 지시 (10/20): Windows 는 드라이브 · UNC · /cygdrive/ 만 전체 경로 ("/" 는 MSYS 가 자기 폴더 기준으로 읽음), Android 는 / · 끝 문구', () {
expect(unreadableOptions('-a --exclude-from=/lists/l.txt', windows: true), hasLength(1));
expect(unreadableOptions('-a --exclude-from=/sdcard/l.txt', windows: false), isEmpty);
expect(unreadableOptions('-a --exclude-from=C:/l.txt', windows: false), hasLength(1), reason: 'Android 에서는 / 로 시작해야');
for (final o in [r'-a --exclude-from=\\nas\s\l.txt', '-a --exclude-from=//nas/s/l.txt', '-a --exclude-from=/cygdrive/c/l.txt']) {
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
}
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: true), windows: true), r'C:\ 처럼 드라이브로 시작하는 전체 경로로');
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: false), windows: false), '/ 로 시작하는 전체 경로로');
});
test('관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\\ · C:/ · UNC 는 rsync 가 읽는 꼴로 바꿔 넘긴다 (막지 않음)', () {
expect(rsyncFileOptionArgs([r'--exclude-from=C:\l\a.txt', '--include-from', 'C:/l/b.txt', r'--files-from=\\nas\s\f.txt'], windows: true),
['--exclude-from=/cygdrive/c/l/a.txt', '--include-from', '/cygdrive/c/l/b.txt', '--files-from=//nas/s/f.txt']);
expect(rsyncFileOptionArgs(['--filter=merge,- C:/r/m.txt', '--filter', '. C:/r/d.txt', '-f. C:/r/e.txt', '-avf', 'merge C:/r/g.txt'], windows: true),
['--filter=merge,- /cygdrive/c/r/m.txt', '--filter', '. /cygdrive/c/r/d.txt', '-f. /cygdrive/c/r/e.txt', '-avf', 'merge /cygdrive/c/r/g.txt']);
expect(rsyncFileOptionArgs(['--log-file=C:/logs/r.log', '--write-batch', r'D:\b\batch', '--only-write-batch=C:/b2', '--read-batch=C:/b3'], windows: true),
['--log-file=/cygdrive/c/logs/r.log', '--write-batch', '/cygdrive/d/b/batch', '--only-write-batch=/cygdrive/c/b2', '--read-batch=/cygdrive/c/b3']);
// 바꾸지 않는 것: 패턴 · 폴더마다 읽는 규칙 · 받는 쪽 기준 옵션 · 다른 값 받는 글자 · 이미 /cygdrive · Android
final keep = ['--exclude=C:/x', '--filter=: .rsync-filter', '--filter=- C:/x', '--backup-dir=C:/b', '-e', 'C:/ssh.exe', '--exclude-from=/cygdrive/c/l.txt'];
expect(rsyncFileOptionArgs(keep, windows: true), keep);
expect(rsyncFileOptionArgs([r'--exclude-from=C:\l.txt'], windows: false), [r'--exclude-from=C:\l.txt']);
});
group('실제 rsync', () { group('실제 rsync', () {
final rsync = sceneRsync; final rsync = sceneRsync;
late Directory tmp; late Directory tmp;
@ -134,6 +157,36 @@ void main() {
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
} }
test('28 확인 (10/20): 목록 · 기록 파일을 C:/ · C:\\ 로 적어도 rsync 가 연다 (예전: failed to open exclude file, exit 11) - keep.txt 를 지킴', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final lists = Directory(p.join(tmp.path, 'lists'))..createSync();
File(p.join(lists.path, 'ex.txt')).writeAsStringSync('keep.txt\n');
File(p.join(lists.path, 'rules.txt')).writeAsStringSync('- keep2.txt\n');
final slash = lists.path.replaceAll(r'\', '/');
for (final (name, opts) in [
('--exclude-from=C:/…', '-a --delete --exclude-from=$slash/ex.txt --exclude=keep2.txt'),
(r'--exclude-from=C:\…', '-a --delete --exclude-from="${lists.path}\\ex.txt" --exclude=keep2.txt'),
('--exclude-from C:/… (다음 낱말)', '-a --delete --exclude-from $slash/ex.txt --exclude=keep2.txt'),
('--filter="merge C:/…"', '-a --delete --exclude=keep.txt --filter="merge $slash/rules.txt"'),
('-f". C:/…"', '-a --delete --exclude=keep.txt -f". $slash/rules.txt"'),
]) {
final (s, d) = scene('s_$name'.replaceAll(RegExp(r'[^\w]'), '_'), 'd_$name'.replaceAll(RegExp(r'[^\w]'), '_'), ['a.txt'],
['keep.txt', 'keep2.txt', 'old.txt']);
expect(unreadableOptions(opts), isEmpty, reason: '$name: 막지 않음');
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '$name: ${job.error}');
expect(treeOf(d).keys.toSet(), {'keep.txt', 'keep2.txt', 'a.txt'}, reason: '$name: 목록의 것을 지키고 old.txt 만 지움');
}
// --log-file=C:/… 은 그 자리에 기록을 남긴다 (예전에는 열지 못해 실패)
final (s, d) = scene('s_log', 'd_log', ['a.txt'], []);
final job = TransferJob(
sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a --log-file=$slash/r.log', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '${job.error}');
expect(File(p.join(lists.path, 'r.log')).existsSync(), isTrue);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async { test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']); final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);