next9: + msys_args 끝 (634fcbc · 849c830: 목록 · 기록 · 배치 파일의 상대 경로 막기) - 시험판 985

합침 (d3): 상대 경로 막기를 optionProblems 의 새 종류 relativePath 로 (안내 끝 문구 "드라이브 (C:) 나 / 로 시작하는 경로로", 내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next9 v2026.10.18_985
RyuJongJin 2026-10-20 17:14:23 +09:00
commit f947935c19
7 changed files with 110 additions and 4 deletions

View File

@ -822,6 +822,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({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.", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
"기능 버튼 줄": "Button bar", "기능 버튼 줄": "Button bar",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the log or batch file: {0}",
"기본": "Default", "기본": "Default",
"기본 DNS": "Primary DNS", "기본 DNS": "Primary DNS",
"기본 앱으로 열기": "Open with default app", "기본 앱으로 열기": "Open with default app",
@ -1126,6 +1127,7 @@
"뒤로": "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",
@ -1241,6 +1243,7 @@
"목록": "list", "목록": "list",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 보기": "View list", "목록 보기": "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.", "목록에 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", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here",
"목록에서 빼기": "Remove from list", "목록에서 빼기": "Remove from list",

View File

@ -822,6 +822,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
"기능 버튼 줄": "機能ボタンの列", "기능 버튼 줄": "機能ボタンの列",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "ログ・バッチファイルはフルパスで書いてください: {0}",
"기본": "既定", "기본": "既定",
"기본 DNS": "優先 DNS", "기본 DNS": "優先 DNS",
"기본 앱으로 열기": "既定のアプリで開く", "기본 앱으로 열기": "既定のアプリで開く",
@ -1126,6 +1127,7 @@
"뒤로": "戻る", "뒤로": "戻る",
"뒤로 보내고 계속": "裏で続ける", "뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける", "뒤에서 계속": "バックグラウンドで続ける",
"드라이브 (C:) 나 / 로 시작하는 경로로": "ドライブ (C:) か / で始まるパスで",
"드라이브 경로": "ドライブのパス", "드라이브 경로": "ドライブのパス",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
"등록": "登録", "등록": "登録",
@ -1241,6 +1243,7 @@
"목록": "一覧", "목록": "一覧",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る", "목록 보기": "リストを見る",
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
"목록에서 빼기": "リストから外す", "목록에서 빼기": "リストから外す",

View File

@ -822,6 +822,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
"기능 버튼 줄": "功能按钮栏", "기능 버튼 줄": "功能按钮栏",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "请填写日志或批处理文件的完整路径: {0}",
"기본": "默认", "기본": "默认",
"기본 DNS": "首选 DNS", "기본 DNS": "首选 DNS",
"기본 앱으로 열기": "用默认应用打开", "기본 앱으로 열기": "用默认应用打开",
@ -1126,6 +1127,7 @@
"뒤로": "后退", "뒤로": "后退",
"뒤로 보내고 계속": "转到后台继续", "뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续", "뒤에서 계속": "在后台继续",
"드라이브 (C:) 나 / 로 시작하는 경로로": "请使用以盘符 (C:) 或 / 开头的路径",
"드라이브 경로": "驱动器路径", "드라이브 경로": "驱动器路径",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
"등록": "注册", "등록": "注册",
@ -1241,6 +1243,7 @@
"목록": "列表", "목록": "列表",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表", "목록 보기": "查看列表",
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
"목록에서 빼기": "从列表中移除", "목록에서 빼기": "从列表中移除",

View File

@ -203,7 +203,7 @@ const _rsyncLongWithValue = {
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text]; List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적) /// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote } enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath }
/// [unreadableOptions] 의 한 줄과 그 종류 /// [unreadableOptions] 의 한 줄과 그 종류
class OptionProblem { class OptionProblem {
@ -227,6 +227,7 @@ 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:) 나 / 로 시작하는 경로로'),
].join(' · '); ].join(' · ');
} }
@ -274,9 +275,66 @@ List<OptionProblem> optionProblems(String options, {bool? windows}) {
} }
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); 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; return bad;
} }
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
List<String> _optionValues(List<String> words, Set<String> 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<String> listFileValues(List<String> words) {
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
final out = <String>[];
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). /// 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

@ -229,11 +229,11 @@ void main() {
..writeAsStringSync('o'); ..writeAsStringSync('o');
bool? r; bool? r;
// 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로 // 관리자 지시 (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 host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
expect(find.textContaining('미리 볼 수 없습니다'), findsOneWidget); 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<FilledButton>(find.widgetWithText(FilledButton, '실행')); FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행'));
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음'); expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')); await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));

View File

@ -311,7 +311,7 @@ void main() {
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async { testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); 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); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair]; c.settings.liveSyncPairs = [pair];
final live = LiveSync(c); final live = LiveSync(c);

View File

@ -36,6 +36,45 @@ void main() {
expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty); 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', () { group('실제 rsync', () {
final rsync = sceneRsync; final rsync = sceneRsync;
late Directory tmp; late Directory tmp;