JJ_MKVMaker/lib/core/sync_preview.dart

294 lines
13 KiB
Dart

import 'dart:io' show FileSystemEntity;
import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException;
import 'rsync_filter.dart';
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption;
export 'sync_tools.dart' show isSourceRemovingOption;
import 'vfs.dart';
/// rsync (Rsync 화면의 → · ← · ⇄) 를 실행하기 전에 무엇이 바뀌는지 미리 보기 (72).
enum PreviewAction {
/// 받는 쪽에 없어 새로 복사
add,
/// 받는 쪽에도 있지만 달라서 덮어씀
update,
/// 받는 쪽에만 있음 (지우기 없이 그대로 남음)
onlyTarget,
/// 받는 쪽에만 있어 지워짐 (--delete)
delete,
/// 229: 받는 쪽이 더 새것이라 건너뜀 (-u · 환경 설정 "받는 쪽이 더 새것이면 건너뛰기")
skipNewer,
/// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214)
skipLink,
/// M1 (10/19): 원본은 파일인데 받는 쪽에 같은 이름의 폴더가 있어 건너뜀 (--delete 가 없을 때 - 폴더를 지우지 않는다)
skipDir,
}
class PreviewItem {
/// 원본 폴더 기준 상대 경로 ("/" 구분)
final String rel;
final PreviewAction action;
/// true = 왼쪽 → 오른쪽, false = 오른쪽 → 왼쪽
final bool toRight;
final bool isDir;
final int size;
const PreviewItem(this.rel, this.action,
{required this.toRight, this.isDir = false, this.size = 0, this.newer = false, this.replacedByFile = false, this.inner = 0});
/// M1 (10/19): [PreviewAction.delete] 인 폴더가 원본의 같은 이름 파일로 바뀜 (--delete) - 그 폴더 안의 항목 수 [inner]
final bool replacedByFile;
final int inner;
/// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀)
final bool newer;
}
/// rsync 의 지우기 옵션인지: --delete · --delete-before/during/delay/after/excluded/missing-args · --del (별칭)
/// (97: --del 을 놓치면 확인 · 잠금 없이 지운다)
bool isRsyncDeleteOption(String o) => o == '--del' || o.startsWith('--delete') || o.startsWith('--del=');
/// rsync 옵션에 지우기 (--delete 계열) 가 있는지
bool optionsDelete(String options) => splitOptions(options).any(isRsyncDeleteOption);
/// M2 (관리자 지시 10/19): rsync 옵션에 거르기 (--exclude · --include · --filter · -f · --exclude-from · --include-from · -C · -F) 가 있는지.
/// 원격 (WebDAV · SFTP · SMB · FTP) 은 앱이 직접 맞추며 이 거르기를 따르지 않으므로 확인 창에 알린다
bool optionsFilter(String options) => splitOptions(options).any((o) =>
o == '-f' ||
o == '-F' ||
o == '--cvs-exclude' ||
o == '-C' ||
RegExp(r'^--(exclude|include|filter|exclude-from|include-from)(=|$)').hasMatch(o) ||
(RegExp(r'^-[a-zA-Z]+$').hasMatch(o) && (o.contains('C') || o.contains('F'))));
/// 옵션 글을 낱말로 나누되 따옴표는 그대로 둔 원래 글 조각 (106: --exclude="My Folder" 가 갈라지지 않게)
List<String> rawOptionTokens(String s) {
final out = <String>[];
final cur = StringBuffer();
String? quote;
for (final ch in s.split('')) {
if (quote != null) {
cur.write(ch);
if (ch == quote) quote = null;
} else if (ch == '"' || ch == "'") {
quote = ch;
cur.write(ch);
} else if (ch.trim().isEmpty) {
if (cur.isNotEmpty) out.add(cur.toString());
cur.clear();
} else {
cur.write(ch);
}
}
if (cur.isNotEmpty) out.add(cur.toString());
return out;
}
/// 지우기 옵션을 뺀 rsync 옵션 (⇄ 의 두 번째 ← 처럼 지우면 안 되는 실행). 다른 옵션은 따옴표까지 원래 글 그대로
String withoutDeleteOptions(String options) => rawOptionTokens(options)
.where((raw) => !splitOptions(raw).any(isRsyncDeleteOption))
.join(' ');
/// 178: 옵션 글에서 원본을 지우는 옵션을 뺀다 (원본 지우기는 [원본 파일 지우기] · [이동] 으로만 - 보이는 것과 실행이 같게)
String withoutSourceRemovingOptions(String options) => rawOptionTokens(options)
.where((raw) => !splitOptions(raw).any(isSourceRemovingOption))
.join(' ');
/// 옵션 글에 원본을 지우는 옵션이 들어 있는지 (환경 설정의 rsync 옵션 등)
bool optionsRemoveSource(String options) => splitOptions(options).any(isSourceRemovingOption);
/// rsync 옵션에 -u (받는 쪽이 더 새 파일은 건너뜀) 가 있는지
bool optionsUpdate(String options) =>
splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
/// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다.
/// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상").
/// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)",
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다.
/// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다).
/// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움)
Future<List<PreviewItem>> previewSync(String src, String dst,
{required bool toRight,
bool delete = false,
bool update = false,
bool skipLinks = false,
RsyncFilter filter = RsyncFilter.none,
int limit = 2000}) async {
final out = <PreviewItem>[];
// 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김)
Future<void> deleteInside(String dir, String rel) async {
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(dir);
} catch (_) {
return;
}
for (final x in items) {
if (out.length >= limit) return;
final name = vBasename(x.path);
if (isAppTempName(name)) continue;
final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) continue;
if (x.isDir && await _hasExcluded(x.path, r, filter)) {
await deleteInside(x.path, r);
} else {
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: x.isDir, size: x.size));
}
}
}
Future<void> walk(String s, String d, String rel) async {
if (out.length >= limit) return;
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
try {
for (final e in await vList(d)) {
there[vBasename(e.path)] = e;
}
} catch (_) {} // 받는 쪽 폴더가 아직 없음
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(s, strict: true);
} catch (e) {
// 맨 위 원본을 읽지 못하면 비교 실패 (93: 지우는 실행을 막는다). 안쪽 폴더는 그 폴더의 "지워짐" 만 세지 않는다
if (rel.isEmpty) throw SourceUnreadableException(src, cause: e);
return;
}
final names = <String>{};
for (final e in items) {
if (out.length >= limit) return;
final name = vBasename(e.path);
if (isPartialFile(name) || isAppTempName(name)) continue;
final r = rel.isEmpty ? name : '$rel/$name';
// 177: 거른 것은 보내지 않는다 - 받는 쪽의 같은 이름은 아래에서 (지우지 않음 / --delete-excluded)
if (filter.excluded(r, isDir: e.isDir)) continue;
names.add(name);
final t = there[name];
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) {
if (skipLinks) {
out.add(PreviewItem(r, PreviewAction.skipLink, toRight: toRight, isDir: e.isDir));
} else if (t == null) {
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight));
}
continue;
}
if (e.isDir) {
if (t == null) out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, isDir: true));
await walk(e.path, vJoin(d, name), r);
continue;
}
// M1 (관리자 P0, 10/19): 받는 쪽의 같은 이름이 폴더 - --delete 면 폴더 (안의 것 모두) 를 지우고 파일로, 아니면 건너뜀 (앱 맞추기 · rsync 같음)
if (t != null && t.isDir) {
if (delete) {
out.add(PreviewItem(r, PreviewAction.delete,
toRight: toRight, isDir: true, replacedByFile: true, inner: await _countInside(t.path)));
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
} else {
out.add(PreviewItem(r, PreviewAction.skipDir, toRight: toRight, size: e.size));
}
continue;
}
if (t == null) {
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
} else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) {
// -u: 받는 쪽이 더 새 것이면 건너뜀. 229: 건너뛰는 것도 · 더 새 것을 덮어쓰는 것도 목록에 따로
final newer = t.modified.isAfter(e.modified.add(const Duration(seconds: 2)));
if (update && newer) {
out.add(PreviewItem(r, PreviewAction.skipNewer, toRight: toRight, size: e.size));
continue;
}
out.add(PreviewItem(r, PreviewAction.update, toRight: toRight, size: e.size, newer: newer));
}
}
for (final e in there.values) {
final name = vBasename(e.path);
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음)
if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue;
final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: e.isDir)) {
// 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다)
if (delete && filter.deleteExcluded) {
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: e.isDir, size: e.size));
}
continue;
}
// 177 (28 확인): 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고
// 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만
if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) {
await deleteInside(e.path, r);
continue;
}
out.add(PreviewItem(r, delete ? PreviewAction.delete : PreviewAction.onlyTarget,
toRight: toRight, isDir: e.isDir, size: e.size));
}
}
await walk(src, dst, '');
return out;
}
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다)
Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(dir);
} catch (_) {
return false;
}
for (final x in items) {
final name = vBasename(x.path);
if (isPartialFile(name) || isAppTempName(name)) continue;
final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) return true;
if (x.isDir && await _hasExcluded(x.path, r, filter)) return true;
}
return false;
}
/// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고,
/// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만).
/// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)
Future<List<PreviewItem>> previewBoth(String left, String right,
{bool delete = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async {
final a = await previewSync(left, right, toRight: true, update: true, delete: delete, filter: filter, limit: limit);
final b = await previewSync(right, left, toRight: false, update: true, filter: filter, limit: limit);
final gone = {for (final x in a) if (x.action == PreviewAction.delete) x.rel};
final seen = <String>{};
return [
for (final x in a)
if (x.action == PreviewAction.delete && seen.add(x.rel)) x,
for (final x in [...a, ...b])
if ((x.action == PreviewAction.add || x.action == PreviewAction.update) &&
!gone.any((g) => x.rel == g || x.rel.startsWith('$g/')) &&
seen.add(x.rel))
x,
];
}
/// M1: 폴더 [dir] 안의 모든 항목 수 (지우고 파일로 바뀔 때 보이려고, [limit] 에서 멈춤)
Future<int> _countInside(String dir, {int limit = 100000}) async {
var n = 0;
Future<void> go(String d) async {
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(d);
} catch (_) {
return;
}
for (final x in items) {
if (n >= limit) return;
n++;
if (x.isDir) await go(x.path);
}
}
await go(dir);
return n;
}