diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 193e72d..3573900 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1886,6 +1886,8 @@ "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).", "옮길까요?": "move?", "옵션": "Options", + "옵션을 알아볼 수 없습니다: {0}": "Unrecognized option: {0}", + "옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Unrecognized option: {0} - retype it with straight quotes and hyphens (-)", "옵션을 저장했습니다": "Options saved", "완료": "Done", "완료 시 자동 동영상 추가": "Auto-add videos when done", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index dc04b74..93eaf58 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1886,6 +1886,8 @@ "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。", "옮길까요?": "移動しますか?", "옵션": "オプション", + "옵션을 알아볼 수 없습니다: {0}": "オプションを認識できません: {0}", + "옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "オプションを認識できません: {0} - 半角の引用符とハイフン (-) で入力し直してください", "옵션을 저장했습니다": "オプションを保存しました", "완료": "完了", "완료 시 자동 동영상 추가": "完了時に動画を自動追加", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 276f223..86642e7 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1886,6 +1886,8 @@ "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。", "옮길까요?": "移动吗?", "옵션": "选项", + "옵션을 알아볼 수 없습니다: {0}": "无法识别的选项: {0}", + "옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "无法识别的选项: {0} - 请用直引号和连字符 (-) 重新输入", "옵션을 저장했습니다": "已保存选项", "완료": "完成", "완료 시 자동 동영상 추가": "完成后自动添加视频", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 843cd08..f64ee3c 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -510,9 +510,15 @@ class LiveSync extends ChangeNotifier { return null; } + /// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게) + List unreadableOf(LiveSyncPair x) => unreadableOptions(_optionsOf(x)); + Future syncNow(LiveSyncPair x) async { final k = keyOf(x); - final nest = nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter); + final bad = unreadableOf(x); + final nest = bad.isNotEmpty + ? trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]) + : nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter); if (nest != null) { status[k] = (DateTime.now(), nest); notifyListeners(); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index c78357e..2d82eab 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -268,6 +268,11 @@ class TransferJob extends ChangeNotifier { Future run() async { try { + // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐) + if (method == CopyMethod.rsync) { + final bad = unreadableOptions(options); + if (bad.isNotEmpty) throw StateError(trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')])); + } for (var i = 0; i < sources.length; i++) { final s = sources[i]; if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 22dac21..e1ef635 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -24,29 +24,64 @@ const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; /// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · /// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. /// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 -String cleanOptionText(String s) => - s.replaceAll(RegExp('[\u00A0\u2007\u202F]'), ' ').replaceAll(RegExp('[\u200B\u200C\u200D\u2060\uFEFF]'), ''); +/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로 +String cleanOptionText(String s) => s + .replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ') + .replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), ''); -/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText] +/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것 +const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'; + +/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다) +const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; + +/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. +/// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 +/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. +/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표 뒤가 빈칸 · 끝이면 닫는다. +/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. +/// - 낱말 맨 앞의 대시 (– — ‑ - 등): 둘이면 --, 하나 뒤에 소문자 낱말 (3자 이상, 예: –delete) 이면 -- (자동 고침이 -- 를 한 글자로 +/// 바꾼 것 - "-delete" 로 두면 rsync 가 -d -e … 짧은 옵션으로 읽는다, 내 판단), 그 밖은 -. 낱말 가운데의 대시는 그대로 (파일 이름). List splitOptions(String s) { final out = []; final cur = StringBuffer(); - String? quote; + final chars = cleanOptionText(s).split(''); + String? quote; // 닫는 곧은 따옴표 (" 또는 ') + var curly = false; // 굽은 따옴표로 열었는지 var has = false; - for (final ch in cleanOptionText(s).split('')) { + bool endAt(int i) => i + 1 >= chars.length || chars[i + 1].trim().isEmpty; + for (var i = 0; i < chars.length; i++) { + final ch = chars[i]; if (quote != null) { - if (ch == quote) { + if (ch == quote || (curly && _curlyQuote[ch] == quote && endAt(i))) { quote = null; + curly = false; } else { cur.write(ch); } } else if (ch == '"' || ch == "'") { quote = ch; has = true; + } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { + quote = _curlyQuote[ch]; + curly = true; + has = true; } else if (ch.trim().isEmpty) { if (cur.isNotEmpty || has) out.add(cur.toString()); cur.clear(); has = false; + } else if (cur.isEmpty && !has && _dashLike.contains(ch)) { + // 맨 앞의 대시 무리 (곧은 - 가 섞여도 하나로) + 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 { cur.write(ch); } @@ -55,6 +90,41 @@ List splitOptions(String s) { return out; } +/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게 +const _rsyncLongWithValue = { + '--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix', + '--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit', + '--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir', + '--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv', + '--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug', + '--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at', + '--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input', +}; + +/// 관리자 지시 (10/20, 28 확인): [splitOptions] 로 고쳐 읽은 뒤에도 옵션 자리에 ASCII 밖 글자로 시작하는 낱말이 남으면 +/// (rsync 는 그것을 원본 경로로 읽어 옵션이 몰래 빠진다) 그 낱말들. 있으면 확인 창 · 실시간 동기화에 빨갛게 알리고 실행을 막는다. +/// 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 +List unreadableOptions(String options) { + final bad = []; + var value = false; + for (final t in splitOptions(options)) { + if (value) { + value = false; + continue; + } + if (t.startsWith('--')) { + value = !t.contains('=') && _rsyncLongWithValue.contains(t); + } else if (t.length > 1 && t.startsWith('-')) { + // 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙) + final k = t.indexOf(RegExp('[BefMT@]'), 1); + value = k == t.length - 1; + } else if (t.isNotEmpty && t.codeUnitAt(0) > 0x7F) { + bad.add(t); + } + } + return bad; +} + /// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). /// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. String toCygwinPath(String path, {bool windows = true}) { diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 56d4a6d..a04406a 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -76,8 +76,10 @@ class _CopySyncSettingsState extends State { /// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값). /// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다 - Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) { + Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set, {bool rsync = false}) { final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value)); + // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘) + final bad = rsync ? unreadableOptions(ctl.text) : const []; return SettingTile( title: Text(title), subtitle: Text(hint), @@ -91,6 +93,8 @@ class _CopySyncSettingsState extends State { decoration: InputDecoration( isDense: true, border: const OutlineInputBorder(), + errorText: bad.isEmpty ? null : trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]), + errorMaxLines: 3, suffixIcon: IconButton( tooltip: trf('기본값으로 ({0})', [def]), icon: const Icon(Icons.restart_alt, size: 18), @@ -189,7 +193,7 @@ class _CopySyncSettingsState extends State { // 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다 _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. ' '새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'), - s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v), + s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true), _skipNewerTile(), SettingTile( title: Text(tr('rsync 가져오기')), diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index a06be22..9f75c83 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3418,6 +3418,8 @@ class _ExplorerPageState extends State { var filterAck = false; // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다 List filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); + // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다 + List badOptions() => {for (final t in tasks) ...unreadableOptions(opts(t))}.toList(); // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 bool deletes() => tasks.any((t) => optionsDelete(opts(t))); // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 @@ -3493,6 +3495,7 @@ class _ExplorerPageState extends State { ], // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) const SizedBox(height: 8), + if (badOptions().isNotEmpty) OptionProblemNote(badOptions()), if (filterLimits().isNotEmpty) FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), SyncPreviewView( @@ -3552,7 +3555,7 @@ class _ExplorerPageState extends State { final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; return FilledButton( style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, - onPressed: nestedDelete() || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck) + onPressed: nestedDelete() || badOptions().isNotEmpty || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true), // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 2e6431f..cc35e4c 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -21,7 +21,7 @@ import 'path_label.dart'; import 'rsync_setup.dart'; import 'schedule_editor.dart'; import '../core/sync_preview.dart' show optionsRemoveSource; -import 'sync_preview_view.dart' show FilterLimitNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, unbreakableOption; +import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, unbreakableOption; import 'theme.dart'; /// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. @@ -662,6 +662,8 @@ class _CopyCardState extends State<_CopyCard> { '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}', style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim), ), + // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다) + if (_method == 'rsync' && unreadableOptions(_options.text).isNotEmpty) OptionProblemNote(unreadableOptions(_options.text)), if (!t.move && optionsRemoveSource(_options.text)) Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 015fa1d..f9bca8a 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -228,6 +228,7 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM // 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음) // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음) final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none; + final bad = t.method == 'rsync' ? unreadableOptions(t.options) : const []; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -256,6 +257,7 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM ), const SizedBox(height: 8), // 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐) + if (bad.isNotEmpty) OptionProblemNote(bad), if (filter.unsupported.isNotEmpty) FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)), SyncPreviewView( @@ -295,7 +297,9 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, - onPressed: preview == null || (deletes && filter.unsupported.isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true), + onPressed: preview == null || bad.isNotEmpty || (deletes && filter.unsupported.isNotEmpty && !filterAck) + ? null + : () => Navigator.pop(ctx, true), child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게) ), ], @@ -330,6 +334,20 @@ class OptionTextFormatter extends TextInputFormatter { } } +/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐) +class OptionProblemNote extends StatelessWidget { + final List bad; + const OptionProblemNote(this.bad, {super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + trf('옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요', [bad.map(unbreakableOption).join(' · ')]), + style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)), + ); +} + class FilterLimitNote extends StatelessWidget { final List unsupported; final bool deletes; diff --git a/test/option_paste_test.dart b/test/option_paste_test.dart new file mode 100644 index 0000000..4c6f920 --- /dev/null +++ b/test/option_paste_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/rsync_filter.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart' show optionsDelete; +import 'package:jj_mkvmaker/core/sync_tools.dart'; +import 'package:jj_mkvmaker/ui/sync_preview_view.dart'; +import 'package:path/path.dart' as p; + +import 'support/rsync_scenes.dart'; + +/// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 옵션 칸에 붙여 넣은 글 (굽은 따옴표 · 대시 · 보이지 않는 글자) 을 +/// 곧은 글자로 쓴 것과 똑같이 읽는다. 예전에는 --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, +/// –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. 보이지 않는 글자는 \u 표기로 쓴다 +void main() { + // 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것) + const cases = <(String, String, String)>[ + ('줄바꿈 없는 빈칸 (U+00A0)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('화면 글의 U+2060', '-a --delete --exclude=⁠*.log', '-a --delete --exclude=*.log'), + ('U+200B', '-a --delete --excl​ude=*.log', '-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'), + ('en dash (–delete)', '-a –delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('em dash (—delete)', '-a —delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('굽은 큰따옴표', '-a --delete --filter=“- *.log”', '-a --delete --filter="- *.log"'), + ('굽은 작은따옴표', '-a --delete --exclude=‘*.log’', "-a --delete --exclude='*.log'"), + ('소프트 하이픈 (U+00AD)', '-a --del­ete --exclude=*.log', '-a --delete --exclude=*.log'), + ('줄바꿈 없는 하이픈 (U+2011)', '-a ‑‑delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('전각 하이픈 (U+FF0D)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('BOM 앞', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), + ]; + + group('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () { + for (final (name, pasted, straight) in cases) { + test(name, () { + // 옵션 칸 (OptionTextFormatter) 을 거친 글도, 거치지 않은 글 (예전에 저장된 값) 도 + final typed = const OptionTextFormatter() + .formatEditUpdate(TextEditingValue.empty, TextEditingValue(text: pasted, selection: TextSelection.collapsed(offset: pasted.length))) + .text; + for (final s in [pasted, typed]) { + expect(splitOptions(s), splitOptions(straight)); + expect(unreadableOptions(s), isEmpty); + expect(optionsDelete(s), optionsDelete(straight)); + final f = RsyncFilter.parse(s), g = RsyncFilter.parse(straight); + expect(f.excluded('keep.log', isDir: false), g.excluded('keep.log', isDir: false)); + expect(f.excluded('a.txt', isDir: false), isFalse); + } + }); + } + }); + + test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () { + // 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다 + expect(splitOptions('--exclude=it’s.txt'), ['--exclude=it’s.txt']); + expect(splitOptions('--exclude=a–b.txt'), ['--exclude=a–b.txt']); + // 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음) + expect(splitOptions('--exclude=‘it’s a.txt’ -a'), ['--exclude=it’s a.txt', '-a']); + // 짧은 옵션 뒤 (-f“- *.log”) + expect(splitOptions('-f“- *.log”'), ['-f- *.log']); + // 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --bwlimit=100) + expect(splitOptions('–av –delete –bwlimit=100'), ['-av', '--delete', '--bwlimit=100']); + }); + + test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () { + expect(unreadableOptions('-a «delete»'), ['«delete»']); + expect(unreadableOptions('-a …delete'), ['…delete']); + expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값'); + expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값'); + expect(unreadableOptions('-a 한글'), ['한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다'); + expect(unreadableOptions('-avPog --delete'), isEmpty); + }); + + testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['«delete»'])))); + expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget); + }); + + // 실제 rsync (Windows, 앱에 든 것): 붙여 넣은 글로 맞춘 결과 = 곧은 글자로 맞춘 결과 (28 의 장면: 받는 쪽 keep.log · old.txt) + group('실제 rsync 결과가 같다', () { + for (final (name, pasted, straight) in cases) { + test(name, () async { + final rsync = sceneRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)'); + final tmp = Directory.systemTemp.createTempSync('jj_optpaste_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + Future> run(String opts, String tag) async { + final s = p.join(tmp.path, '${tag}s'), d = p.join(tmp.path, '${tag}d'); + writeScene(s, d, srcFiles: const ['a.txt', 'b.log'], dstFiles: const ['old.txt', 'keep.log']); + return realRsyncTree(rsync, s, d, opts); + } + + final want = await run(straight, 'w'); + expect(want.containsKey('keep.log'), isTrue, reason: '곧은 글자: *.log 는 거르므로 받는 쪽 keep.log 를 지키고'); + expect(want.containsKey('old.txt'), isFalse, reason: '--delete 로 old.txt 는 지움'); + expect(await run(pasted, 'g'), want); + }); + } + }); +}