WIP 옵션 칸에 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽음 (관리자 지시 10/20, 28 확인, 데이터): 굽은 따옴표는 낱말 앞 · = 뒤 · -f 뒤에서 따옴표로, 낱말 맨 앞 대시 (– — ‑ - 등) 는 - · -- 로, 소프트 하이픈 · 전각 빈칸 고침 - 예전에는 --exclude=‘*.log’ 가 거르지 못해 받는 쪽 keep.log 가 지워졌고 –delete 는 원본 경로로 넘어가 --delete 가 빠졌다 · 그래도 알아볼 수 없는 옵션이 남으면 확인 창 · 설정 칸 · 모니터링에 빨갛게, 실행 · 실시간 동기화는 막음 · 시험 28 의 13가지 (실제 rsync 결과 = 곧은 글자) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review_d2dc267
RyuJongJin 2026-10-20 14:57:25 +09:00
parent f5a01a1ecd
commit d2dc267ae2
11 changed files with 226 additions and 12 deletions

View File

@ -1886,6 +1886,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).", "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).",
"옮길까요?": "move?", "옮길까요?": "move?",
"옵션": "Options", "옵션": "Options",
"옵션을 알아볼 수 없습니다: {0}": "Unrecognized option: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Unrecognized option: {0} - retype it with straight quotes and hyphens (-)",
"옵션을 저장했습니다": "Options saved", "옵션을 저장했습니다": "Options saved",
"완료": "Done", "완료": "Done",
"완료 시 자동 동영상 추가": "Auto-add videos when done", "완료 시 자동 동영상 추가": "Auto-add videos when done",

View File

@ -1886,6 +1886,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。", "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。",
"옮길까요?": "移動しますか?", "옮길까요?": "移動しますか?",
"옵션": "オプション", "옵션": "オプション",
"옵션을 알아볼 수 없습니다: {0}": "オプションを認識できません: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "オプションを認識できません: {0} - 半角の引用符とハイフン (-) で入力し直してください",
"옵션을 저장했습니다": "オプションを保存しました", "옵션을 저장했습니다": "オプションを保存しました",
"완료": "完了", "완료": "完了",
"완료 시 자동 동영상 추가": "完了時に動画を自動追加", "완료 시 자동 동영상 추가": "完了時に動画を自動追加",

View File

@ -1886,6 +1886,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。", "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。",
"옮길까요?": "移动吗?", "옮길까요?": "移动吗?",
"옵션": "选项", "옵션": "选项",
"옵션을 알아볼 수 없습니다: {0}": "无法识别的选项: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "无法识别的选项: {0} - 请用直引号和连字符 (-) 重新输入",
"옵션을 저장했습니다": "已保存选项", "옵션을 저장했습니다": "已保存选项",
"완료": "完成", "완료": "完成",
"완료 시 자동 동영상 추가": "完成后自动添加视频", "완료 시 자동 동영상 추가": "完成后自动添加视频",

View File

@ -510,9 +510,15 @@ class LiveSync extends ChangeNotifier {
return null; return null;
} }
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
List<String> unreadableOf(LiveSyncPair x) => unreadableOptions(_optionsOf(x));
Future<void> syncNow(LiveSyncPair x) async { Future<void> syncNow(LiveSyncPair x) async {
final k = keyOf(x); 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) { if (nest != null) {
status[k] = (DateTime.now(), nest); status[k] = (DateTime.now(), nest);
notifyListeners(); notifyListeners();

View File

@ -268,6 +268,11 @@ class TransferJob extends ChangeNotifier {
Future<void> run() async { Future<void> run() async {
try { 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++) { for (var i = 0; i < sources.length; i++) {
final s = sources[i]; final s = sources[i];
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {

View File

@ -24,29 +24,64 @@ const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · /// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. /// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 /// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
String cleanOptionText(String s) => /// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로
s.replaceAll(RegExp('[\u00A0\u2007\u202F]'), ' ').replaceAll(RegExp('[\u200B\u200C\u200D\u2060\uFEFF]'), ''); 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<String> splitOptions(String s) { List<String> splitOptions(String s) {
final out = <String>[]; final out = <String>[];
final cur = StringBuffer(); final cur = StringBuffer();
String? quote; final chars = cleanOptionText(s).split('');
String? quote; // 닫는 곧은 따옴표 (" 또는 ')
var curly = false; // 굽은 따옴표로 열었는지
var has = 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 (quote != null) {
if (ch == quote) { if (ch == quote || (curly && _curlyQuote[ch] == quote && endAt(i))) {
quote = null; quote = null;
curly = false;
} else { } else {
cur.write(ch); cur.write(ch);
} }
} else if (ch == '"' || ch == "'") { } else if (ch == '"' || ch == "'") {
quote = ch; quote = ch;
has = true; 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) { } else if (ch.trim().isEmpty) {
if (cur.isNotEmpty || has) out.add(cur.toString()); if (cur.isNotEmpty || has) out.add(cur.toString());
cur.clear(); cur.clear();
has = false; 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 { } else {
cur.write(ch); cur.write(ch);
} }
@ -55,6 +90,41 @@ List<String> splitOptions(String s) {
return out; 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<String> unreadableOptions(String options) {
final bad = <String>[];
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). /// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. /// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
String toCygwinPath(String path, {bool windows = true}) { String toCygwinPath(String path, {bool windows = true}) {

View File

@ -76,8 +76,10 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값). /// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값).
/// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다 /// 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)); final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘)
final bad = rsync ? unreadableOptions(ctl.text) : const <String>[];
return SettingTile( return SettingTile(
title: Text(title), title: Text(title),
subtitle: Text(hint), subtitle: Text(hint),
@ -91,6 +93,8 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: bad.isEmpty ? null : trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]),
errorMaxLines: 3,
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: trf('기본값으로 ({0})', [def]), tooltip: trf('기본값으로 ({0})', [def]),
icon: const Icon(Icons.restart_alt, size: 18), icon: const Icon(Icons.restart_alt, size: 18),
@ -189,7 +193,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
// 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다 // 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. ' _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'), '새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v), s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true),
_skipNewerTile(), _skipNewerTile(),
SettingTile( SettingTile(
title: Text(tr('rsync 가져오기')), title: Text(tr('rsync 가져오기')),

View File

@ -3418,6 +3418,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
var filterAck = false; var filterAck = false;
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다 // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다
List<String> badOptions() => {for (final t in tasks) ...unreadableOptions(opts(t))}.toList();
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t))); bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
@ -3493,6 +3495,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
], ],
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
const SizedBox(height: 8), const SizedBox(height: 8),
if (badOptions().isNotEmpty) OptionProblemNote(badOptions()),
if (filterLimits().isNotEmpty) if (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(
@ -3552,7 +3555,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0;
return FilledButton( return FilledButton(
style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
onPressed: nestedDelete() || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck) onPressed: nestedDelete() || badOptions().isNotEmpty || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck)
? null ? null
: () => Navigator.pop(ctx, true), : () => Navigator.pop(ctx, true),
// 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨).

View File

@ -21,7 +21,7 @@ import 'path_label.dart';
import 'rsync_setup.dart'; import 'rsync_setup.dart';
import 'schedule_editor.dart'; import 'schedule_editor.dart';
import '../core/sync_preview.dart' show optionsRemoveSource; 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'; import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. /// 모니터링 (파일 탐색기 > 모니터링): 복사 · 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)])}', '${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), 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)) if (!t.move && optionsRemoveSource(_options.text))
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'), Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),

View File

@ -228,6 +228,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음) // 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음) // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none; final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
final bad = t.method == 'rsync' ? unreadableOptions(t.options) : const <String>[];
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -256,6 +257,7 @@ 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 (filter.unsupported.isNotEmpty) if (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(
@ -295,7 +297,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton( FilledButton(
style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
onPressed: preview == null || (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: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게) child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게)
), ),
], ],
@ -330,6 +334,20 @@ class OptionTextFormatter extends TextInputFormatter {
} }
} }
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
class OptionProblemNote extends StatelessWidget {
final List<String> 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 { class FilterLimitNote extends StatelessWidget {
final List<String> unsupported; final List<String> unsupported;
final bool deletes; final bool deletes;

100
test/option_paste_test.dart Normal file
View File

@ -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<Map<String, String>> 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);
});
}
});
}