From fe34a8b55640b1656d1437a14fe53fdb9652f8fe Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:51:07 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EC=9B=90=EA=B2=A9=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B8=B0=20(WebDAV=20=C2=B7=20SFTP=20=C2=B7=20SMB=20=C2=B7=20F?= =?UTF-8?q?TP)=20=EB=8F=84=20rsync=20=EC=98=B5=EC=85=98=EC=9D=98=20?= =?UTF-8?q?=EA=B1=B0=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=99=80=20=EA=B0=99=EA=B2=8C=20=EB=94=B0?= =?UTF-8?q?=EB=A6=84=20-=20M2=20=EC=9D=98=20=EB=B3=B8=20=EA=B3=A0=EC=B9=A8?= =?UTF-8?q?=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (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 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + lib/app/live_sync.dart | 37 ++++++++++----- lib/app/transfer_job.dart | 8 +++- lib/core/file_ops.dart | 66 ++++++++++++++++++++++---- lib/ui/explorer_page.dart | 16 ++----- lib/ui/sync_preview_view.dart | 10 +--- test/explorer_webdav_test.dart | 5 +- test/sftp_test.dart | 26 +++++++++++ test/webdav_test.dart | 85 ++++++++++++++++++++++++++++++++++ 11 files changed, 214 insertions(+), 42 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b2199a7..81f5ffc 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -357,6 +357,7 @@ "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 · 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 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "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.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 8939c1e..eb2cabf 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -357,6 +357,7 @@ "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", "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 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index d52c759..a737a1d 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -357,6 +357,7 @@ "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: 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 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 137898f..5ea30e9 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -16,6 +16,7 @@ import '../l10n/tr.dart'; import '../platform/windows/rsync_installer.dart'; import 'app_controller.dart'; import 'settings.dart'; +import '../core/rsync_filter.dart'; /// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. /// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) @@ -149,8 +150,12 @@ class LiveSync extends ChangeNotifier { } /// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로. - static Future> diff(LiveSyncPair x, {int limit = 500}) async { - if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit); + /// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다. + /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이 + RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none; + + static Future> 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 = []; Future walk(String src, String dst, String rel) async { if (out.length >= limit) return; @@ -194,7 +199,7 @@ class LiveSync extends ChangeNotifier { } /// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면 - static Future> _diffV(LiveSyncPair x, {int limit = 500}) async { + static Future> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async { final out = []; Future walk(String src, String dst, String rel) async { if (out.length >= limit) return; @@ -215,20 +220,28 @@ class LiveSync extends ChangeNotifier { if (out.length >= limit) return; final name = vBasename(e.path); if (isAppTempName(name)) continue; // 78 보충 - names.add(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) { await walk(e.path, vJoin(dst, name), r); continue; } 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)))) { out.add(r); } } if (x.delete) { - for (final name in there.keys) { - if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); + for (final t in there.entries) { + 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); } try { - pending[k] = await diff(x); + pending[k] = await diff(x, filter: _filterOf(x)); await _countOnlyTarget(x); } catch (_) {} notifyListeners(); @@ -265,7 +278,7 @@ class LiveSync extends ChangeNotifier { } // 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것 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 (_) { onlyTarget.remove(k); // 세지 못하면 예전 글 } @@ -401,7 +414,7 @@ class LiveSync extends ChangeNotifier { try { // 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete]) 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) { await _setPair(x, x.copyWith(deleteConfirmed: true)); } else { @@ -419,7 +432,7 @@ class LiveSync extends ChangeNotifier { // M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다 final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps); 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.robocopy => await _robocopy(x, s), }; @@ -430,7 +443,7 @@ class LiveSync extends ChangeNotifier { : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); problems.remove(k); emptyDeletes.remove(k); - pending[k] = await diff(asked); + pending[k] = await diff(asked, filter: _filterOf(asked)); await _countOnlyTarget(asked); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); } on SourceUnreadableException catch (e) { @@ -446,7 +459,7 @@ class LiveSync extends ChangeNotifier { ); if (e.empty) { 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 (_) {} } else { emptyDeletes.remove(k); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index a10b640..c78357e 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -10,6 +10,7 @@ import '../core/sync_preview.dart' show isRsyncDeleteOption; import '../core/sync_tools.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; +import '../core/rsync_filter.dart'; /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 @@ -343,7 +344,12 @@ class TransferJob extends ChangeNotifier { if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) { // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 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); _ops!.dirConflicts.clear(); if (move && !cancelled) await _deleteSettled(); diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index aecae60..06e8115 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'playlist.dart' show naturalCompare; +import 'rsync_filter.dart'; import 'vfs.dart'; import 'webdav.dart' show DavException, DavLockedException; @@ -631,11 +632,12 @@ class FileOps { /// /// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게). /// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다 + /// [filter] (관리자 지시 10/19, M2 의 본 고침): rsync 옵션의 거르기 - 원격 (앱이 직접 맞춤) 도 미리 보기와 같은 규칙을 따른다 Future 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 (_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; Future walk(String src, String dst) async { _check(); @@ -919,9 +921,42 @@ class FileOps { return empty; } - Future _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false}) async { + Future _mirrorV(String srcDir, String dstDir, + {bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { var n = 0; - Future walk(String src, String dst) async { + // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 + // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 + Future 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 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 walk(String src, String dst, String rel) async { _check(); await vMkdirs(dst); final there = {for (final e in await vList(dst)) vBasename(e.path): e}; @@ -935,12 +970,15 @@ class FileOps { for (final e in items) { final name = vBasename(e.path); 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); final target = vJoin(dst, name); // 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게) if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue; if (e.isDir) { - await walk(e.path, target); + await walk(e.path, target, r); continue; } final t = there[name]; @@ -968,13 +1006,25 @@ class FileOps { onSettled?.call(e.path, e.size, e.modified); } if (delete) { - for (final name in there.keys) { - if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) await vDelete(vJoin(dst, name)); + for (final t in there.entries) { + 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; } } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b595b33..a06be22 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3416,7 +3416,8 @@ class _ExplorerPageState extends State { Object? previewError; // 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때) var filterAck = false; - List filterLimits() => _viaDav([l, r]) ? const [] : {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); + // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다 + List filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 bool deletes() => tasks.any((t) => optionsDelete(opts(t))); // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 @@ -3499,7 +3500,7 @@ class _ExplorerPageState extends State { key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), // 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음) 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) ...await previewSync(t.sources.first, t.dest, @@ -3508,7 +3509,7 @@ class _ExplorerPageState extends State { // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer), skipLinks: _viaDav([l, r]), - filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(t))), + filter: RsyncFilter.parse(opts(t))), ], // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 onStart: () => set(() { @@ -3541,15 +3542,8 @@ class _ExplorerPageState extends State { ), const SizedBox(height: 4), 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)), - // 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: [ diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 108cf7c..a2ca922 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -225,7 +225,8 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM : optionsUpdate(t.options) || (direct && skipNewer); String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src)); // 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( context: context, builder: (ctx) => StatefulBuilder( @@ -245,13 +246,6 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM ], // 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다) 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: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히 if (t.move) Padding( diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index 4ac56e7..659f126 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -206,8 +206,9 @@ void main() { expect(find.textContaining('→ + inner.txt'), findsOneWidget); expect(find.textContaining('= note.txt'), findsOneWidget); expect(find.textContaining('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget); - // M2: 옵션에 거르기가 없으면 거르기 경고는 없다 - expect(find.textContaining('거르기 (--exclude'), findsNothing); + // 관리자 지시 (10/19): 원격도 거르기를 따른다 - 안내에 적고, "따르지 않습니다" 경고 (M2) 는 없다 + expect(find.textContaining('거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름'), findsOneWidget); + expect(find.textContaining('거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다'), findsNothing); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행'))); final got = File(p.join(remote.path, '원격', 'inner.txt')); await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200); diff --git a/test/sftp_test.dart b/test/sftp_test.dart index 213f62e..74215eb 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -12,6 +12,7 @@ import 'package:path/path.dart' as p; import 'support/sftp_server.dart'; import 'package:jj_mkvmaker/core/file_ops.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) 를 띄운다 - /// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. @@ -183,6 +184,31 @@ void main() { expect((off! as SftpException).kind, SftpFailure.unreachable); }, 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 { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); diff --git a/test/webdav_test.dart b/test/webdav_test.dart index e857269..94d2134 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -15,6 +15,7 @@ import 'package:path/path.dart' as p; import 'support/dav_server.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; +import 'package:jj_mkvmaker/core/rsync_filter.dart'; void main() { late Directory tmp, remote, local; @@ -217,4 +218,88 @@ void main() { expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file'); expect(ops2.dirConflicts, isEmpty); }); + /// 관리자 지시 (10/19): 원격 맞추기 (_mirrorV) 가 rsync 옵션의 거르기를 미리 보기와 같게 따르는지 - + /// 미리 보기의 "새로 · 바뀜" 은 실제로 생기고, "지워짐" 은 실제로 없어지고, 그 밖의 받는 쪽 것은 그대로 + Future checkSame(String src, String dst, String Function(String rel) real, String options, {required bool delete}) async { + final filter = RsyncFilter.parse(options); + final before = {}; + 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); + }); }