From 343ef533cd75c318a33af03887b72db1a8c20955 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:56:14 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20D:=20sync?= =?UTF-8?q?=5Ftools=20(656)=20=EB=A5=BC=20lib/core/sync/=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=203=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95?= =?UTF-8?q?=EB=A9=B4=20sync=5Ftools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rsync_options (옵션 글 고치기 · 나누기 · 문제 찾기 · 고칠 방법 · 목록 파일 값) - copy_commands (복사 방법 · rsync 인수 · MSYS 경로 · robocopy 묶음 · 성공 코드 · MSYS2 rsync 패키지) - copy_output (rsync · robocopy 출력 읽기) - sync_tools.dart 는 export 만 (부르는 쪽은 바꾸지 않음). 임시 이름 제외는 files/path_rules 를 부름 - 약속 시험: test/sync_modules_contract_test.dart Co-Authored-By: Claude Opus 5.5 --- lib/core/sync/copy_commands.dart | 238 ++++++++++ lib/core/sync/copy_output.dart | 102 +++++ lib/core/sync/rsync_options.dart | 328 +++++++++++++ lib/core/sync_tools.dart | 662 +-------------------------- test/sync_modules_contract_test.dart | 45 ++ 5 files changed, 719 insertions(+), 656 deletions(-) create mode 100644 lib/core/sync/copy_commands.dart create mode 100644 lib/core/sync/copy_output.dart create mode 100644 lib/core/sync/rsync_options.dart create mode 100644 test/sync_modules_contract_test.dart diff --git a/lib/core/sync/copy_commands.dart b/lib/core/sync/copy_commands.dart new file mode 100644 index 0000000..dd8590f --- /dev/null +++ b/lib/core/sync/copy_commands.dart @@ -0,0 +1,238 @@ +import 'package:path/path.dart' as p; + +import '../files/path_rules.dart' show appTempRsyncExcludes; +import 'rsync_options.dart'; + +// 약속 (나누기 sync_tools): 복사 방법 (앱 · rsync · robocopy) 과 그 명령 인수 - rsync 인수 · Windows 경로를 MSYS2 꼴로 · +// robocopy 실행 묶음 · 성공 종료 코드 · Windows 용 rsync 패키지 (MSYS2, SHA256 고정). +// 입력: 옵션 글 · 원본들 · 대상 · 속도 제한 · 이동인지. 출력: 인수 목록 (실행은 부르는 쪽). 오류: 던지지 않는다. + +/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) +/// +/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기. +/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일) +/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만) +enum CopyMethod { + builtin('현재 방식'), + rsync('rsync'), + robocopy('robocopy'); + + final String label; + const CopyMethod(this.label); + + static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin); +} + +const defaultRsyncOptions = '-avPog'; +const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; + +/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다 +/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음). +/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch +List rsyncFileOptionArgs(List words, {required bool windows}) { + if (!windows) return words; + const opts = { + '--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch', + }; + // 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로, + // 드라이브는 /cygdrive/… 로 + String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v) + ? toCygwinPath(v) + : RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v) + ? v.replaceAll('/', '\\') + : v; + String rule(String r) { + final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r); + return m == null ? r : '${m[1]}${conv(m[2]!)}'; + } + + final out = []; + 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 && opts.contains(w.substring(0, eq))) { + out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}'); + } else if (opts.contains(w) && next != null) { + out + ..add(w) + ..add(conv(next)); + i++; + } else if (w.startsWith('--filter=')) { + out.add('--filter=${rule(w.substring(9))}'); + } else if (w == '--filter' && next != null) { + out + ..add(w) + ..add(rule(next)); + 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' && k < w.length - 1) { + out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}'); + } else if (k >= 0 && w[k] == 'f' && next != null) { + out + ..add(w) + ..add(rule(next)); + i++; + } else { + out.add(w); + // 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀 + if (k == w.length - 1 && next != null) { + out.add(next); + i++; + } + } + } else { + out.add(w); + } + } + return out; +} + +/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). +/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. +/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다 +/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로 +String rsyncDestPath(String path, {bool windows = true}) { + if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\'); + return toCygwinPath(path, windows: windows); +} + +String toCygwinPath(String path, {bool windows = true}) { + if (!windows) return path; + final s = path.replaceAll('\\', '/'); + final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s); + if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}'; + return s; // //server/share 는 그대로 읽는다 +} + +/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도). +/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게. +String withUpdateOption(String options) { + final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); + return has ? options : '${options.trim()} -u'.trim(); +} + +/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/ +/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과). +List rsyncArgs({ + required String options, + required List sources, + required String dest, + int bandwidthKBps = 0, + bool move = false, + bool windows = true, + bool contents = false, +}) { + // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 + final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows); + return [ + ...opts, + if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', + // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) + if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', + if (move) '--remove-source-files', + ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 + // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) + for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', + '${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/', + ]; +} + +String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s; + +/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178 +bool isSourceRemovingOption(String o) => + o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE'; + +/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다). +/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션] +/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에) +/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다. +List> robocopyRuns({ + required String options, + required List folders, + required List files, + required String dest, + int bandwidthKBps = 0, + bool move = false, + bool contents = false, + bool skipNewer = false, +}) { + // 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만 + final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; + final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps); + final runs = >[ + for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], + ]; + final byDir = >{}; + for (final f in files) { + byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f)); + } + // 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다 + final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList(); + final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList(); + byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra])); + return runs; +} + +/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게 +List robocopyExtraOptions(List opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [ + if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', + if (move) ...robocopyMoveOptions(opts), + // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼). + // 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다. + // 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로 + if (!robocopyLinkOption(opts)) '/XJ', + // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다 + if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO', + ]; + +/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL) +bool robocopyLinkOption(List opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase())); + +/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면 +bool robocopySkipsLinks(List opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase())); + +/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. +/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). +/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. +/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다 +/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인) +List robocopyMoveOptions(List opts) => [ + '/MOVE', + for (final o in const ['/IS', '/IT', '/IM']) + if (!opts.any((x) => x.toUpperCase() == o)) o, + ]; + +/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초 +int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000); + +/// rsync · robocopy 의 성공 종료 코드 +bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐 +bool robocopyOk(int code) => code >= 0 && code < 8; + +/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증) +const msys2Repo = 'https://repo.msys2.org/msys/x86_64/'; +const msys2RsyncPackages = <(String, String)>[ + ('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'), + ('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'), + ('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'), + ('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'), + ('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'), + ('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'), + ('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'), + ('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'), + ('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'), + ('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'), + ('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'), + ('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'), +]; + +/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만) +const msys2RsyncFiles = { + 'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll', + 'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll', + 'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll', +}; diff --git a/lib/core/sync/copy_output.dart b/lib/core/sync/copy_output.dart new file mode 100644 index 0000000..cd25d45 --- /dev/null +++ b/lib/core/sync/copy_output.dart @@ -0,0 +1,102 @@ +// 약속 (나누기 sync_tools): rsync · robocopy 출력 읽기 - 조각을 받을 때마다 "다 보낸 파일" 줄과 진행률 · 속도 · 확인한 수. +// 입력: 출력 조각 (줄 중간에서 끊겨도 됨 - 남은 것은 다음 조각과 이어 읽음). 출력: 다 보낸 파일 (원본 기준 상대 경로) 목록. +// 오류: 던지지 않는다 (모르는 줄은 건너뜀). + +/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌). +/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt") +class RsyncOutput { + final _buf = StringBuffer(); + + /// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄) + double? currentPercent; + + /// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s") + double? currentSpeed; + + /// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null. + int? checked, checkTotal; + + static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)'); + static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s'); + + List feed(String chunk) { + _buf.write(chunk); + final text = _buf.toString(); + final cut = text.lastIndexOf(RegExp(r'[\r\n]')); + if (cut < 0) return const []; + _buf + ..clear() + ..write(text.substring(cut + 1)); + final done = []; + for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { + final line = raw.trimRight(); + if (line.isEmpty) continue; + if (line.startsWith(' ')) { + final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line); + if (m != null) currentPercent = int.parse(m[1]!) / 100; + final ck = _chkRx.firstMatch(line); + if (ck != null) { + checkTotal = int.parse(ck[2]!); + checked = checkTotal! - int.parse(ck[1]!); + } + final sp = _speedRx.firstMatch(line); + if (sp != null) { + const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024}; + currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!; + } + continue; + } + if (line.endsWith('/')) continue; // 폴더 + if (line.startsWith('sending ') || + line.startsWith('receiving ') || + line.startsWith('sent ') || + line.startsWith('total size') || + line.startsWith('created directory') || + line.startsWith('deleting ') || + line.startsWith('rsync:') || + line.startsWith('rsync error') || + line.contains(' -> ')) { + continue; + } + currentPercent = null; + done.add(line); + } + return done; + } +} + +/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다 +class RobocopyOutput { + final _buf = StringBuffer(); + double? currentPercent; + + /// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로 + /// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다. + /// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다. + static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false); + + List feed(String chunk) { + _buf.write(chunk); + final text = _buf.toString(); + final cut = text.lastIndexOf(RegExp(r'[\r\n]')); + if (cut < 0) return const []; + _buf + ..clear() + ..write(text.substring(cut + 1)); + final done = []; + for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { + final line = raw.trim(); + final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line); + if (pct != null) { + currentPercent = double.parse(pct[1]!) / 100; + continue; + } + final m = _file.firstMatch(raw); + if (m != null && !m[1]!.startsWith('*')) { + currentPercent = null; + done.add(m[2]!.trim()); + } + } + return done; + } +} diff --git a/lib/core/sync/rsync_options.dart b/lib/core/sync/rsync_options.dart new file mode 100644 index 0000000..90979b3 --- /dev/null +++ b/lib/core/sync/rsync_options.dart @@ -0,0 +1,328 @@ +import 'dart:io' show Platform; + +import '../../l10n/tr.dart'; + +// 약속 (나누기 sync_tools): rsync 옵션 칸의 글 - 붙여 넣은 이상한 글자 고치기 · 인수로 나누기 · rsync 가 다르게 읽을 것 찾기 · 목록 파일 값. +// 입력: 옵션 글 (사용자가 적거나 붙여 넣은 것) · 낱말 목록 · Windows 인지 (null 이면 이 기기). +// 출력: 낱말 목록 · 문제 목록 ([OptionProblem]) · 고칠 방법 글. 오류: 던지지 않는다 (알아볼 수 없는 것은 문제 목록으로 돌려준다). + +/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · +/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. +/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 +/// 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]'), ''); + +/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것 +const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'; + +/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다) +const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; + +/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. +/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 +/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. +/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다 +/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다. +/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. +/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고 +/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다. +/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -. +/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. +List splitOptions(String s) => _parseOptions(s).words; + +/// 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', + 'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress', + 'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest', + 'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del', + 'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded', + 'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing', + 'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help', + 'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing', + 'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6', + 'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc', + 'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs', + 'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times', + 'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial', + 'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet', + 'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links', + 'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at', + 'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version', + 'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt', +}; + +/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다) +typedef _Word = ({String text, bool quoted}); + +/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부) +({List words, List unclosed}) _parseOptions(String s) { + final chars = cleanOptionText(s).split(''); + final literal = {}; // 닫히지 않아 글자로 보는 따옴표 자리 + final unclosed = []; + while (true) { + final (raw, open) = _splitRaw(chars, literal); + if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed); + literal.add(open); + final rest = chars.sublist(open).join().trim(); + unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest); + } +} + +/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값) +(List<_Word>, int?) _splitRaw(List chars, Set literal) { + final out = <_Word>[]; + final cur = StringBuffer(); + String? quote; // 닫는 곧은 따옴표 (" 또는 ') + var curly = false; // 굽은 따옴표로 열었는지 + var openAt = -1; + var has = false; + var quoted = false; + bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]); + for (var i = 0; i < chars.length; i++) { + final ch = chars[i]; + if (quote != null) { + // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다 + final closes = ch == quote && !curly || + curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1)); + if (closes) { + quote = null; + curly = false; + } else { + cur.write(ch); + } + } else if (literal.contains(i)) { + cur.write(ch); + } else if (ch == '"' || ch == "'") { + if (cur.isEmpty && !has) quoted = true; + quote = ch; + openAt = i; + has = true; + } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { + if (cur.isEmpty && !has) quoted = true; + quote = _curlyQuote[ch]; + curly = true; + openAt = i; + has = true; + } else if (ch.trim().isEmpty) { + if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); + cur.clear(); + has = false; + quoted = false; + } else { + cur.write(ch); + } + } + if (quote != null) return (out, openAt); + if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); + return (out, null); +} + +bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i])); + +/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로 +String _fixRuleHead(String v) { + if (v.isEmpty) return v; + if (_dashLike.contains(v[0])) return '-${v.substring(1)}'; + if (v[0] == '\uFF0B') return '+${v.substring(1)}'; + return v; +} + +/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로) +List _normalizeWords(List<_Word> raw) { + final out = []; + var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로) + for (final w in raw) { + var t = w.text; + if (value.isNotEmpty) { + out.add(value == 'filter' ? _fixRuleHead(t) : t); + value = ''; + continue; + } + // 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로 + if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) { + final n = _dashAt(t, 1) ? 2 : 1; + final rest = t.substring(n); + final name = rest.split('=').first; + final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3))); + t = '${long ? '--' : '-'}$rest'; + } + if (t.startsWith('--filter=')) { + t = '--filter=${_fixRuleHead(t.substring(9))}'; + } else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) { + final k = t.indexOf(RegExp('[BefMT@]'), 1); + if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}'; + } + out.add(t); + if (t.startsWith('--')) { + if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other'; + } else if (t.length > 1 && t.startsWith('-')) { + final k = t.indexOf(RegExp('[BefMT@]'), 1); + if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other'; + } + } + 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', + // 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다 + '--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root', +}; + +/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에 +/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐), +/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 +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, relativePath, stdin } + +/// [unreadableOptions] 의 한 줄과 그 종류 +class OptionProblem { + final OptionProblemKind kind; + final String text; + + /// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다) + final String token; + const OptionProblem(this.kind, this.text, [this.token = '']); + + @override + String toString() => text; +} + +/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) +String optionProblemHint(Iterable problems, {bool? windows}) { + windows ??= Platform.isWindows; + final kinds = {for (final x in problems) x.kind}; + return [ + if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), + if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'), + if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), + if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), + if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), + // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / + if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), + if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'), + ].join(' · '); +} + +/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸) +String optionProblemMessage(List problems) => + trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]); + +/// [unreadableOptions] 의 몸통 (종류와 함께) +List optionProblems(String options, {bool? windows}) { + windows ??= Platform.isWindows; + final parsed = _parseOptions(options); + // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 + final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)]; + var value = false; + var last = ''; + for (final t in parsed.words) { + // P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도. + // 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다 + if (windows && parsed.unclosed.isEmpty && t.contains('"')) { + bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t)); + value = false; + continue; + } + if (value) { + value = false; + continue; + } + if (t.startsWith('---')) { + // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다" + bad.add(OptionProblem(OptionProblemKind.path, t, t)); + } else if (t.startsWith('--')) { + value = !t.contains('=') && _rsyncLongWithValue.contains(t); + last = 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; + last = t; + } else { + // 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데 + // - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다 + // ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기 + bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t)); + } + } + // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 + if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); + // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 + // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 + for (final f in listFileValues(parsed.words)) { + // 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다 + if (f == '-') { + bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f)); + } else if (!_absolutePath(f, windows: windows)) { + 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, windows: windows)) 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; +} + +/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다. +/// Android: / 로 시작 +bool _absolutePath(String f, {required bool windows}) => + windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/'); diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index ee291d0..31dee77 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -1,656 +1,6 @@ -import 'dart:io' show Platform; - -import 'package:path/path.dart' as p; - -import 'file_ops.dart' show appTempRsyncExcludes; -import '../l10n/tr.dart'; - -/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) -/// -/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기. -/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일) -/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만) -enum CopyMethod { - builtin('현재 방식'), - rsync('rsync'), - robocopy('robocopy'); - - final String label; - const CopyMethod(this.label); - - static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin); -} - -const defaultRsyncOptions = '-avPog'; -const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; - -/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · -/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. -/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 -/// 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]'), ''); - -/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것 -const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'; - -/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다) -const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; - -/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. -/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 -/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. -/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다 -/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다. -/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. -/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고 -/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다. -/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -. -/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. -List splitOptions(String s) => _parseOptions(s).words; - -/// 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', - 'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress', - 'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest', - 'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del', - 'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded', - 'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing', - 'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help', - 'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing', - 'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6', - 'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc', - 'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs', - 'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times', - 'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial', - 'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet', - 'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links', - 'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at', - 'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version', - 'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt', -}; - -/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다) -typedef _Word = ({String text, bool quoted}); - -/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부) -({List words, List unclosed}) _parseOptions(String s) { - final chars = cleanOptionText(s).split(''); - final literal = {}; // 닫히지 않아 글자로 보는 따옴표 자리 - final unclosed = []; - while (true) { - final (raw, open) = _splitRaw(chars, literal); - if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed); - literal.add(open); - final rest = chars.sublist(open).join().trim(); - unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest); - } -} - -/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값) -(List<_Word>, int?) _splitRaw(List chars, Set literal) { - final out = <_Word>[]; - final cur = StringBuffer(); - String? quote; // 닫는 곧은 따옴표 (" 또는 ') - var curly = false; // 굽은 따옴표로 열었는지 - var openAt = -1; - var has = false; - var quoted = false; - bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]); - for (var i = 0; i < chars.length; i++) { - final ch = chars[i]; - if (quote != null) { - // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다 - final closes = ch == quote && !curly || - curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1)); - if (closes) { - quote = null; - curly = false; - } else { - cur.write(ch); - } - } else if (literal.contains(i)) { - cur.write(ch); - } else if (ch == '"' || ch == "'") { - if (cur.isEmpty && !has) quoted = true; - quote = ch; - openAt = i; - has = true; - } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { - if (cur.isEmpty && !has) quoted = true; - quote = _curlyQuote[ch]; - curly = true; - openAt = i; - has = true; - } else if (ch.trim().isEmpty) { - if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); - cur.clear(); - has = false; - quoted = false; - } else { - cur.write(ch); - } - } - if (quote != null) return (out, openAt); - if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); - return (out, null); -} - -bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i])); - -/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로 -String _fixRuleHead(String v) { - if (v.isEmpty) return v; - if (_dashLike.contains(v[0])) return '-${v.substring(1)}'; - if (v[0] == '\uFF0B') return '+${v.substring(1)}'; - return v; -} - -/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로) -List _normalizeWords(List<_Word> raw) { - final out = []; - var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로) - for (final w in raw) { - var t = w.text; - if (value.isNotEmpty) { - out.add(value == 'filter' ? _fixRuleHead(t) : t); - value = ''; - continue; - } - // 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로 - if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) { - final n = _dashAt(t, 1) ? 2 : 1; - final rest = t.substring(n); - final name = rest.split('=').first; - final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3))); - t = '${long ? '--' : '-'}$rest'; - } - if (t.startsWith('--filter=')) { - t = '--filter=${_fixRuleHead(t.substring(9))}'; - } else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) { - final k = t.indexOf(RegExp('[BefMT@]'), 1); - if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}'; - } - out.add(t); - if (t.startsWith('--')) { - if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other'; - } else if (t.length > 1 && t.startsWith('-')) { - final k = t.indexOf(RegExp('[BefMT@]'), 1); - if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other'; - } - } - 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', - // 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다 - '--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root', -}; - -/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에 -/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐), -/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 -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, relativePath, stdin } - -/// [unreadableOptions] 의 한 줄과 그 종류 -class OptionProblem { - final OptionProblemKind kind; - final String text; - - /// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다) - final String token; - const OptionProblem(this.kind, this.text, [this.token = '']); - - @override - String toString() => text; -} - -/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) -String optionProblemHint(Iterable problems, {bool? windows}) { - windows ??= Platform.isWindows; - final kinds = {for (final x in problems) x.kind}; - return [ - if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), - if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'), - if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), - if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), - if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), - // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / - if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), - if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'), - ].join(' · '); -} - -/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸) -String optionProblemMessage(List problems) => - trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]); - -/// [unreadableOptions] 의 몸통 (종류와 함께) -List optionProblems(String options, {bool? windows}) { - windows ??= Platform.isWindows; - final parsed = _parseOptions(options); - // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 - final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)]; - var value = false; - var last = ''; - for (final t in parsed.words) { - // P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도. - // 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다 - if (windows && parsed.unclosed.isEmpty && t.contains('"')) { - bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t)); - value = false; - continue; - } - if (value) { - value = false; - continue; - } - if (t.startsWith('---')) { - // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다" - bad.add(OptionProblem(OptionProblemKind.path, t, t)); - } else if (t.startsWith('--')) { - value = !t.contains('=') && _rsyncLongWithValue.contains(t); - last = 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; - last = t; - } else { - // 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데 - // - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다 - // ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기 - bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t)); - } - } - // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 - if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); - // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 - // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 - for (final f in listFileValues(parsed.words)) { - // 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다 - if (f == '-') { - bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f)); - } else if (!_absolutePath(f, windows: windows)) { - 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, windows: windows)) 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; -} - -/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다. -/// Android: / 로 시작 -bool _absolutePath(String f, {required bool windows}) => - windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/'); - -/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다 -/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음). -/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch -List rsyncFileOptionArgs(List words, {required bool windows}) { - if (!windows) return words; - const opts = { - '--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch', - }; - // 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로, - // 드라이브는 /cygdrive/… 로 - String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v) - ? toCygwinPath(v) - : RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v) - ? v.replaceAll('/', '\\') - : v; - String rule(String r) { - final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r); - return m == null ? r : '${m[1]}${conv(m[2]!)}'; - } - - final out = []; - 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 && opts.contains(w.substring(0, eq))) { - out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}'); - } else if (opts.contains(w) && next != null) { - out - ..add(w) - ..add(conv(next)); - i++; - } else if (w.startsWith('--filter=')) { - out.add('--filter=${rule(w.substring(9))}'); - } else if (w == '--filter' && next != null) { - out - ..add(w) - ..add(rule(next)); - 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' && k < w.length - 1) { - out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}'); - } else if (k >= 0 && w[k] == 'f' && next != null) { - out - ..add(w) - ..add(rule(next)); - i++; - } else { - out.add(w); - // 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀 - if (k == w.length - 1 && next != null) { - out.add(next); - i++; - } - } - } else { - out.add(w); - } - } - return out; -} - -/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). -/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. -/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다 -/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로 -String rsyncDestPath(String path, {bool windows = true}) { - if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\'); - return toCygwinPath(path, windows: windows); -} - -String toCygwinPath(String path, {bool windows = true}) { - if (!windows) return path; - final s = path.replaceAll('\\', '/'); - final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s); - if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}'; - return s; // //server/share 는 그대로 읽는다 -} - -/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도). -/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게. -String withUpdateOption(String options) { - final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); - return has ? options : '${options.trim()} -u'.trim(); -} - -/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/ -/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과). -List rsyncArgs({ - required String options, - required List sources, - required String dest, - int bandwidthKBps = 0, - bool move = false, - bool windows = true, - bool contents = false, -}) { - // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 - final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows); - return [ - ...opts, - if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', - // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) - if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', - if (move) '--remove-source-files', - ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 - // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) - for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', - '${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/', - ]; -} - -String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s; - -/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178 -bool isSourceRemovingOption(String o) => - o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE'; - -/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다). -/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션] -/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에) -/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다. -List> robocopyRuns({ - required String options, - required List folders, - required List files, - required String dest, - int bandwidthKBps = 0, - bool move = false, - bool contents = false, - bool skipNewer = false, -}) { - // 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만 - final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; - final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps); - final runs = >[ - for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], - ]; - final byDir = >{}; - for (final f in files) { - byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f)); - } - // 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다 - final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList(); - final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList(); - byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra])); - return runs; -} - -/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게 -List robocopyExtraOptions(List opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [ - if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', - if (move) ...robocopyMoveOptions(opts), - // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼). - // 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다. - // 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로 - if (!robocopyLinkOption(opts)) '/XJ', - // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다 - if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO', - ]; - -/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL) -bool robocopyLinkOption(List opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase())); - -/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면 -bool robocopySkipsLinks(List opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase())); - -/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. -/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). -/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. -/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다 -/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인) -List robocopyMoveOptions(List opts) => [ - '/MOVE', - for (final o in const ['/IS', '/IT', '/IM']) - if (!opts.any((x) => x.toUpperCase() == o)) o, - ]; - -/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초 -int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000); - -/// rsync · robocopy 의 성공 종료 코드 -bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐 -bool robocopyOk(int code) => code >= 0 && code < 8; - -/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌). -/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt") -class RsyncOutput { - final _buf = StringBuffer(); - - /// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄) - double? currentPercent; - - /// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s") - double? currentSpeed; - - /// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null. - int? checked, checkTotal; - - static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)'); - static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s'); - - List feed(String chunk) { - _buf.write(chunk); - final text = _buf.toString(); - final cut = text.lastIndexOf(RegExp(r'[\r\n]')); - if (cut < 0) return const []; - _buf - ..clear() - ..write(text.substring(cut + 1)); - final done = []; - for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { - final line = raw.trimRight(); - if (line.isEmpty) continue; - if (line.startsWith(' ')) { - final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line); - if (m != null) currentPercent = int.parse(m[1]!) / 100; - final ck = _chkRx.firstMatch(line); - if (ck != null) { - checkTotal = int.parse(ck[2]!); - checked = checkTotal! - int.parse(ck[1]!); - } - final sp = _speedRx.firstMatch(line); - if (sp != null) { - const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024}; - currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!; - } - continue; - } - if (line.endsWith('/')) continue; // 폴더 - if (line.startsWith('sending ') || - line.startsWith('receiving ') || - line.startsWith('sent ') || - line.startsWith('total size') || - line.startsWith('created directory') || - line.startsWith('deleting ') || - line.startsWith('rsync:') || - line.startsWith('rsync error') || - line.contains(' -> ')) { - continue; - } - currentPercent = null; - done.add(line); - } - return done; - } -} - -/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다 -class RobocopyOutput { - final _buf = StringBuffer(); - double? currentPercent; - - /// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로 - /// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다. - /// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다. - static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false); - - List feed(String chunk) { - _buf.write(chunk); - final text = _buf.toString(); - final cut = text.lastIndexOf(RegExp(r'[\r\n]')); - if (cut < 0) return const []; - _buf - ..clear() - ..write(text.substring(cut + 1)); - final done = []; - for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { - final line = raw.trim(); - final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line); - if (pct != null) { - currentPercent = double.parse(pct[1]!) / 100; - continue; - } - final m = _file.firstMatch(raw); - if (m != null && !m[1]!.startsWith('*')) { - currentPercent = null; - done.add(m[2]!.trim()); - } - } - return done; - } -} - -/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증) -const msys2Repo = 'https://repo.msys2.org/msys/x86_64/'; -const msys2RsyncPackages = <(String, String)>[ - ('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'), - ('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'), - ('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'), - ('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'), - ('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'), - ('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'), - ('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'), - ('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'), - ('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'), - ('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'), - ('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'), - ('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'), -]; - -/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만) -const msys2RsyncFiles = { - 'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll', - 'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll', - 'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll', -}; +// 약속 (나누기 sync_tools): 복사 · 동기화 도구 (rsync · robocopy) - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/sync/ 의 모듈이 한다: rsync_options (옵션 글 읽기 · 문제 찾기) · copy_commands (복사 방법 · 명령 인수 · 패키지) · +// copy_output (출력 읽기). +export 'sync/rsync_options.dart'; +export 'sync/copy_commands.dart'; +export 'sync/copy_output.dart'; diff --git a/test/sync_modules_contract_test.dart b/test/sync_modules_contract_test.dart new file mode 100644 index 0000000..ce0f409 --- /dev/null +++ b/test/sync_modules_contract_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/sync/copy_commands.dart'; +import 'package:jj_mkvmaker/core/sync/copy_output.dart'; +import 'package:jj_mkvmaker/core/sync/rsync_options.dart'; + +/// 나누기 sync_tools 약속 시험: lib/core/sync/ 모듈마다 맨 위 약속 (입력 · 출력 · 오류 없음). +/// 실제 rsync · robocopy 실행은 rsync_msys_args_test · move_settled_test 등이 본다 +void main() { + test('rsync_options: 따옴표 · 굽은 따옴표 · 대시 고쳐 나누기 · 문제는 던지지 않고 목록으로', () { + expect(splitOptions('-av --exclude="a b.txt"'), ['-av', '--exclude=a b.txt']); + expect(splitOptions('–delete --exclude=‘*.log’'), ['--delete', '--exclude=*.log']); + expect(optionProblems('-a "x', windows: false).first.kind, OptionProblemKind.unclosed); + expect(optionProblems('-a delete', windows: false).map((e) => e.kind), [OptionProblemKind.path]); + expect(optionProblems('--exclude-from=list.txt', windows: true).map((e) => e.kind), [OptionProblemKind.relativePath]); + expect(optionProblems('-avP --exclude *.tmp', windows: false), isEmpty); + expect(listFileValues(['--files-from', '/a/list', '-f', '. /b/rules']), ['/a/list', '/b/rules']); + expect(optionProblemHint([const OptionProblem(OptionProblemKind.noValue, 'x')], windows: false), isNotEmpty); + }); + + test('copy_commands: rsync 인수 (원본 지우기는 이동일 때만 · 경로는 MSYS 꼴) · robocopy 묶음 · 성공 코드', () { + final a = rsyncArgs(options: '-av --remove-source-files', sources: [r'C:\s\dir\'], dest: r'D:\t', bandwidthKBps: 100); + expect(a, ['-av', '--bwlimit=100', '--exclude=~jjren_*', '--exclude=~jjback_*', '/cygdrive/c/s/dir', '/cygdrive/d/t/']); + expect(rsyncArgs(options: '-a', sources: ['/s'], dest: '/t', move: true, windows: false), contains('--remove-source-files')); + expect(rsyncDestPath('//nas/share/x'), r'\\nas\share\x'); + expect(withUpdateOption('-avu'), '-avu'); + expect(withUpdateOption('-av'), '-av -u'); + final runs = robocopyRuns(options: '/E', folders: [r'C:\s\f'], files: [r'C:\s\a.txt'], dest: r'D:\t', move: true); + expect(runs.first.take(2), [r'C:\s\f', r'D:\t\f']); + expect(runs.first, containsAll(['/MOVE', '/XJ'])); + expect(runs.last, [r'C:\s', r'D:\t', 'a.txt', '/MOV', '/IS', '/IT', '/IM', '/XJ']); + expect([rsyncOk(24), rsyncOk(23), robocopyOk(7), robocopyOk(8)], [true, false, true, false]); + expect(CopyMethod.of('없음'), CopyMethod.builtin); + }); + + test('copy_output: 줄 중간에서 끊긴 조각을 이어 읽음 · 진행률 · 속도 · 모르는 줄은 건너뜀', () { + final r = RsyncOutput(); + expect(r.feed('sending incremental file list\nsub/'), isEmpty); + expect(r.feed('\nsub/inner.txt\n 1,024 45% 1.50MB/s 0:00:01 (xfr#1, to-chk=3/10)\r'), ['sub/inner.txt']); + expect((r.currentPercent, r.checked, r.checkTotal), (0.45, 7, 10)); + expect(r.currentSpeed, 1.5 * 1024 * 1024); + final b = RobocopyOutput(); + expect(b.feed('\t New File \t\t 1234\tC:\\s\\a.txt\n\t *EXTRA File \t\t 1\tx\n 50%\n'), ['C:\\s\\a.txt']); + expect(b.currentPercent, 0.5); + }); +}