JJ_MKVMaker/lib/core/sync_tools.dart

479 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<String> 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<String> words, List<String> unclosed}) _parseOptions(String s) {
final chars = cleanOptionText(s).split('');
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
final unclosed = <String>[];
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<String> chars, Set<int> 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<String> _normalizeWords(List<_Word> raw) {
final out = <String>[];
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;
}
if (!w.quoted && t.isNotEmpty && _dashLike.contains(t[0])) {
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',
};
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
List<String> unreadableOptions(String options, {bool? windows}) {
windows ??= Platform.isWindows;
final parsed = _parseOptions(options);
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
final bad = [for (final u in parsed.unclosed) trf('따옴표가 닫히지 않았습니다: {0}', [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(trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]));
value = false;
continue;
}
if (value) {
value = false;
continue;
}
if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다
bad.add(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 가 원본 경로로 읽어 옵션이 몰래 빠진다
bad.add(trf('경로로 읽힙니다: {0}', [t]));
}
}
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
if (value) bad.add(trf('{0} 의 값이 없습니다', [last]));
return bad;
}
/// 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}) {
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<String> rsyncArgs({
required String options,
required List<String> sources,
required String dest,
int bandwidthKBps = 0,
bool move = false,
bool windows = true,
bool contents = false,
}) {
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
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 ? '/' : ''}',
'${toCygwinPath(_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<List<String>> robocopyRuns({
required String options,
required List<String> folders,
required List<String> 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 = <List<String>>[
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
];
final byDir = <String, List<String>>{};
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<String> robocopyExtraOptions(List<String> 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<String> opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase()));
/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면
bool robocopySkipsLinks(List<String> 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<String> robocopyMoveOptions(List<String> 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<String> 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 = <String>[];
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<String> 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 = <String>[];
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',
};