diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index d564d5a..119bbd9 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -252,7 +252,7 @@ class TransferJob extends ChangeNotifier { bool get viaWebDav => isDav(dest) || sources.any(isDav); /// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀) - bool get _update => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); + bool get _update => rsyncHasOption(splitOptions(options), short: 'u', long: '--update'); /// 100: WebDAV 를 앱이 맞출 때도 rsync 의 지우기 (--delete 계열) 를 따른다 (확인 창에서 지울 목록을 본 뒤에만 실행됨) bool get _delete => method == CopyMethod.rsync && splitOptions(options).any(isRsyncDeleteOption); diff --git a/lib/core/rsync_filter.dart b/lib/core/rsync_filter.dart index bdf4760..0dbce32 100644 --- a/lib/core/rsync_filter.dart +++ b/lib/core/rsync_filter.dart @@ -1,7 +1,7 @@ import 'dart:convert' show utf8; import 'dart:io' show File, Platform; -import 'sync_tools.dart' show splitOptions; +import 'sync_tools.dart' show splitOptions, rsyncShortWithArg; /// 177: rsync 옵션의 거르기 규칙 (--exclude · --include · --exclude-from · --include-from · --filter "- …" / "+ …") /// 을 실행 전 비교 (미리 보기 · 지울 목록) 에도 똑같이 적용한다. 예전에는 미리 보기가 이 규칙을 보지 않아, @@ -137,7 +137,7 @@ class RsyncFilter { // 예전에는 -f 가 맨 앞일 때만 읽고, 묶음 안의 C · F 를 패턴 글자까지 뒤져 잘못 알렸다 for (var j = 1; j < o.length; j++) { final ch = o[j]; - if ('BefMT@'.contains(ch)) { + if (rsyncShortWithArg.contains(ch)) { var arg = o.substring(j + 1); if (arg.isEmpty && i + 1 < t.length) arg = t[++i]; if (ch == 'f') filterRule(arg); diff --git a/lib/core/sync/copy_commands.dart b/lib/core/sync/copy_commands.dart index dd8590f..0765858 100644 --- a/lib/core/sync/copy_commands.dart +++ b/lib/core/sync/copy_commands.dart @@ -110,7 +110,7 @@ String toCygwinPath(String path, {bool windows = true}) { /// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도). /// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게. String withUpdateOption(String options) { - final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); + final has = rsyncHasOption(splitOptions(options), short: 'u', long: '--update'); return has ? options : '${options.trim()} -u'.trim(); } diff --git a/lib/core/sync/rsync_options.dart b/lib/core/sync/rsync_options.dart index 90979b3..511e5c8 100644 --- a/lib/core/sync/rsync_options.dart +++ b/lib/core/sync/rsync_options.dart @@ -32,6 +32,45 @@ const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', /// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. List splitOptions(String s) => _parseOptions(s).words; +/// 짧은 옵션 묶음에서 값을 받는 rsync 글자 (popt): 그 글자 뒤의 글 (비었으면 다음 낱말) 이 값 - -avf"- *.log" · -rf "- x" +const rsyncShortWithArg = 'BefMT@'; + +/// = 없이 다음 낱말을 값으로 받는 rsync 긴 옵션 (--exclude x · --filter "- y") +const _rsyncLongWithArg = { + 'address', 'backup-dir', 'block-size', 'bwlimit', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', + 'compress-choice', 'compress-level', 'contimeout', 'copy-as', 'copy-dest', 'debug', 'early-input', 'exclude', 'exclude-from', + 'files-from', 'filter', 'groupmap', 'iconv', 'include', 'include-from', 'info', 'link-dest', 'log-file', 'log-file-format', + 'max-alloc', 'max-delete', 'max-size', 'min-size', 'modify-window', 'only-write-batch', 'out-format', 'outbuf', 'partial-dir', + 'password-file', 'port', 'protocol', 'read-batch', 'remote-option', 'rsh', 'rsync-path', 'skip-compress', 'sockopts', + 'stop-after', 'stop-at', 'suffix', 'temp-dir', 'timeout', 'usermap', 'write-batch', +}; + +/// modular_sync #3 OptionText: 옵션 낱말들 ([splitOptions]) 에 rsync 옵션 [short] (한 글자, 짧은 묶음 안에서도) 또는 [long] (--이름) 이 있는지. +/// popt 처럼 읽는다 - 짧은 묶음은 한 글자씩, 값 자리 (값을 받는 글자 뒤 · 값을 받는 옵션의 다음 낱말) 는 옵션으로 보지 않는다. +/// 예전에는 "-로 시작하는 낱말에 그 글자가 있으면" 으로 봐서 -f'- build/' 의 u 를 -u 로 읽었다 (미리 보기 ≠ 실제) +bool rsyncHasOption(List words, {String? short, String? long}) { + for (var i = 0; i < words.length; i++) { + final o = words[i]; + if (o.startsWith('--')) { + final eq = o.indexOf('='); + final name = eq < 0 ? o : o.substring(0, eq); + if (long != null && name == long) return true; + if (eq < 0 && _rsyncLongWithArg.contains(name.substring(2))) i++; + continue; + } + if (o.length < 2 || !o.startsWith('-')) continue; + for (var j = 1; j < o.length; j++) { + final ch = o[j]; + if (short != null && ch == short) return true; + if (rsyncShortWithArg.contains(ch)) { + if (j == o.length - 1) i++; + break; + } + } + } + return false; +} + /// 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', diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index 7c9e4ad..6927fd4 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -2,7 +2,7 @@ import 'dart:io' show FileSystemEntity; import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException; import 'rsync_filter.dart'; -import 'sync_tools.dart' show splitOptions, isSourceRemovingOption, CopyMethod, robocopyExtraOptions; +import 'sync_tools.dart' show splitOptions, isSourceRemovingOption, CopyMethod, robocopyExtraOptions, rsyncHasOption; export 'sync_tools.dart' show isSourceRemovingOption; import 'vfs.dart'; @@ -119,7 +119,7 @@ bool optionsRemoveSource(String options) => splitOptions(options).any(isSourceRe /// rsync 옵션에 -u (받는 쪽이 더 새 파일은 건너뜀) 가 있는지 bool optionsUpdate(String options) => - splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); + rsyncHasOption(splitOptions(options), short: 'u', long: '--update'); /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). diff --git a/test/option_text_contract_test.dart b/test/option_text_contract_test.dart new file mode 100644 index 0000000..ad24ff6 --- /dev/null +++ b/test/option_text_contract_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart' show optionsUpdate; +import 'package:jj_mkvmaker/core/sync_tools.dart'; +import 'package:path/path.dart' as p; + +/// modular_sync #3 OptionText 약속: 옵션 글에 rsync 옵션이 있는지는 popt 규칙 하나 (rsyncHasOption) - 값 자리는 옵션으로 보지 않는다. +/// 예전 -u 확인 세 곳은 "-로 시작하는 낱말에 u 가 있으면" 이라 -f'- build/' 의 u 를 -u 로 읽었다 (미리 보기는 "더 새것 건너뜀", 실제는 덮어씀) +void main() { + test('판단 표: 짧은 묶음 · 값을 받는 글자 뒤 · 값을 받는 옵션의 다음 낱말 · 긴 옵션 · = 값', () { + bool u(String opts) => rsyncHasOption(splitOptions(opts), short: 'u', long: '--update'); + expect([u('-avu'), u('-uav'), u('-a --update'), u('-a -u')], [true, true, true, true]); + expect([u('-avHPOg'), u("-avf'- build/'"), u('-av -f "- run/"'), u('--exclude -u.txt -a'), u('--filter "- u*"'), u('--exclude=-u')], + [false, false, false, false, false, false]); + expect(u("-avf'- x' -u"), isTrue, reason: '값 뒤의 진짜 -u'); + expect([optionsUpdate('-avu'), optionsUpdate("-avf'- build/'")], [true, false], reason: '미리 보기가 쓰는 판단도 같은 하나'); + expect(withUpdateOption("-avf'- build/'"), "-avf'- build/' -u", reason: '⇄ 에서 -u 를 더할 때도 값의 u 를 -u 로 보지 않음'); + }); + + test('실제 rsync: 받는 쪽이 더 새 파일 - 옵션 글마다 앱의 -u 판단과 rsync 가 보내는지가 맞음', () async { + final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe'); + if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('rsync 없음'); + final tmp = Directory.systemTemp.createTempSync('jj_opttext_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final now = DateTime.now(); + File(p.join(tmp.path, 's', 'build.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('old') + ..setLastModifiedSync(now.subtract(const Duration(hours: 1))); + File(p.join(tmp.path, 'd', 'build.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('NEW!') + ..setLastModifiedSync(now); + // 거르기 값에 u 가 든 경우 (- build/ · - run/) 가 예전에 -u 로 오인되던 것 (build.txt 는 거르지 않음) + for (final opts in ['-av', '-avu', "-avf'- build/'", "-avuf'- build/'", '-av -f "- run/"']) { + final args = rsyncArgs(options: opts, sources: [p.join(tmp.path, 's')], dest: p.join(tmp.path, 'd'), contents: true); + final r = await Process.run(rsync, ['--dry-run', '--out-format=%n', ...args]); + expect(r.exitCode, 0, reason: '$opts: ${r.stderr}'); + final sends = '${r.stdout}'.split('\n').any((l) => l.trim() == 'build.txt'); + expect(sends, !optionsUpdate(opts), reason: '$opts: rsync 가 보내면 -u 아님 (${r.stdout})'); + } + }); +}