WIP 원격 맞추기 (WebDAV · SFTP · SMB · FTP) 도 rsync 옵션의 거르기를 미리 보기와 같게 따름 - M2 의 본 고침 (검토 대기)

관리자 지시 (10/19): 원격은 rsync 대신 앱이 맞추며 (file_ops._mirrorV · 실시간 동기화 원격 쌍) --exclude 등을 따르지 않아, 지키려고 뺀 것도 복사 · 지웠다
(22ae039 는 경고만). 바탕: remote_filter = next2 7cd8eb8 + next3 63ef362 (d3 의 RsyncFilter · F1~F4).
- FileOps.mirror / _mirrorV 에 filter (RsyncFilter): 거른 것은 보내지 않고 (거른 폴더 안으로 내려가지 않음), 받는 쪽의 거른 것은
  지키고 (--delete-excluded 면 지움), 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 (미리 보기 sync_preview 와 같은 규칙).
- 복사 · 이동 작업: rsync 를 고른 작업이 원격이라 앱이 맞출 때 그 옵션의 거르기를 넘긴다.
- 실시간 동기화: rsync 쌍이면 환경 설정 rsync 옵션의 거르기를 맞추기와 대기 목록 (diff · _diffV) 에 같이. _diffV 는 M1 규칙도 (지우기 없으면 같은 이름 폴더 줄은 기다림으로 세지 않음).
- 확인 창 (Rsync 화면 · 지우기 확인): 원격도 같은 거르기로 미리 보고, 미리 볼 수 없는 규칙 (unsupported) 의 빨간 경고 · [알고 실행] 체크 (63ef362) 를 원격에도.
  22ae039 의 주황 "원격은 거르기를 따르지 않음" 경고는 뺐다. 안내 글: "… 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름".

시험: webdav_test (새 1: 시험 WebDAV 서버에 거르기 5가지 - --exclude 파일 · 폴더 / --delete-excluded / --include 와 --exclude 차례 / --filter "- …" "- /…" / 지우기 없음 -
마다 미리 보기의 + · ~ 는 실제로 있고 − 는 실제로 없어지며, 실제로 지워진 것은 모두 미리 보기 지울 목록에. 한글 폴더 · 거른 것 지키기 · 거른 것이 든 폴더 자세히) ·
sftp_test (새 1: paramiko 서버, 보내지 않기 · 지키기 · 폴더 안만 지우기, 미리 보기 지울 목록 = {old.txt, gone/g.txt}) ·
sync_preview · sync_preview_view · explorer_webdav · rsync_filter · mirror_safety · sync_tools · live_sync · transfer 통과, 임시 폴더 흔적 0, analyze 문제 없음, l10n 빠짐 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remote_filter
RyuJongJin 2026-10-19 22:51:07 +09:00
parent c0695b02d8
commit fe34a8b556
11 changed files with 214 additions and 42 deletions

View File

@ -357,6 +357,7 @@
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept",
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).",
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?",
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV / SFTP can't be added to the MKV list. Copy them to this device first.", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV / SFTP can't be added to the MKV list. Copy them to this device first.",

View File

@ -357,6 +357,7 @@
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します",
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?",
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",

View File

@ -357,6 +357,7 @@
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名",
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。",
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?",
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。",

View File

@ -16,6 +16,7 @@ import '../l10n/tr.dart';
import '../platform/windows/rsync_installer.dart'; import '../platform/windows/rsync_installer.dart';
import 'app_controller.dart'; import 'app_controller.dart';
import 'settings.dart'; import 'settings.dart';
import '../core/rsync_filter.dart';
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. /// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) /// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
@ -149,8 +150,12 @@ class LiveSync extends ChangeNotifier {
} }
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로. /// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async { /// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다.
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit); /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter);
final out = <String>[]; final out = <String>[];
Future<void> walk(String src, String dst, String rel) async { Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -194,7 +199,7 @@ class LiveSync extends ChangeNotifier {
} }
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면 /// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500}) async { static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
final out = <String>[]; final out = <String>[];
Future<void> walk(String src, String dst, String rel) async { Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -215,20 +220,28 @@ class LiveSync extends ChangeNotifier {
if (out.length >= limit) return; if (out.length >= limit) return;
final name = vBasename(e.path); final name = vBasename(e.path);
if (isAppTempName(name)) continue; // 78 보충 if (isAppTempName(name)) continue; // 78 보충
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name'; final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: e.isDir)) continue; // 관리자 지시 (10/19): 맞추기 (_mirrorV) 와 같은 거르기
names.add(name);
if (e.isDir) { if (e.isDir) {
await walk(e.path, vJoin(dst, name), r); await walk(e.path, vJoin(dst, name), r);
continue; continue;
} }
final t = there[name]; final t = there[name];
// M1: 받는 쪽이 같은 이름의 폴더면 지우기 포함일 때만 바뀐다 (아니면 건너뜀 - 기다리는 것으로 세지 않음)
if (t != null && t.isDir && !x.delete) continue;
if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) { if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) {
out.add(r); out.add(r);
} }
} }
if (x.delete) { if (x.delete) {
for (final name in there.keys) { for (final t in there.entries) {
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); final name = t.key;
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
final r = rel.isEmpty ? name : '$rel/$name';
// 거른 것은 지우지 않는다 (--delete-excluded 면 지움). 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만 (아래는 폴더 하나로 셈)
if (filter.excluded(r, isDir: t.value.isDir) && !filter.deleteExcluded) continue;
out.add('− $r');
} }
} }
} }
@ -248,7 +261,7 @@ class LiveSync extends ChangeNotifier {
problems[k] = SourceUnreadableException(x.source, cause: e); problems[k] = SourceUnreadableException(x.source, cause: e);
} }
try { try {
pending[k] = await diff(x); pending[k] = await diff(x, filter: _filterOf(x));
await _countOnlyTarget(x); await _countOnlyTarget(x);
} catch (_) {} } catch (_) {}
notifyListeners(); notifyListeners();
@ -265,7 +278,7 @@ class LiveSync extends ChangeNotifier {
} }
// 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것 // 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것
try { try {
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000)) if (d.startsWith('− ')) d].length; onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000, filter: _filterOf(x))) if (d.startsWith('− ')) d].length;
} catch (_) { } catch (_) {
onlyTarget.remove(k); // 세지 못하면 예전 글 onlyTarget.remove(k); // 세지 못하면 예전 글
} }
@ -401,7 +414,7 @@ class LiveSync extends ChangeNotifier {
try { try {
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete]) // 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
if (x.delete && !x.deleteConfirmed) { if (x.delete && !x.deleteConfirmed) {
final del = [for (final d in await diff(x, limit: 100000)) if (d.startsWith('− ')) d.substring(2)]; final del = [for (final d in await diff(x, limit: 100000, filter: _filterOf(x))) if (d.startsWith('− ')) d.substring(2)];
if (del.isEmpty) { if (del.isEmpty) {
await _setPair(x, x.copyWith(deleteConfirmed: true)); await _setPair(x, x.copyWith(deleteConfirmed: true));
} else { } else {
@ -419,7 +432,7 @@ class LiveSync extends ChangeNotifier {
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다 // M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps); final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps);
final n = switch (method) { final n = switch (method) {
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty), CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)),
CopyMethod.rsync => await _rsync(x, s), CopyMethod.rsync => await _rsync(x, s),
CopyMethod.robocopy => await _robocopy(x, s), CopyMethod.robocopy => await _robocopy(x, s),
}; };
@ -430,7 +443,7 @@ class LiveSync extends ChangeNotifier {
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
problems.remove(k); problems.remove(k);
emptyDeletes.remove(k); emptyDeletes.remove(k);
pending[k] = await diff(asked); pending[k] = await diff(asked, filter: _filterOf(asked));
await _countOnlyTarget(asked); await _countOnlyTarget(asked);
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
} on SourceUnreadableException catch (e) { } on SourceUnreadableException catch (e) {
@ -446,7 +459,7 @@ class LiveSync extends ChangeNotifier {
); );
if (e.empty) { if (e.empty) {
try { try {
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000)) if (d.startsWith('− ')) d.substring(2)]; emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000, filter: _filterOf(asked))) if (d.startsWith('− ')) d.substring(2)];
} catch (_) {} } catch (_) {}
} else { } else {
emptyDeletes.remove(k); emptyDeletes.remove(k);

View File

@ -10,6 +10,7 @@ import '../core/sync_preview.dart' show isRsyncDeleteOption;
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../core/rsync_filter.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
@ -343,7 +344,12 @@ class TransferJob extends ChangeNotifier {
if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) { if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도
settled.clear(); settled.clear();
await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move); await _ops!.mirror(sources[i], dest,
update: _update || skipNewer,
delete: _delete,
strict: move,
// 관리자 지시 (10/19): rsync 를 고른 작업 (원격이라 앱이 맞춤) 은 옵션의 거르기도 미리 보기와 같게
filter: method == CopyMethod.rsync ? RsyncFilter.parse(options) : RsyncFilter.none);
dirConflicts.addAll(_ops!.dirConflicts); dirConflicts.addAll(_ops!.dirConflicts);
_ops!.dirConflicts.clear(); _ops!.dirConflicts.clear();
if (move && !cancelled) await _deleteSettled(); if (move && !cancelled) await _deleteSettled();

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'playlist.dart' show naturalCompare; import 'playlist.dart' show naturalCompare;
import 'rsync_filter.dart';
import 'vfs.dart'; import 'vfs.dart';
import 'webdav.dart' show DavException, DavLockedException; import 'webdav.dart' show DavException, DavLockedException;
@ -631,11 +632,12 @@ class FileOps {
/// ///
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게). /// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게).
/// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다 /// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다
/// [filter] (관리자 지시 10/19, M2 의 본 고침): rsync 옵션의 거르기 - 원격 (앱이 직접 맞춤) 도 미리 보기와 같은 규칙을 따른다
Future<int> mirror(String srcDir, String dstDir, Future<int> mirror(String srcDir, String dstDir,
{bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false}) async { {bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다) // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다)
if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource); if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource);
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict); if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter);
var n = 0; var n = 0;
Future<void> walk(String src, String dst) async { Future<void> walk(String src, String dst) async {
_check(); _check();
@ -919,9 +921,42 @@ class FileOps {
return empty; return empty;
} }
Future<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false}) async { Future<int> _mirrorV(String srcDir, String dstDir,
{bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
var n = 0; var n = 0;
Future<void> walk(String src, String dst) async { // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다
// (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다
Future<bool> hasExcluded(String dir, String rel) 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 r = '$rel/${vBasename(x.path)}';
if (filter.excluded(r, isDir: x.isDir)) return true;
if (x.isDir && await hasExcluded(x.path, r)) return true;
}
return false;
}
Future<void> deleteInside(String dir, String rel) async {
for (final x in await vList(dir)) {
_check();
final name = vBasename(x.path);
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) continue;
if (x.isDir && await hasExcluded(x.path, r)) {
await deleteInside(x.path, r);
} else {
await vDelete(x.path);
}
}
}
Future<void> walk(String src, String dst, String rel) async {
_check(); _check();
await vMkdirs(dst); await vMkdirs(dst);
final there = {for (final e in await vList(dst)) vBasename(e.path): e}; final there = {for (final e in await vList(dst)) vBasename(e.path): e};
@ -935,12 +970,15 @@ class FileOps {
for (final e in items) { for (final e in items) {
final name = vBasename(e.path); final name = vBasename(e.path);
if (isAppTempName(name)) continue; // 78 보충 if (isAppTempName(name)) continue; // 78 보충
final r = rel.isEmpty ? name : '$rel/$name';
// 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded)
if (filter.excluded(r, isDir: e.isDir)) continue;
names.add(name); names.add(name);
final target = vJoin(dst, name); final target = vJoin(dst, name);
// 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게) // 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게)
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue; if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue;
if (e.isDir) { if (e.isDir) {
await walk(e.path, target); await walk(e.path, target, r);
continue; continue;
} }
final t = there[name]; final t = there[name];
@ -968,13 +1006,25 @@ class FileOps {
onSettled?.call(e.path, e.size, e.modified); onSettled?.call(e.path, e.size, e.modified);
} }
if (delete) { if (delete) {
for (final name in there.keys) { for (final t in there.entries) {
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) await vDelete(vJoin(dst, name)); final name = t.key;
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
_check();
final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: t.value.isDir)) {
if (filter.deleteExcluded) await vDelete(t.value.path);
continue;
}
if (t.value.isDir && !filter.isEmpty && !filter.deleteExcluded && await hasExcluded(t.value.path, r)) {
await deleteInside(t.value.path, r);
continue;
}
await vDelete(t.value.path);
} }
} }
} }
await walk(srcDir, dstDir); await walk(srcDir, dstDir, '');
return n; return n;
} }
} }

View File

@ -3416,7 +3416,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
Object? previewError; Object? previewError;
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때) // 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
var filterAck = false; var filterAck = false;
List<String> filterLimits() => _viaDav([l, r]) ? const [] : {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t))); bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
@ -3499,7 +3500,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')),
// 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음) // 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음)
compute: () async => both compute: () async => both
? previewBoth(l, r, delete: deletes(), filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(tasks.first))) ? previewBoth(l, r, delete: deletes(), filter: RsyncFilter.parse(opts(tasks.first)))
: [ : [
for (final t in tasks) for (final t in tasks)
...await previewSync(t.sources.first, t.dest, ...await previewSync(t.sources.first, t.dest,
@ -3508,7 +3509,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다
update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer), update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer),
skipLinks: _viaDav([l, r]), skipLinks: _viaDav([l, r]),
filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(t))), filter: RsyncFilter.parse(opts(t))),
], ],
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
onStart: () => set(() { onStart: () => set(() {
@ -3541,15 +3542,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (_viaDav([l, r])) if (_viaDav([l, r]))
Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'), Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'),
style: const TextStyle(fontSize: 12, color: JjColors.accent)), style: const TextStyle(fontSize: 12, color: JjColors.accent)),
// M2 (관리자 지시 10/19): 원격이고 옵션에 거르기가 있으면 - 앱 맞추기는 거르기를 따르지 않는다 (뺀 것도 복사 · 지움)
if (_viaDav([l, r]) && tasks.any((t) => optionsFilter(opts(t))))
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
),
]), ]),
), ),
actions: [ actions: [

View File

@ -225,7 +225,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
: optionsUpdate(t.options) || (direct && skipNewer); : optionsUpdate(t.options) || (direct && skipNewer);
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src)); String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음) // 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' && !direct ? RsyncFilter.parse(t.options) : RsyncFilter.none; // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -245,13 +246,6 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
], ],
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다) // 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
// M2 (관리자 지시 10/19): 원격 (앱이 직접 맞춤) 인데 옵션에 거르기가 있으면 - 거르기를 따르지 않는다
if (direct && t.method == 'rsync' && optionsFilter(t.options))
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
),
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히 // 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
if (t.move) if (t.move)
Padding( Padding(

View File

@ -206,8 +206,9 @@ void main() {
expect(find.textContaining('→ + inner.txt'), findsOneWidget); expect(find.textContaining('→ + inner.txt'), findsOneWidget);
expect(find.textContaining('= note.txt'), findsOneWidget); expect(find.textContaining('= note.txt'), findsOneWidget);
expect(find.textContaining('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget); expect(find.textContaining('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget);
// M2: 옵션에 거르기가 없으면 거르기 경고는 없다 // 관리자 지시 (10/19): 원격도 거르기를 따른다 - 안내에 적고, "따르지 않습니다" 경고 (M2) 는 없다
expect(find.textContaining('거르기 (--exclude'), findsNothing); expect(find.textContaining('거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름'), findsOneWidget);
expect(find.textContaining('거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다'), findsNothing);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
final got = File(p.join(remote.path, '원격', 'inner.txt')); final got = File(p.join(remote.path, '원격', 'inner.txt'));
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200); await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);

View File

@ -12,6 +12,7 @@ import 'package:path/path.dart' as p;
import 'support/sftp_server.dart'; import 'support/sftp_server.dart';
import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - /// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. /// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
@ -183,6 +184,31 @@ void main() {
expect((off! as SftpException).kind, SftpFailure.unreachable); expect((off! as SftpException).kind, SftpFailure.unreachable);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('관리자 지시 (10/19): SFTP 맞추기도 거르기를 따름 (보내지 않기 · 받는 쪽 지키기 · 거른 것이 든 폴더)', () async {
await start();
SftpRegistry.configure([srv(fp: fingerprint)]);
final src = Directory(p.join(tmp.path, 'local', 'fsrc'))..createSync(recursive: true);
File(p.join(src.path, 'a.txt')).writeAsStringSync('a');
File(p.join(src.path, 'skip.tmp')).writeAsStringSync('s');
Directory(p.join(root.path, 'fdst', 'gone')).createSync(recursive: true);
File(p.join(root.path, 'fdst', 'only.tmp')).writeAsStringSync('o');
File(p.join(root.path, 'fdst', 'old.txt')).writeAsStringSync('old');
File(p.join(root.path, 'fdst', 'gone', 'g.tmp')).writeAsStringSync('g');
File(p.join(root.path, 'fdst', 'gone', 'g.txt')).writeAsStringSync('g');
const dst = 'sftp://s1/fdst';
final filter = RsyncFilter.parse('-av --exclude=*.tmp');
final pv = await previewSync(src.path, dst, toRight: true, delete: true, filter: filter);
expect({for (final x in pv) if (x.action == PreviewAction.delete) x.rel}, {'old.txt', 'gone/g.txt'});
await FileOps().mirror(src.path, dst, delete: true, filter: filter);
String real(String rel) => p.joinAll([root.path, 'fdst', ...rel.split('/')]);
expect(File(real('a.txt')).existsSync(), isTrue);
expect(File(real('skip.tmp')).existsSync(), isFalse);
expect(File(real('only.tmp')).existsSync(), isTrue);
expect(File(real('old.txt')).existsSync(), isFalse);
expect(File(real('gone/g.tmp')).existsSync(), isTrue);
expect(File(real('gone/g.txt')).existsSync(), isFalse);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async { test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async {
await start(); await start();
SftpRegistry.configure([srv(fp: fingerprint)]); SftpRegistry.configure([srv(fp: fingerprint)]);

View File

@ -15,6 +15,7 @@ import 'package:path/path.dart' as p;
import 'support/dav_server.dart'; import 'support/dav_server.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
void main() { void main() {
late Directory tmp, remote, local; late Directory tmp, remote, local;
@ -217,4 +218,88 @@ void main() {
expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file'); expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file');
expect(ops2.dirConflicts, isEmpty); expect(ops2.dirConflicts, isEmpty);
}); });
/// 관리자 지시 (10/19): 원격 맞추기 (_mirrorV) 가 rsync 옵션의 거르기를 미리 보기와 같게 따르는지 -
/// 미리 보기의 "새로 · 바뀜" 은 실제로 생기고, "지워짐" 은 실제로 없어지고, 그 밖의 받는 쪽 것은 그대로
Future<void> checkSame(String src, String dst, String Function(String rel) real, String options, {required bool delete}) async {
final filter = RsyncFilter.parse(options);
final before = <String>{};
void scan(Directory d, String rel) {
for (final e in d.listSync()) {
final r = rel.isEmpty ? p.basename(e.path) : '$rel/${p.basename(e.path)}';
before.add(r);
if (e is Directory) scan(e, r);
}
}
final root = Directory(real(''));
if (root.existsSync()) scan(root, '');
final pv = await previewSync(src, dst, toRight: true, delete: delete, filter: filter);
await FileOps().mirror(src, dst, delete: delete, filter: filter);
for (final x in pv) {
final there = FileSystemEntity.typeSync(real(x.rel)) != FileSystemEntityType.notFound;
switch (x.action) {
case PreviewAction.add || PreviewAction.update:
expect(there, isTrue, reason: '미리 보기 + / ~ ${x.rel} → 실제로 있음 ($options)');
case PreviewAction.delete when !x.replacedByFile:
expect(there, isFalse, reason: '미리 보기 − ${x.rel} → 실제로 지워짐 ($options)');
default:
}
}
final shownDeleted = {for (final x in pv) if (x.action == PreviewAction.delete) x.rel};
for (final r in before) {
final gone = FileSystemEntity.typeSync(real(r)) == FileSystemEntityType.notFound;
final underShown = shownDeleted.any((d) => r == d || r.startsWith('$d/'));
if (gone) expect(underShown, isTrue, reason: '실제로 지워진 $r 는 미리 보기 지울 목록에 ($options)');
}
}
test('관리자 지시 (10/19): WebDAV 맞추기가 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따르고 미리 보기와 같음', () async {
void fresh() {
for (final d in [Directory(p.join(local.path, 'src')), Directory(p.join(remote.path, 'dst'))]) {
if (d.existsSync()) d.deleteSync(recursive: true);
}
lf('src/a.txt', 'a');
lf('src/skip.tmp', 's');
lf('src/cache/c.bin', 'c');
lf('src/keep/x.txt', 'x');
lf('src/keep/y.log', 'y');
lf('src/한글 폴더/가.txt', '가');
rf('dst/only.tmp', 'o'); // 받는 쪽에만 있고 거른 것
rf('dst/old.txt', 'old'); // 받는 쪽에만 있음
rf('dst/cache/remote.bin', 'r'); // 거른 폴더
rf('dst/gone/g.tmp', 'g'); // 받는 쪽에만 있는 폴더 안의 거른 것
rf('dst/gone/g.txt', 'g');
}
final src = p.join(local.path, 'src');
const dst = 'dav://$id/dst';
String real(String rel) => p.joinAll([remote.path, 'dst', ...rel.split('/').where((s) => s.isNotEmpty)]);
for (final (opts, delete) in [
('-av --exclude=*.tmp --exclude=cache/', true),
('-av --exclude=*.tmp --exclude=cache/ --delete-excluded', true),
('-av --include=keep/ --include=keep/*.txt --exclude=keep/* --exclude=*.tmp', true),
('-av --filter="- *.log" --filter="- /cache"', true),
('-av --exclude=*.tmp', false),
]) {
fresh();
await checkSame(src, dst, real, opts, delete: delete);
}
// 하나를 자세히: 거른 것은 보내지 않고 받는 쪽의 거른 것은 지키며, 거른 것이 든 폴더는 남기고 안의 것만 지움
fresh();
await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/'));
expect(File(real('a.txt')).existsSync(), isTrue);
expect(File(real('skip.tmp')).existsSync(), isFalse, reason: '거른 것은 보내지 않음');
expect(File(real('cache/c.bin')).existsSync(), isFalse, reason: '거른 폴더 안으로 내려가지 않음');
expect(File(real('only.tmp')).readAsStringSync(), 'o', reason: '받는 쪽의 거른 것은 지키기');
expect(File(real('cache/remote.bin')).existsSync(), isTrue);
expect(File(real('old.txt')).existsSync(), isFalse, reason: '거르지 않은 것은 --delete 로 지움');
expect(File(real('gone/g.tmp')).existsSync(), isTrue, reason: '거른 것이 든 폴더는 남기고');
expect(File(real('gone/g.txt')).existsSync(), isFalse, reason: '그 안의 거르지 않은 것만 지움');
expect(File(real('한글 폴더/가.txt')).readAsStringSync(), '가');
// --delete-excluded: 받는 쪽의 거른 것도 지움
fresh();
await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/ --delete-excluded'));
expect(File(real('only.tmp')).existsSync(), isFalse);
expect(Directory(real('cache')).existsSync(), isFalse);
expect(Directory(real('gone')).existsSync(), isFalse);
});
} }