From 88f0701d232977b5f795b0d219dcf6bd427fe25c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:27:39 +0900 Subject: [PATCH] =?UTF-8?q?modular=5Fsync=20F2:=20"=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC"=20=ED=8C=90=EB=8B=A8=EC=9D=84=20SameFile=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20-=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=A0=84=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=3D=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/core/files/same_file.dart: 크기 + 바뀐 때 ±2초, 받는 쪽 시각이 올린 때인 곳 (원격이 낀 앱 맞추기, 이동 아님) 은 받는 쪽이 더 새것도 같음. SameFile.forAppSync(remote, move) · targetNewer - 부르는 곳: 로컬 맞추기 (local_transfer) · 원격 맞추기 (any_transfer) · 미리 보기 (sync_preview) · 실시간 동기화 기다림 (sync_diff) · -u 건너뛰기 · 폴더 합칠 때 더 새것 지키기 - 고친 어긋남 (15 검토 F2): 원격으로 앱이 직접 맞출 때 받는 쪽이 같은 크기에 더 새 파일이면 미리 보기에 "바뀜" 으로 나왔는데 실제로는 보내지 않았다. 이제 미리 보기도 실제와 같은 판단 (rsync_runner viaDav · sync_preview_view direct 가 SameFile.forAppSync 를 넘김) - 약속 시험: 판단 표 + 실제 파일로 미리 보기와 실제 맞추기 (anyMirror · localMirror) 가 같은지 - 원격 규칙 · 이동 · 로컬 사용자가 보게 될 동작: 원격으로 앱이 직접 맞출 때 받는 쪽의 같은 크기 · 더 새 파일이 미리 보기에서 빠짐 (실제로도 보내지 않던 것). 로컬 · rsync · 이동은 그대로 확인: analyze 0 · 단위 1479 ×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 --- lib/app/rsync_runner.dart | 3 ++ lib/app/sync/sync_diff.dart | 6 ++- lib/core/files/any_transfer.dart | 19 ++++---- lib/core/files/local_transfer.dart | 11 +++-- lib/core/files/same_file.dart | 27 ++++++++++++ lib/core/sync_preview.dart | 7 ++- lib/ui/sync_preview_view.dart | 3 ++ test/same_file_contract_test.dart | 69 ++++++++++++++++++++++++++++++ 8 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 lib/core/files/same_file.dart create mode 100644 test/same_file_contract_test.dart diff --git a/lib/app/rsync_runner.dart b/lib/app/rsync_runner.dart index 047a2bf..05335a3 100644 --- a/lib/app/rsync_runner.dart +++ b/lib/app/rsync_runner.dart @@ -6,6 +6,7 @@ import '../l10n/tr.dart'; import 'copy_center.dart'; import 'settings.dart' show CopyTask; import 'transfer_job.dart'; +import '../core/files/same_file.dart'; /// 약속 (E5c): Rsync 화면의 [→] · [←] · [⇄] 실행. 화면 (확인 창) 은 이 객체만 부른다. /// rsync 옵션 · 미리 보기 · 실행은 sync_preview · sync_tools · rsync_filter · CopyCenter 를 부르기만 한다 (rsync 를 흉내 내지 않음). @@ -123,6 +124,8 @@ class RsyncRun { delete: deletes(), update: optionsUpdate(opts(t)) || (viaDav && copySkipNewer), skipLinks: viaDav, + // F2: 앱이 직접 (원격) 맞출 때는 실제 맞추기와 같은 판단 (이동이면 거의 같은 때만) + same: viaDav ? SameFile.forAppSync(remote: true, move: optionMove) : const SameFile(), filter: RsyncFilter.parse(opts(t))), ]; diff --git a/lib/app/sync/sync_diff.dart b/lib/app/sync/sync_diff.dart index 01b79ef..22ec8c0 100644 --- a/lib/app/sync/sync_diff.dart +++ b/lib/app/sync/sync_diff.dart @@ -11,6 +11,7 @@ import '../../core/file_ops.dart'; import '../../core/rsync_filter.dart'; import '../../core/vfs.dart'; import '../settings.dart'; +import '../../core/files/same_file.dart'; class SyncDiff { /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게): @@ -100,7 +101,7 @@ class SyncDiff { continue; } final ts = await tf.stat(); - if (ts.size != st.size || ts.modified.difference(st.modified).inSeconds.abs() > 2) out.add(r); + if (!const SameFile().same(size: st.size, modified: st.modified, targetSize: ts.size, targetModified: ts.modified)) out.add(r); } } if (x.delete && await Directory(dst).exists()) { @@ -149,7 +150,8 @@ class SyncDiff { 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)))) { + // 원격 맞추기와 같은 판단 (SameFile - 받는 쪽 시각이 올린 때라 더 새것도 같음) + if (t == null || t.isDir || !const SameFile(uploadTime: true).same(size: e.size, modified: e.modified, targetSize: t.size, targetModified: t.modified)) { out.add(r); } } diff --git a/lib/core/files/any_transfer.dart b/lib/core/files/any_transfer.dart index aadf43d..75d6a9d 100644 --- a/lib/core/files/any_transfer.dart +++ b/lib/core/files/any_transfer.dart @@ -7,6 +7,7 @@ import '../vfs.dart'; import 'path_rules.dart'; import 'transfer_control.dart'; import 'transfer_guard.dart'; +import 'same_file.dart'; // 약속 (나누기 file_ops): 어느 저장소든 (로컬 · WebDAV · SFTP · FTP 등 vfs 가 아는 곳) 의 복사 · 이동 · 맞추기 · 크기. // 한쪽이라도 원격이면 [FileOps] 가 이쪽을 부른다. 같은 서버 안의 이동은 서버가 옮긴다. @@ -94,7 +95,7 @@ Future> anyCopy(TransferControl c, List sources, String des // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 (WebDAV 도 로컬과 같게) if (inner && c.skipNewerInMerge) { final t = await vStat(target); - if (t != null && !t.isDir && t.modified.isAfter(st.modified.add(const Duration(seconds: 2)))) { + if (t != null && !t.isDir && SameFile.targetNewer(t.modified, st.modified)) { c.keptNewer.add(src); return; } @@ -170,6 +171,7 @@ Future _deleteExcept(String dir, Set keep) async { Future 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); // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 Future hasExcluded(String dir, String rel) async { @@ -228,16 +230,13 @@ Future anyMirror(TransferControl c, String srcDir, String dstDir, continue; } final t = there[name]; - if (t != null && !t.isDir && t.size == e.size) { - final close = t.modified.difference(e.modified).inSeconds.abs() <= 2; - // 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로. - // 214: 이동 ([strict]) 은 거의 같은 시각일 때만 같다고 본다 (아니면 다시 보내고 지운다 - 내용이 다른데 지우지 않게) - if (close || (!strict && !t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2))))) { - c.onSettled?.call(e.path, e.size, e.modified); - continue; - } + // 같은 파일 판단은 SameFile 하나 (미리 보기와 같음): 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로. + // 214: 이동 ([strict]) 은 거의 같은 시각일 때만 같다고 본다 (아니면 다시 보내고 지운다 - 내용이 다른데 지우지 않게) + if (t != null && !t.isDir && same.same(size: e.size, modified: e.modified, targetSize: t.size, targetModified: t.modified)) { + c.onSettled?.call(e.path, e.size, e.modified); + continue; } - if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; + if (update && t != null && SameFile.targetNewer(t.modified, e.modified)) continue; // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 if (t != null && t.isDir) { diff --git a/lib/core/files/local_transfer.dart b/lib/core/files/local_transfer.dart index f9cacfe..35e35d0 100644 --- a/lib/core/files/local_transfer.dart +++ b/lib/core/files/local_transfer.dart @@ -7,6 +7,7 @@ import '../vfs.dart'; import 'path_rules.dart'; import 'transfer_control.dart'; import 'transfer_guard.dart'; +import 'same_file.dart'; // 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기. // 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더. 출력: 만든 경로들 · 복사한 파일 수. @@ -129,7 +130,7 @@ Future _deleteExcept(String dir, Set keep) async { Future isNewerThere(String src, String target) async { final t = File(target); if (!await t.exists()) return false; - return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2))); + return SameFile.targetNewer(await t.lastModified(), await File(src).lastModified()); } /// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만) @@ -224,14 +225,12 @@ Future localMirror(TransferControl c, String srcDir, String dstDir, {bool d final st = await e.stat(); final t = File(target); final exists = await t.exists(); - final same = exists && - (await t.length()) == st.size && - ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); - if (same) { + // 같은 파일 판단은 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 && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue; + if (update && exists && SameFile.targetNewer(await t.lastModified(), st.modified)) continue; final tmp = '$target.jjsync'; final out = File(tmp).openWrite(); try { diff --git a/lib/core/files/same_file.dart b/lib/core/files/same_file.dart new file mode 100644 index 0000000..af42443 --- /dev/null +++ b/lib/core/files/same_file.dart @@ -0,0 +1,27 @@ +/// 약속 (modular_sync F2): 받는 쪽에 이미 있는 파일을 원본과 "같은 파일" 로 치는지 (다시 보내지 않음) 와 받는 쪽이 더 새것인지. +/// 앱이 직접 맞추는 길 (로컬 · 원격) 과 실행 전 미리 보기가 모두 이 하나를 부른다 - 예전에는 미리 보기와 실제 원격 맞추기의 판단이 달라 +/// 원격 받는 쪽이 더 새 파일이 미리 보기에는 "바뀜" 으로 보이고 실제로는 건너뛰었다. +/// - 입력: 원본 · 받는 쪽의 크기와 바뀐 때. 출력: 같은지 · 받는 쪽이 더 새것인지. 오류 없음 (순수). +/// - 바뀐 때는 [window] (2초) 안이면 같은 때로 본다 (FAT · 원격의 초 단위 시각). +/// - [uploadTime]: 받는 쪽 시각이 "올린 때" 인 곳 (원격이 낀 앱 맞추기, 이동 아님) - 크기가 같고 받는 쪽이 더 새것이어도 같다. +/// 이동 (원본을 지움) 은 거의 같은 때일 때만 같다고 본다 (내용이 다른데 원본을 지우지 않게, 214). +library; + +class SameFile { + const SameFile({this.uploadTime = false}); + + /// 앱이 직접 맞출 때의 판단: 원격이 끼고 이동이 아니면 [uploadTime] + factory SameFile.forAppSync({required bool remote, required bool move}) => SameFile(uploadTime: remote && !move); + + static const window = Duration(seconds: 2); + final bool uploadTime; + + bool same({required int size, required DateTime modified, required int targetSize, required DateTime targetModified}) { + if (size != targetSize) return false; + if (targetModified.difference(modified).abs() <= window) return true; + return uploadTime && !targetModified.isBefore(modified.subtract(window)); + } + + /// 받는 쪽이 [window] 넘게 더 새것인지 (-u 건너뛰기 · 미리 보기 "더 새 것" 표시) + static bool targetNewer(DateTime targetModified, DateTime modified) => targetModified.isAfter(modified.add(window)); +} diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index 3c88955..b94ca8d 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -6,6 +6,7 @@ import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; export 'sync_tools.dart' show isSourceRemovingOption; import 'vfs.dart'; +import 'files/same_file.dart'; /// rsync (Rsync 화면의 → · ← · ⇄) 를 실행하기 전에 무엇이 바뀌는지 미리 보기 (72). enum PreviewAction { @@ -112,6 +113,7 @@ bool optionsUpdate(String options) => /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). /// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)", /// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다. +/// F2: [same] = 같은 파일 판단 - 실제로 맞출 길과 같은 것 (앱이 직접 맞추고 원격이 끼면 [SameFile.forAppSync], rsync · 로컬은 처음 값). /// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다). /// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움) Future> previewSync(String src, String dst, @@ -120,6 +122,7 @@ Future> previewSync(String src, String dst, bool update = false, bool skipLinks = false, RsyncFilter filter = RsyncFilter.none, + SameFile same = const SameFile(), int limit = 2000}) async { final out = []; // 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김) @@ -196,9 +199,9 @@ Future> previewSync(String src, String dst, } if (t == null) { out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); - } else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) { + } else if (!same.same(size: e.size, modified: e.modified, targetSize: t.size, targetModified: t.modified)) { // -u: 받는 쪽이 더 새 것이면 건너뜀. 229: 건너뛰는 것도 · 더 새 것을 덮어쓰는 것도 목록에 따로 - final newer = t.modified.isAfter(e.modified.add(const Duration(seconds: 2))); + final newer = SameFile.targetNewer(t.modified, e.modified); if (update && newer) { out.add(PreviewItem(r, PreviewAction.skipNewer, toRight: toRight, size: e.size)); continue; diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index c00b288..6ab5841 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -10,6 +10,7 @@ import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'path_label.dart'; import 'theme.dart'; +import '../core/files/same_file.dart'; /// rsync 실행 전 비교 (72): 파일별로 → · ← · 받는 쪽에만 있음 · 지워짐 (빨강). 창이 열리면 바로 센다. /// [onResult]: 다 세면 결과 (실행 버튼에 지워질 수를 보여 주려고) @@ -266,6 +267,8 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, // robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면) skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options))), + // F2: 앱이 직접 맞출 때는 실제 맞추기와 같은 판단 (원격이 끼고 이동이 아니면 받는 쪽이 더 새것도 같음) + same: direct ? SameFile.forAppSync(remote: isDav(s) || isDav(dstOf(s)), move: t.move) : const SameFile(), filter: filter), ], onStart: () => set(() { diff --git a/test/same_file_contract_test.dart b/test/same_file_contract_test.dart new file mode 100644 index 0000000..0cf06ea --- /dev/null +++ b/test/same_file_contract_test.dart @@ -0,0 +1,69 @@ +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; + +/// modular_sync F2 약속: "같은 파일" 판단은 SameFile 하나 - 실행 전 미리 보기와 실제 맞추기가 같은 결과를 낸다 (실제 파일 · 실제 맞추기) +void main() { + final t0 = DateTime(2026, 10, 1, 12); + + test('판단 표: 크기 · 2초 안 · 받는 쪽이 더 새것 (올린 때 · 이동)', () { + bool same(SameFile s, {int size = 5, Duration dt = Duration.zero}) => + s.same(size: 5, modified: t0, targetSize: size, targetModified: t0.add(dt)); + const exact = SameFile(); + const upload = SameFile(uploadTime: true); + expect([same(exact, size: 6), same(upload, size: 6)], [false, false], reason: '크기가 다르면 다름'); + expect([same(exact, dt: const Duration(seconds: 2)), same(exact, dt: const Duration(seconds: -2))], [true, true]); + expect([same(exact, dt: const Duration(minutes: 1)), same(upload, dt: const Duration(minutes: 1))], [false, true], reason: '받는 쪽이 더 새것'); + expect([same(exact, dt: const Duration(minutes: -1)), same(upload, dt: const Duration(minutes: -1))], [false, false], reason: '받는 쪽이 더 예전'); + expect([SameFile.forAppSync(remote: true, move: false).uploadTime, SameFile.forAppSync(remote: true, move: true).uploadTime, + SameFile.forAppSync(remote: false, move: false).uploadTime], [true, false, false]); + expect([SameFile.targetNewer(t0.add(const Duration(seconds: 3)), t0), SameFile.targetNewer(t0.add(const Duration(seconds: 2)), t0)], [true, false]); + }); + + group('미리 보기 = 실제 맞추기 (받는 쪽 같은 크기 · 1분 더 새것)', () { + late Directory tmp; + late String s, m; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_samefile_'); + s = p.join(tmp.path, 's'); + m = p.join(tmp.path, 'm'); + File(p.join(s, 'a.txt')) + ..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))); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + String there() => File(p.join(m, 'a.txt')).readAsStringSync(); + + test('원격이 낀 앱 맞추기: 미리 보기에 없고 · 실제로도 보내지 않음', () async { + final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: true, move: false)); + expect(items, isEmpty); + expect(await anyMirror(TransferControl(), s, m), 0); + expect(there(), 'DST'); + }); + + test('이동 (원본을 지움): 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async { + final items = await previewSync(s, m, 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'); + }); + + test('로컬끼리: 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async { + final items = await previewSync(s, m, toRight: true); + expect([items.single.action, items.single.newer], [PreviewAction.update, true]); + expect(await localMirror(TransferControl(), s, m), 1); + expect(there(), 'SRC'); + }); + }); +}