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
parent
86b8ddd603
commit
ed459a0229
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:) か / で始まるパスで",
|
|
||||||
"드라이브 경로": "ドライブのパス",
|
"드라이브 경로": "ドライブのパス",
|
||||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
|
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
|
||||||
"등록": "登録",
|
"등록": "登録",
|
||||||
|
|
|
||||||
|
|
@ -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:) 或 / 开头的路径",
|
|
||||||
"드라이브 경로": "驱动器路径",
|
"드라이브 경로": "驱动器路径",
|
||||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
|
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
|
||||||
"등록": "注册",
|
"등록": "注册",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
|
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
|
||||||
|
|
|
||||||
|
|
@ -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 => [
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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 - 옵션은 - 나 -- 로 시작합니다');
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue