modular_sync F1: 맞추기는 로컬 · 원격 모두 같은 구현 하나 (anyMirror) - 로컬도 거르기 · 같은 이름 폴더 충돌을 따름

- FileOps.mirror 가 늘 anyMirror 를 부름. localMirror (거르기를 무시 · 같은 이름 폴더 충돌 (M1) 을 모름) 를 지움
- anyMirror 의 같은 파일 판단: SameFile.forAppSync(remote: 경로에 원격이 있는지, move: strict) - "받는 쪽이 더 새것도 같음" 은 원격일 때만
  (로컬끼리는 크기 + ±2초 그대로)
- 약속 시험: mirror_one_impl (실제 폴더 - 거르기: 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않음 · 나머지는 --delete, 미리 보기와 같음 /
  같은 이름 폴더: 지우기 없으면 건너뛰고 알림 · 있으면 파일로). same_file 의 원격 경우는 실제 WebDAV 시험 서버로

사용자가 보게 될 동작: 로컬끼리 맞추기 (현재 방식 · 실시간 동기화) 가 rsync 옵션의 거르기를 따름 - 미리 보기에서 빠진 것은 실제로도 보내지 않고
받는 쪽의 거른 것은 지우지 않음 (예전에는 미리 보기와 달리 다 보내고 다 지웠다). 받는 쪽의 같은 이름 폴더는 지우기 포함일 때만 바꿈

확인: analyze 0 · 단위 1484 ×2 · 통합 6/6 ×2 · base10 214장 0px · rsync_page · copy_sync_shot · lsync_p0_shot · live_excl_shot ×2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 11:02:48 +09:00
parent 9d1883311f
commit 81a2c169ff
6 changed files with 88 additions and 91 deletions

View File

@ -75,8 +75,8 @@ class FileOps extends TransferControl {
{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 anyMirror(this, srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter);
return local.localMirror(this, srcDir, dstDir, delete: delete, update: update);
// F1 (modular_sync): 로컬 · 원격 모두 같은 구현 하나 - 예전 로컬 맞추기는 거르기 (filter) 를 무시하고 같은 이름 폴더 충돌 (M1) 도 몰랐다
return anyMirror(this, srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter);
}
Future<void> delete(List<String> paths) => local.deletePaths(this, paths);

View File

@ -171,7 +171,8 @@ Future<bool> _deleteExcept(String dir, Set<String> keep) async {
Future<int> anyMirror(TransferControl c, String srcDir, String dstDir,
{bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
var n = 0;
final same = SameFile(uploadTime: !strict);
// 받는 쪽 시각이 올린 때인 곳은 원격이 낄 때만 (로컬끼리는 크기 + ±2초) - 미리 보기와 같은 판단 (F1 · F2)
final same = SameFile.forAppSync(remote: isDav(srcDir) || isDav(dstDir), move: strict);
// 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다
// (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다
Future<bool> hasExcluded(String dir, String rel) async {

View File

@ -6,7 +6,6 @@ import '../../l10n/tr.dart';
import '../vfs.dart';
import 'path_rules.dart';
import 'transfer_control.dart';
import 'transfer_guard.dart';
import 'same_file.dart';
// 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기.
@ -198,73 +197,6 @@ Future<List<String>> localMove(TransferControl c, List<String> sources, String d
return made;
}
/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다 (로컬끼리).
/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다.
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다. 지우기 전 원본 확인 (ensureSourceForDelete) 은 부르는 쪽이 한다.
Future<int> localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
var n = 0;
Future<void> walk(String src, String dst) async {
c.check();
await Directory(dst).create(recursive: true);
final names = <String>{};
// 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다
List<FileSystemEntity> items;
try {
items = await Directory(src).list(followLinks: false).toList();
} catch (e) {
throw SourceUnreadableException(src, cause: e);
}
for (final e in items) {
final name = p.basename(e.path);
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다
names.add(name);
final target = p.join(dst, name);
if (e is Directory) {
await walk(e.path, target);
} else if (e is File) {
final st = await e.stat();
final t = File(target);
final exists = await t.exists();
// 같은 파일 판단은 SameFile 하나 (로컬끼리는 크기 + 바뀐 때 ±2초)
if (exists && const SameFile().same(size: st.size, modified: st.modified, targetSize: await t.length(), targetModified: await t.lastModified())) {
c.onSettled?.call(e.path, st.size, st.modified);
continue;
}
if (update && exists && SameFile.targetNewer(await t.lastModified(), st.modified)) continue;
final tmp = '$target.jjsync';
final out = File(tmp).openWrite();
try {
await for (final chunk in e.openRead()) {
c.check();
out.add(chunk);
c.onBytes?.call(chunk.length);
await c.throttle(chunk.length);
}
} finally {
await out.close();
}
await File(tmp).rename(target);
try {
await File(target).setLastModified(st.modified);
} catch (_) {}
n++;
c.onFileDone?.call(e.path);
c.onSettled?.call(e.path, st.size, st.modified);
}
}
if (delete) {
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) {
await e.delete(recursive: true);
}
}
}
}
await walk(srcDir, dstDir);
return n;
}
/// 지우기 (로컬 · WebDAV). 없는 것은 그냥 지나간다. 항목마다 취소를 본다
Future<void> deletePaths(TransferControl c, List<String> paths) async {
for (final s in paths) {

View File

@ -112,7 +112,7 @@ void main() {
expect(File(moved.single).readAsStringSync(), 'hello');
final m = p.join(tmp.path, 'm');
mk('m/old.txt');
expect(await localMirror(TransferControl(), p.join(tmp.path, 'd'), m, delete: true), 2);
expect(await anyMirror(TransferControl(), p.join(tmp.path, 'd'), m, delete: true), 2, reason: 'F1: 로컬 맞추기도 anyMirror 하나');
expect(File(p.join(m, 'old.txt')).existsSync(), isFalse);
final c = TransferControl()..cancel();
await expectLater(localCopy(c, [moved.single], p.join(tmp.path, 's')), throwsA(isA<FileOpCancelled>()));

View File

@ -0,0 +1,49 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show FileOps;
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:path/path.dart' as p;
/// modular_sync F1 약속: 맞추기는 로컬 · 원격 모두 같은 구현 하나 (anyMirror) - 예전 로컬 맞추기는 거르기 (filter) 를 무시하고
/// 받는 쪽의 같은 이름 폴더 충돌도 몰랐다. 실제 시험 폴더 · 실제 맞추기, 실행 전 미리 보기와 같은 결과
void main() {
late Directory tmp;
late String s, m;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_mirror1_');
s = p.join(tmp.path, 's');
m = p.join(tmp.path, 'm');
});
tearDown(() => tmp.deleteSync(recursive: true));
void mk(String path, [String text = 'x']) => File(path)
..createSync(recursive: true)
..writeAsStringSync(text);
bool there(String rel) => FileSystemEntity.typeSync(p.join(m, rel)) != FileSystemEntityType.notFound;
test('로컬도 거르기를 따른다: 거른 것은 보내지 않고 · 받는 쪽의 거른 것은 지우지 않음 · 나머지는 --delete 로 지움 (미리 보기와 같음)', () async {
mk(p.join(s, 'keep.txt'));
mk(p.join(s, 'skip.tmp'));
mk(p.join(s, 'cache', 'x.txt'));
mk(p.join(m, 'old.tmp'));
mk(p.join(m, 'extra.txt'));
final filter = RsyncFilter.parse('--exclude=*.tmp --exclude=cache/');
final preview = await previewSync(s, m, toRight: true, delete: true, filter: filter);
final adds = {for (final i in preview) if (i.action == PreviewAction.add) i.rel};
final dels = {for (final i in preview) if (i.action == PreviewAction.delete) i.rel};
expect([adds, dels], [{'keep.txt'}, {'extra.txt'}]);
await FileOps().mirror(s, m, delete: true, filter: filter);
expect([there('keep.txt'), there('skip.tmp'), there('cache'), there('old.tmp'), there('extra.txt')], [true, false, false, true, false]);
});
test('받는 쪽에 같은 이름의 폴더: 지우기 없으면 건너뛰고 알림 · 지우기 포함이면 폴더를 지우고 파일로', () async {
mk(p.join(s, 'clash'), 'file');
mk(p.join(m, 'clash', 'inner.txt'));
final ops = FileOps();
await ops.mirror(s, m);
expect([FileSystemEntity.isDirectorySync(p.join(m, 'clash')), ops.dirConflicts], [true, [p.join(m, 'clash')]]);
await FileOps().mirror(s, m, delete: true);
expect(File(p.join(m, 'clash')).readAsStringSync(), 'file');
});
}

View File

@ -2,11 +2,13 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/files/any_transfer.dart';
import 'package:jj_mkvmaker/core/files/local_transfer.dart';
import 'package:jj_mkvmaker/core/files/same_file.dart';
import 'package:jj_mkvmaker/core/files/transfer_control.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:path/path.dart' as p;
import 'package:jj_mkvmaker/core/webdav.dart';
import 'support/dav_server.dart';
/// modular_sync F2 약속: "같은 파일" 판단은 SameFile 하나 - 실행 전 미리 보기와 실제 맞추기가 같은 결과를 낸다 (실제 파일 · 실제 맞추기)
void main() {
@ -29,7 +31,9 @@ void main() {
group('미리 보기 = 실제 맞추기 (받는 쪽 같은 크기 · 1분 더 새것)', () {
late Directory tmp;
late String s, m;
setUp(() {
late TestDavServer server;
// 받는 쪽: 로컬 m 과, 실제 WebDAV 시험 서버 (dav://nas/m) 에 같은 파일
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_samefile_');
s = p.join(tmp.path, 's');
m = p.join(tmp.path, 'm');
@ -37,33 +41,44 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('SRC')
..setLastModifiedSync(t0);
File(p.join(m, 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('DST')
..setLastModifiedSync(t0.add(const Duration(minutes: 1)));
for (final dir in [m, p.join(tmp.path, 'remote', 'm')]) {
File(p.join(dir, 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('DST')
..setLastModifiedSync(t0.add(const Duration(minutes: 1)));
}
server = TestDavServer(Directory(p.join(tmp.path, 'remote')));
await server.start();
DavRegistry.configure([DavServer(id: 'nas', name: 'NAS', url: server.url, user: 'user', password: 'pass')]);
});
tearDown(() => tmp.deleteSync(recursive: true));
String there() => File(p.join(m, 'a.txt')).readAsStringSync();
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
String localThere() => File(p.join(m, 'a.txt')).readAsStringSync();
String remoteThere() => File(p.join(tmp.path, 'remote', 'm', 'a.txt')).readAsStringSync();
const remote = 'dav://nas/m';
test('원격이 낀 앱 맞추기: 미리 보기에 없고 · 실제로도 보내지 않음', () async {
final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: true, move: false));
final items = await previewSync(s, remote, toRight: true, same: SameFile.forAppSync(remote: true, move: false));
expect(items, isEmpty);
expect(await anyMirror(TransferControl(), s, m), 0);
expect(there(), 'DST');
expect(await anyMirror(TransferControl(), s, remote), 0);
expect(remoteThere(), 'DST');
});
test('이동 (원본을 지움): 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async {
final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: true, move: true));
test('원격 이동 (원본을 지움): 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async {
final items = await previewSync(s, remote, toRight: true, same: SameFile.forAppSync(remote: true, move: true));
expect([items.single.action, items.single.newer], [PreviewAction.update, true]);
expect(await anyMirror(TransferControl(), s, m, strict: true), 1);
expect(there(), 'SRC');
expect(await anyMirror(TransferControl(), s, remote, strict: true), 1);
expect(remoteThere(), 'SRC');
});
test('로컬끼리: 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async {
final items = await previewSync(s, m, toRight: true);
test('로컬끼리: 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄 (F1: 로컬도 같은 구현)', () async {
final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: false, move: false));
expect([items.single.action, items.single.newer], [PreviewAction.update, true]);
expect(await localMirror(TransferControl(), s, m), 1);
expect(there(), 'SRC');
expect(await anyMirror(TransferControl(), s, m), 1);
expect(localThere(), 'SRC');
});
});
}