WIP 원격 맞추기 (WebDAV · SFTP · SMB · FTP) 도 rsync 옵션의 거르기를 미리 보기와 같게 따름 - M2 의 본 고침 (검토 대기)
관리자 지시 (10/19): 원격은 rsync 대신 앱이 맞추며 (file_ops._mirrorV · 실시간 동기화 원격 쌍) --exclude 등을 따르지 않아, 지키려고 뺀 것도 복사 · 지웠다 (remote_filter22ae039는 경고만). 바탕: remote_filter = next27cd8eb8+ next363ef362(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>
parent
c0695b02d8
commit
fe34a8b556
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 リストに追加できません。先にこの端末へコピーしてください。",
|
||||
|
|
|
|||
|
|
@ -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 列表。请先复制到本设备。",
|
||||
|
|
|
|||
|
|
@ -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<List<String>> 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<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>[];
|
||||
Future<void> 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<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>[];
|
||||
Future<void> 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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 (_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<void> walk(String src, String dst) async {
|
||||
_check();
|
||||
|
|
@ -919,9 +921,42 @@ class FileOps {
|
|||
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;
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3416,7 +3416,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
Object? previewError;
|
||||
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
|
||||
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 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
|
||||
bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
|
||||
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
|
||||
|
|
@ -3499,7 +3500,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
// 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<ExplorerPage> {
|
|||
),
|
||||
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: [
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ Future<bool> 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<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -245,13 +246,6 @@ Future<bool> 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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue