diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3ed0b4b..8078b98 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -822,6 +822,7 @@ "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.", "기능 버튼 줄": "Button bar", + "기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the log or batch file: {0}", "기본": "Default", "기본 DNS": "Primary DNS", "기본 앱으로 열기": "Open with default app", @@ -1126,6 +1127,7 @@ "뒤로": "Back", "뒤로 보내고 계속": "Keep running in background", "뒤에서 계속": "Continue in background", + "드라이브 (C:) 나 / 로 시작하는 경로로": "Use a path that starts with a drive (C:) or /", "드라이브 경로": "Drive path", "드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted", "등록": "Register", @@ -1241,6 +1243,7 @@ "목록": "list", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 보기": "View list", + "목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here", "목록에서 빼기": "Remove from list", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0c870c4..ccb57d8 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -822,6 +822,7 @@ "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。", "기능 버튼 줄": "機能ボタンの列", + "기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "ログ・バッチファイルはフルパスで書いてください: {0}", "기본": "既定", "기본 DNS": "優先 DNS", "기본 앱으로 열기": "既定のアプリで開く", @@ -1126,6 +1127,7 @@ "뒤로": "戻る", "뒤로 보내고 계속": "裏で続ける", "뒤에서 계속": "バックグラウンドで続ける", + "드라이브 (C:) 나 / 로 시작하는 경로로": "ドライブ (C:) か / で始まるパスで", "드라이브 경로": "ドライブのパス", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "등록": "登録", @@ -1241,6 +1243,7 @@ "목록": "一覧", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 보기": "リストを見る", + "목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます", "목록에서 빼기": "リストから外す", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 540cb8b..faec53e 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -822,6 +822,7 @@ "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。", "기능 버튼 줄": "功能按钮栏", + "기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "请填写日志或批处理文件的完整路径: {0}", "기본": "默认", "기본 DNS": "首选 DNS", "기본 앱으로 열기": "用默认应用打开", @@ -1126,6 +1127,7 @@ "뒤로": "后退", "뒤로 보내고 계속": "转到后台继续", "뒤에서 계속": "在后台继续", + "드라이브 (C:) 나 / 로 시작하는 경로로": "请使用以盘符 (C:) 或 / 开头的路径", "드라이브 경로": "驱动器路径", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "등록": "注册", @@ -1241,6 +1243,7 @@ "목록": "列表", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 보기": "查看列表", + "목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情", "목록에서 빼기": "从列表中移除", diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 396a4dd..bacd2ae 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -203,7 +203,7 @@ const _rsyncLongWithValue = { List unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text]; /// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적) -enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote } +enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath } /// [unreadableOptions] 의 한 줄과 그 종류 class OptionProblem { @@ -227,6 +227,7 @@ String optionProblemHint(Iterable problems) { if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), + if (kinds.contains(OptionProblemKind.relativePath)) tr('드라이브 (C:) 나 / 로 시작하는 경로로'), ].join(' · '); } @@ -274,9 +275,66 @@ List optionProblems(String options, {bool? windows}) { } // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); + // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 + // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 + for (final f in listFileValues(parsed.words)) { + if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + } + // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다. + // --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'})) { + if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + } return bad; } +/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값) +List _optionValues(List words, Set names) => [ + for (var i = 0; i < words.length; i++) + if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('=')))) + words[i].substring(words[i].indexOf('=') + 1) + else if (names.contains(words[i]) && i + 1 < words.length) + words[i + 1], + ]; + +/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일"). +/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고 +List listFileValues(List words) { + const fileOpts = {'--exclude-from', '--include-from', '--files-from'}; + final out = []; + String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1); + 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 && fileOpts.contains(w.substring(0, eq))) { + out.add(w.substring(eq + 1)); + } else if (fileOpts.contains(w) && next != null) { + out.add(next); + i++; + } else if (w.startsWith('--filter=')) { + final f = mergeFile(w.substring(9)); + if (f != null) out.add(f); + } else if (w == '--filter' && next != null) { + final f = mergeFile(next); + if (f != null) out.add(f); + 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') continue; + final rule = k == w.length - 1 ? next : w.substring(k + 1); + if (k == w.length - 1) i++; + final f = rule == null ? null : mergeFile(rule); + if (f != null) out.add(f); + } + } + return out; +} + +/// 전체 경로 (C:\ · C:/ · \\서버 · / 로 시작 - /cygdrive/c/… 와 Android 경로 포함) +bool _absolutePath(String f) => RegExp(r'^(?:[A-Za-z]:[\\/]|[\\/])').hasMatch(f); + /// 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/test/dismiss_test.dart b/test/dismiss_test.dart index 7704d52..e5400e3 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -229,11 +229,11 @@ void main() { ..writeAsStringSync('o'); bool? r; // 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로 - final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge rules.txt"'); + final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt"'); // 목록 파일은 전체 경로 (관리자 판단 10/20) 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('merge rules.txt'), findsWidgets); + expect(find.textContaining('merge C:/jj_rules/rules.txt'), findsWidgets); FilledButton run() => tester.widget(find.widgetWithText(FilledButton, '실행')); expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음'); await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')); diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart index d94d63b..6834bd8 100644 --- a/test/live_sync_scene_test.dart +++ b/test/live_sync_scene_test.dart @@ -311,7 +311,7 @@ void main() { testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); - c.settings.rsyncOptions = '-a --filter="merge rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) + c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로 final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); diff --git a/test/rsync_msys_args_test.dart b/test/rsync_msys_args_test.dart index f782fb8..45a42a2 100644 --- a/test/rsync_msys_args_test.dart +++ b/test/rsync_msys_args_test.dart @@ -36,6 +36,45 @@ void main() { expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty); }); + test('관리자 판단 (10/20): 목록 파일의 상대 경로는 막는다 (rsync 와 미리 보기가 다른 작업 폴더 기준으로 읽음) - 전체 경로 · 폴더마다 읽는 규칙은 그대로', () { + for (final o in [ + '-a --exclude-from=list.txt', + '-a --exclude-from list.txt', + '-a --include-from=./a.txt', + '-a --files-from=sub/files.txt', + '-a --filter=". rules.txt"', + '-a --filter="merge,- rules.txt"', + '-a --filter ". w.txt"', + '-a -f". y.txt"', + '-avf ". z.txt"', + '-a --exclude-from=~/list.txt', + ]) { + expect(unreadableOptions(o, windows: true), [contains('목록 파일은 전체 경로로 적어 주세요')], reason: o); + } + for (final o in [ + r'-a --exclude-from=C:\lists\l.txt', + '-a --exclude-from=C:/lists/l.txt', + '-a --include-from=/cygdrive/c/l.txt', + r'-a --files-from=\\nas\share\l.txt', + '-a --exclude-from /sdcard/l.txt', + '-a --filter=". /cygdrive/c/rules.txt"', + r'-a -f". C:\r.txt"', + '-a --filter=": .rsync-filter"', + '-a --filter="dir-merge .x"', + '-a -F', + '-a --exclude=list.txt', + // 받는 쪽 기준이라 그대로 (28 확인) + '-a --backup-dir=old --partial-dir=.part --temp-dir=tmp --compare-dest=../base', + r'-a --log-file=C:\logs\r.log --write-batch=/cygdrive/c/b/batch', + ]) { + expect(unreadableOptions(o, windows: true), isEmpty, reason: o); + } + // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 전체 경로만 (상대면 응답 파일 임시 폴더에 생겼다가 함께 지워짐) + for (final o in ['-a --log-file=r.log', '-a --log-file r.log', '-a --write-batch=b', '-a --only-write-batch b', '-a --read-batch=./b']) { + expect(unreadableOptions(o, windows: true), [contains('기록 · 배치 파일은 전체 경로로 적어 주세요')], reason: o); + } + }); + group('실제 rsync', () { final rsync = sceneRsync; late Directory tmp;