469 lines
26 KiB
Dart
469 lines
26 KiB
Dart
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) {
|
||
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) {
|
||
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',
|
||
};
|