WIP 옵션 붙여 넣기 보충 (15 검토 F1~F5, 관리자 지시 10/20): 닫히지 않은 따옴표는 뒤 옵션을 삼키지 않고 "따옴표가 닫히지 않았습니다" 로 실행을 막음 (데이터 - 예전에는 뒤의 --exclude 가 사라져 지킬 파일이 지워짐, 곧은 따옴표도) · 대시 하나는 rsync 긴 옵션 이름일 때만 -- (–avz → -avz) · --filter · -f 값의 규칙 머리 대시를 - 로 · 값 자리 대시는 그대로 · “a b”.txt 처럼 닫는 따옴표 뒤에 글이 이어져도 닫음 · 시험 파일의 보이지 않는 글자를 \u 표기로 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review_55caef5
RyuJongJin 2026-10-20 15:52:05 +09:00
parent 0d86a9b163
commit 55caef5c3f
5 changed files with 168 additions and 45 deletions

View File

@ -1128,6 +1128,7 @@
"등록 중 일부가 실패했습니다.": "Some registrations failed.", "등록 중 일부가 실패했습니다.": "Some registrations failed.",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)",
"따옴표가 닫히지 않았습니다: {0}": "Unclosed quote: {0}",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)",
"라오어": "Lao", "라오어": "Lao",
"라이선스 원문 보기": "View full license text", "라이선스 원문 보기": "View full license text",

View File

@ -1128,6 +1128,7 @@
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)",
"따옴표가 닫히지 않았습니다: {0}": "引用符が閉じていません: {0}",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)",
"라오어": "ラオ語", "라오어": "ラオ語",
"라이선스 원문 보기": "ライセンス原文を見る", "라이선스 원문 보기": "ライセンス原文を見る",

View File

@ -1128,6 +1128,7 @@
"등록 중 일부가 실패했습니다.": "部分注册失败。", "등록 중 일부가 실패했습니다.": "部分注册失败。",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)",
"따옴표가 닫히지 않았습니다: {0}": "引号未闭合: {0}",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)",
"라오어": "老挝语", "라오어": "老挝语",
"라이선스 원문 보기": "查看许可证原文", "라이선스 원문 보기": "查看许可证原文",

View File

@ -1,6 +1,7 @@
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'file_ops.dart' show appTempRsyncExcludes; import 'file_ops.dart' show appTempRsyncExcludes;
import '../l10n/tr.dart';
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) /// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
/// ///
@ -36,57 +37,147 @@ const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'
const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'};
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. /// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText].
/// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 /// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는
/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. /// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다.
/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표 뒤가 빈칸 · 끝이면 닫는다. /// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다
/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다.
/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. /// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다.
/// - 낱말 맨 앞의 대시 (– — ‑ - 등): 둘이면 --, 하나 뒤에 소문자 낱말 (3자 이상, 예: –delete) 이면 -- (자동 고침이 -- 를 한 글자로 /// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고
/// 바꾼 것 - "-delete" 로 두면 rsync 가 -d -e … 짧은 옵션으로 읽는다, 내 판단), 그 밖은 -. 낱말 가운데의 대시는 그대로 (파일 이름). /// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다.
List<String> splitOptions(String s) { /// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -.
final out = <String>[]; /// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
final cur = StringBuffer(); List<String> splitOptions(String s) => _parseOptions(s).words;
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
const _rsyncLong = {
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',
'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress',
'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest',
'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del',
'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded',
'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing',
'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help',
'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing',
'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6',
'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc',
'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs',
'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times',
'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial',
'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet',
'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links',
'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at',
'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version',
'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt',
};
/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다)
typedef _Word = ({String text, bool quoted});
/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부)
({List<String> words, List<String> unclosed}) _parseOptions(String s) {
final chars = cleanOptionText(s).split(''); final chars = cleanOptionText(s).split('');
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
final unclosed = <String>[];
while (true) {
final (raw, open) = _splitRaw(chars, literal);
if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed);
literal.add(open);
final rest = chars.sublist(open).join().trim();
unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest);
}
}
/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값)
(List<_Word>, int?) _splitRaw(List<String> chars, Set<int> literal) {
final out = <_Word>[];
final cur = StringBuffer();
String? quote; // 닫는 곧은 따옴표 (" 또는 ') String? quote; // 닫는 곧은 따옴표 (" 또는 ')
var curly = false; // 굽은 따옴표로 열었는지 var curly = false; // 굽은 따옴표로 열었는지
var openAt = -1;
var has = false; var has = false;
bool endAt(int i) => i + 1 >= chars.length || chars[i + 1].trim().isEmpty; var quoted = false;
bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]);
for (var i = 0; i < chars.length; i++) { for (var i = 0; i < chars.length; i++) {
final ch = chars[i]; final ch = chars[i];
if (quote != null) { if (quote != null) {
if (ch == quote || (curly && _curlyQuote[ch] == quote && endAt(i))) { // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다
final closes = ch == quote && !curly ||
curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1));
if (closes) {
quote = null; quote = null;
curly = false; curly = false;
} else { } else {
cur.write(ch); cur.write(ch);
} }
} else if (literal.contains(i)) {
cur.write(ch);
} else if (ch == '"' || ch == "'") { } else if (ch == '"' || ch == "'") {
if (cur.isEmpty && !has) quoted = true;
quote = ch; quote = ch;
openAt = i;
has = true; has = true;
} else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) {
if (cur.isEmpty && !has) quoted = true;
quote = _curlyQuote[ch]; quote = _curlyQuote[ch];
curly = true; curly = true;
openAt = i;
has = true; has = true;
} else if (ch.trim().isEmpty) { } else if (ch.trim().isEmpty) {
if (cur.isNotEmpty || has) out.add(cur.toString()); if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
cur.clear(); cur.clear();
has = false; has = false;
} else if (cur.isEmpty && !has && _dashLike.contains(ch)) { quoted = false;
// 맨 앞의 대시 무리 (곧은 - 가 섞여도 하나로)
var n = 0;
while (i + n < chars.length && n < 2 && (chars[i + n] == '-' || _dashLike.contains(chars[i + n]))) {
n++;
}
final rest = StringBuffer();
for (var j = i + n; j < chars.length && chars[j].trim().isNotEmpty && chars[j] != '='; j++) {
rest.write(chars[j]);
}
cur.write(n >= 2 || RegExp(r'^[a-z][a-z0-9-]{2,}$').hasMatch('$rest') ? '--' : '-');
i += n - 1;
} else { } else {
cur.write(ch); cur.write(ch);
} }
} }
if (cur.isNotEmpty || has) out.add(cur.toString()); if (quote != null) return (out, openAt);
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
return (out, null);
}
bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i]));
/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로
String _fixRuleHead(String v) {
if (v.isEmpty) return v;
if (_dashLike.contains(v[0])) return '-${v.substring(1)}';
if (v[0] == '\uFF0B') return '+${v.substring(1)}';
return v;
}
/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로)
List<String> _normalizeWords(List<_Word> raw) {
final out = <String>[];
var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로)
for (final w in raw) {
var t = w.text;
if (value.isNotEmpty) {
out.add(value == 'filter' ? _fixRuleHead(t) : t);
value = '';
continue;
}
if (!w.quoted && t.isNotEmpty && _dashLike.contains(t[0])) {
final n = _dashAt(t, 1) ? 2 : 1;
final rest = t.substring(n);
final name = rest.split('=').first;
final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3)));
t = '${long ? '--' : '-'}$rest';
}
if (t.startsWith('--filter=')) {
t = '--filter=${_fixRuleHead(t.substring(9))}';
} else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) {
final k = t.indexOf(RegExp('[BefMT@]'), 1);
if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}';
}
out.add(t);
if (t.startsWith('--')) {
if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other';
} else if (t.length > 1 && t.startsWith('-')) {
final k = t.indexOf(RegExp('[BefMT@]'), 1);
if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other';
}
}
return out; return out;
} }
@ -105,9 +196,11 @@ const _rsyncLongWithValue = {
/// (rsync 는 그것을 원본 경로로 읽어 옵션이 몰래 빠진다) 그 낱말들. 있으면 확인 창 · 실시간 동기화에 빨갛게 알리고 실행을 막는다. /// (rsync 는 그것을 원본 경로로 읽어 옵션이 몰래 빠진다) 그 낱말들. 있으면 확인 창 · 실시간 동기화에 빨갛게 알리고 실행을 막는다.
/// 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 /// 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
List<String> unreadableOptions(String options) { List<String> unreadableOptions(String options) {
final bad = <String>[]; final parsed = _parseOptions(options);
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
final bad = [for (final u in parsed.unclosed) trf('따옴표가 닫히지 않았습니다: {0}', [u])];
var value = false; var value = false;
for (final t in splitOptions(options)) { for (final t in parsed.words) {
if (value) { if (value) {
value = false; value = false;
continue; continue;

View File

@ -16,19 +16,26 @@ import 'support/rsync_scenes.dart';
void main() { void main() {
// 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것) // 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것)
const cases = <(String, String, String)>[ const cases = <(String, String, String)>[
('줄바꿈 없는 빈칸 (U+00A0)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), ('줄바꿈 없는 빈칸 (U+00A0)', '-a\u00A0--delete\u00A0--exclude=*.log', '-a --delete --exclude=*.log'),
('화면 글의 U+2060', '-a --delete --exclude=⁠*.log', '-a --delete --exclude=*.log'), ('화면 글의 U+2060', '-a --delete --exclude=\u2060*.log', '-a --delete --exclude=*.log'),
('U+200B', '-a --delete --excl​ude=*.log', '-a --delete --exclude=*.log'), ('U+200B', '-a --delete --excl\u200Bude=*.log', '-a --delete --exclude=*.log'),
('줄바꿈 · 탭', '-a\t--delete\n--exclude=*.log\r\n', '-a --delete --exclude=*.log'), ('줄바꿈 · 탭', '-a\t--delete\n--exclude=*.log\r\n', '-a --delete --exclude=*.log'),
('전각 빈칸 (U+3000)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), ('전각 빈칸 (U+3000)', '-a\u3000--delete\u3000--exclude=*.log', '-a --delete --exclude=*.log'),
('en dash (–delete)', '-a –delete --exclude=*.log', '-a --delete --exclude=*.log'), ('en dash (\u2013delete)', '-a \u2013delete --exclude=*.log', '-a --delete --exclude=*.log'),
('em dash (—delete)', '-a —delete --exclude=*.log', '-a --delete --exclude=*.log'), ('em dash (\u2014delete)', '-a \u2014delete --exclude=*.log', '-a --delete --exclude=*.log'),
('굽은 큰따옴표', '-a --delete --filter=“- *.log”', '-a --delete --filter="- *.log"'), ('굽은 큰따옴표', '-a --delete --filter=\u201C- *.log\u201D', '-a --delete --filter="- *.log"'),
('굽은 작은따옴표', '-a --delete --exclude=‘*.log’', "-a --delete --exclude='*.log'"), ('굽은 작은따옴표', '-a --delete --exclude=\u2018*.log\u2019', "-a --delete --exclude='*.log'"),
('소프트 하이픈 (U+00AD)', '-a --del­ete --exclude=*.log', '-a --delete --exclude=*.log'), ('소프트 하이픈 (U+00AD)', '-a --del\u00ADete --exclude=*.log', '-a --delete --exclude=*.log'),
('줄바꿈 없는 하이픈 (U+2011)', '-a ‑‑delete --exclude=*.log', '-a --delete --exclude=*.log'), ('줄바꿈 없는 하이픈 (U+2011)', '-a \u2011\u2011delete --exclude=*.log', '-a --delete --exclude=*.log'),
('전각 하이픈 (U+FF0D)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), ('전각 하이픈 (U+FF0D)', '-a \uFF0D\uFF0Ddelete --exclude=*.log', '-a --delete --exclude=*.log'),
('BOM 앞', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), ('BOM 앞', '\uFEFF-a --delete --exclude=*.log', '-a --delete --exclude=*.log'),
// 15 확인 (10/20): F2 짧은 옵션 묶음 앞의 대시 하나 · F3 거르기 규칙 머리의 대시 · F5 닫는 따옴표 뒤에 글
('F2 \u2013avz', '\u2013avz --delete --exclude=*.log', '-avz --delete --exclude=*.log'),
('F2 \u2013rlpt', '\u2013rlpt --delete --exclude=*.log', '-rlpt --delete --exclude=*.log'),
('F3 --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter="- *.log"'),
('F3 -f\u201C\u2013 *.log\u201D', '-a --delete -f\u201C\u2013 *.log\u201D', '-a --delete -f"- *.log"'),
('F3 --filter \u201C\u2013 *.log\u201D (값 따로)', '-a --delete --filter \u201C\u2013 *.log\u201D', '-a --delete --filter "- *.log"'),
('F5 \u201C*\u201D.log', '-a --delete --exclude=\u201C*\u201D.log', '-a --delete --exclude="*".log'),
]; ];
group('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () { group('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () {
@ -52,19 +59,39 @@ void main() {
test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () { test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () {
// 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다 // 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다
expect(splitOptions('--exclude=it’s.txt'), ['--exclude=it’s.txt']); expect(splitOptions('--exclude=it\u2019s.txt'), ['--exclude=it\u2019s.txt']);
expect(splitOptions('--exclude=a–b.txt'), ['--exclude=a–b.txt']); expect(splitOptions('--exclude=a\u2013b.txt'), ['--exclude=a\u2013b.txt']);
// 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음) // 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음)
expect(splitOptions('--exclude=‘it’s a.txt’ -a'), ['--exclude=it’s a.txt', '-a']); expect(splitOptions('--exclude=\u2018it\u2019s a.txt\u2019 -a'), ['--exclude=it\u2019s a.txt', '-a']);
// 짧은 옵션 뒤 (-f“- *.log”) // 짧은 옵션 뒤 (-f“- *.log”)
expect(splitOptions('-f“- *.log”'), ['-f- *.log']); expect(splitOptions('-f\u201C- *.log\u201D'), ['-f- *.log']);
// 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --bwlimit=100) // 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --bwlimit=100)
expect(splitOptions('–av –delete –bwlimit=100'), ['-av', '--delete', '--bwlimit=100']); expect(splitOptions('\u2013av \u2013delete \u2013bwlimit=100'), ['-av', '--delete', '--bwlimit=100']);
});
test('15 확인 F1 (데이터): 닫히지 않은 따옴표는 뒤의 옵션을 삼키지 않고, 알려서 실행을 막는다 (굽은 · 곧은 모두)', () {
for (final q in ['\u2018', '\u201C', '"', "'"]) {
final o = '-a --delete --exclude=${q}a.tmp --exclude=keep.txt';
expect(splitOptions(o), ['-a', '--delete', '--exclude=${q}a.tmp', '--exclude=keep.txt'], reason: q);
expect(unreadableOptions(o).single, contains('따옴표가 닫히지 않았습니다'), reason: q);
expect(RsyncFilter.parse(o).excluded('keep.txt', isDir: false), isTrue, reason: '뒤의 규칙이 살아 있음');
}
// 닫힌 것은 문제 없음
expect(unreadableOptions('-a --exclude="a b.txt" --exclude=\u2018c d\u2019'), isEmpty);
});
test('15 확인 F4 · F5: 값 자리의 대시는 그대로, 닫는 굽은 따옴표 뒤에 글이 이어져도 닫는다', () {
expect(splitOptions('--exclude \u2013x.txt -a'), ['--exclude', '\u2013x.txt', '-a']);
expect(splitOptions('-e \u2013x'), ['-e', '\u2013x']);
expect(splitOptions('--exclude=\u201Ca b\u201D.txt'), ['--exclude=a b.txt']);
expect(splitOptions('--exclude=\u2018a b\u2019.txt'), ['--exclude=a b.txt']);
// 긴 옵션 이름 (–no-perms · –dry-run) 은 --, 목록에 없는 것은 짧은 옵션 묶음
expect(splitOptions('\u2013no-perms \u2013dry-run \u2013avh'), ['--no-perms', '--dry-run', '-avh']);
}); });
test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () { test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () {
expect(unreadableOptions('-a «delete»'), ['«delete»']); expect(unreadableOptions('-a \u00ABdelete\u00BB'), ['\u00ABdelete\u00BB']);
expect(unreadableOptions('-a …delete'), ['…delete']); expect(unreadableOptions('-a \u2026delete'), ['\u2026delete']);
expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값'); expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값');
expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값'); expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값');
expect(unreadableOptions('-a 한글'), ['한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다'); expect(unreadableOptions('-a 한글'), ['한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다');
@ -72,7 +99,7 @@ void main() {
}); });
testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async { testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['«delete»'])))); await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['\u00ABdelete\u00BB']))));
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget); expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
}); });