From cd00d3ce3ac5d82646bcd0ed3f0332f1a8e4d035 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:21:21 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=B1=EC=9D=B4=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=9D=B4=EB=8F=99=EC=9D=80=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=97=90=20=EB=A7=9E=EC=B6=98=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=A7=8C=20=EC=A7=80=EC=9A=B4=EB=8B=A4=20(21?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (P0 데이터, 205 점검에서 찾음): WebDAV 가 끼거나 현재 방식으로 하는 "안의 것" 이동이 맞춘 뒤 원본 폴더를 다시 읽어 모든 파일을 지웠다. - 맞추기 (mirror) 가 지금 복사했거나 같다고 확인한 원본 파일만 알리고 (onSettled), 그 파일만 지운다. -u 로 건너뛴 것 (대상이 더 새것) · 맞추는 동안 새로 생긴 것 · 맞춘 뒤 바뀐 것은 원본에 남긴다. - 이동의 같음은 크기 + 바뀐 때 (±2초) 로만 본다 (WebDAV 의 "크기 같고 대상이 더 새것" 은 다시 보낸다 - 내용이 다른데 원본만 지우지 않게) - 로컬 원본의 링크 · junction 은 따라가지 않는다 (WebDAV 로 맞출 때도) - 시험: 건너뛴 것 · 도중에 생긴 것 · 링크 (로컬 · WebDAV) · 크기만 같고 내용이 다른 대상 Co-Authored-By: Claude Opus 5.5 --- lib/app/transfer_job.dart | 31 ++++++--- lib/core/file_ops.dart | 31 ++++++--- test/move_settled_test.dart | 122 ++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 test/move_settled_test.dart diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index ef766c1..b553919 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -239,12 +239,15 @@ class TransferJob extends ChangeNotifier { // 보낸 양으로 전송 속도 (WebDAV 포함) onBytes: _addBytes, conflict: conflict, + // 214: 이동은 맞춘 원본 파일만 지운다 + onSettled: move ? (s, size, modified) => settled[s] = (size, modified) : null, ); _tick(); if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) { // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 - await _ops!.mirror(sources[i], dest, update: _update, delete: _delete); - if (move && !cancelled) await _deleteFilesIn(sources[i]); + settled.clear(); + await _ops!.mirror(sources[i], dest, update: _update, delete: _delete, strict: move); + if (move && !cancelled) await _deleteSettled(); made.add(dest); } else { final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest); @@ -256,18 +259,28 @@ class TransferJob extends ChangeNotifier { index = sources.length; } - /// 맞춘 뒤 원본의 파일만 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리) - Future _deleteFilesIn(String dir) async { - for (final e in await vList(dir)) { + /// 214: 이번 맞추기에서 대상에 맞춘 원본 파일 (경로 → 그때의 크기 · 바뀐 때) + final settled = {}; + + /// 맞춘 뒤 원본의 파일을 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리). + /// 214: 맞춘 파일만 - 건너뛴 것 (-u 로 대상이 더 새것) · 맞추는 동안 새로 생긴 것은 남긴다. + /// 맞춘 뒤 바뀐 것 (크기 · 바뀐 때가 다름) 도 남긴다. 예전에는 원본 폴더를 다시 읽어 모든 파일을 지웠다 + Future _deleteSettled() async { + for (final MapEntry(key: path, value: (size, modified)) in settled.entries) { if (cancelled) return; - if (e.isDir) { - await _deleteFilesIn(e.path); - } else { - await vDelete(e.path); + final now = await vStat(path); + if (now == null || now.isDir) continue; + if (now.size != size || now.modified.difference(modified).inSeconds.abs() > 2) { + skippedSources.add(path); + continue; } + await vDelete(path); } } + /// 214: 이동인데 원본에 남긴 파일 (맞춘 뒤 바뀜) + final skippedSources = []; + void _addLog(String line) { if (line.trim().isEmpty) return; log.add(line.trim()); diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 69e47dc..a52bfdd 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -321,7 +321,11 @@ class FileOps { /// 건너뛴 항목 (원본 경로) final skipped = []; - FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes, this.conflict = NameConflict.rename}); + FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes, this.conflict = NameConflict.rename, this.onSettled}); + + /// 214: [mirror] 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때. + /// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다) + final void Function(String source, int size, DateTime modified)? onSettled; /// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀). /// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로. @@ -487,11 +491,12 @@ class FileOps { /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. /// /// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게). + /// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다 Future mirror(String srcDir, String dstDir, - {bool delete = false, bool update = false, bool allowEmptySource = false}) async { + {bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false}) async { // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다) if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource); - if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update); + if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict); var n = 0; Future walk(String src, String dst) async { _check(); @@ -517,7 +522,10 @@ class FileOps { final same = exists && (await t.length()) == st.size && ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); - if (same) continue; + if (same) { + onSettled?.call(e.path, st.size, st.modified); + continue; + } if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue; final tmp = '$target.jjsync'; final out = File(tmp).openWrite(); @@ -537,6 +545,7 @@ class FileOps { } catch (_) {} n++; onFileDone?.call(e.path); + onSettled?.call(e.path, st.size, st.modified); } } if (delete) { @@ -728,7 +737,7 @@ class FileOps { return made; } - Future _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false}) async { + Future _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false}) async { var n = 0; Future walk(String src, String dst) async { _check(); @@ -745,19 +754,27 @@ class FileOps { final name = vBasename(e.path); 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); continue; } final t = there[name]; if (t != null && !t.isDir && t.size == e.size) { - // 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로 - if (!t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) continue; + 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))))) { + 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 (t != null && t.isDir) await vDelete(target); await _copyFileV(e.path, target, e.size, e.modified, null); n++; + onSettled?.call(e.path, e.size, e.modified); } if (delete) { for (final name in there.keys) { diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart new file mode 100644 index 0000000..64df5c5 --- /dev/null +++ b/test/move_settled_test.dart @@ -0,0 +1,122 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/transfer_job.dart'; +import 'package:jj_mkvmaker/core/webdav.dart'; +import 'package:path/path.dart' as p; + +import 'support/dav_server.dart'; + +/// 214: 앱이 직접 하는 "안의 것" 이동 (WebDAV 가 낄 때 · 현재 방식) 은 대상에 맞춘 원본 파일만 지운다. +/// 예전에는 맞춘 뒤 원본 폴더를 다시 읽어 모든 파일을 지웠다 (건너뛴 것 · 도중에 생긴 것 · 링크 너머까지). +void main() { + late Directory tmp; + late String src, dst; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_move_settled_'); + src = p.join(tmp.path, 'src'); + dst = p.join(tmp.path, 'dst'); + File(p.join(src, 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('a'); + Directory(dst).createSync(); + }); + tearDown(() { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + TransferJob moveJob({String options = ''}) => + TransferJob(sources: [src], dest: dst, move: true, contents: true, options: options); + + test('214 a: -u 로 건너뛴 파일 (대상이 더 새것) 은 원본에 남는다 (rsync 처럼)', () async { + final old = File(p.join(src, 'keep.txt'))..writeAsStringSync('old source'); + old.setLastModifiedSync(DateTime(2020)); + File(p.join(dst, 'keep.txt')).writeAsStringSync('newer target'); + final job = moveJob(options: '-u'); + await job.run(); + expect(job.error, isNull); + expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'a'); + expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '맞춘 것은 지움'); + expect(old.existsSync(), isTrue, reason: '건너뛴 것은 남김 (예전에는 지웠다)'); + expect(File(p.join(dst, 'keep.txt')).readAsStringSync(), 'newer target'); + }); + + test('214 c: 맞추는 동안 원본에 새로 생긴 파일은 지우지 않는다', () async { + // 천천히 보내는 큰 파일 (맞추는 동안 새 파일을 만든다) + File(p.join(src, 'big.bin')).writeAsBytesSync(List.filled(300 * 1024, 7)); + final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, bandwidthKBps: 400); + final running = job.run(); + await Future.delayed(const Duration(milliseconds: 250)); + File(p.join(src, 'arrived.txt')).writeAsStringSync('new'); + await running; + expect(job.error, isNull); + expect(File(p.join(dst, 'big.bin')).lengthSync(), 300 * 1024); + expect(File(p.join(src, 'big.bin')).existsSync(), isFalse); + expect(File(p.join(src, 'arrived.txt')).existsSync(), isTrue, reason: '복사하지 않은 것은 남김'); + }); + + test('214 d: 원본 안의 링크 (junction) 는 따라가지 않는다 - 링크가 가리키는 바깥 폴더의 파일은 그대로', () async { + final outside = Directory(p.join(tmp.path, 'outside'))..createSync(); + final precious = File(p.join(outside.path, 'precious.txt'))..writeAsStringSync('keep me'); + try { + Link(p.join(src, 'jn')).createSync(outside.path); + } on FileSystemException { + markTestSkipped('이 환경에서는 링크를 만들 수 없음'); + return; + } + final job = moveJob(); + await job.run(); + expect(job.error, isNull); + expect(precious.existsSync(), isTrue); + expect(precious.readAsStringSync(), 'keep me'); + expect(File(p.join(src, 'a.txt')).existsSync(), isFalse); + }); + + group('214 b: WebDAV', () { + late Directory remote; + late TestDavServer server; + setUp(() async { + remote = Directory(p.join(tmp.path, 'remote'))..createSync(); + server = TestDavServer(remote); + await server.start(); + final s = DavServer(id: 'nas', name: 'NAS', url: server.url, user: 'user', password: 'pass'); + DavRegistry.configure([s]); + }); + tearDown(() async { + DavRegistry.configure([]); + await server.stop(); + }); + + test('214 b: 크기만 같고 내용이 다른 대상 (대상이 더 새것) 이 있어도, 이동은 다시 보내고 지운다 (내용을 잃지 않게)', () async { + HttpOverrides.global = null; + File(p.join(src, 'same.txt')) + ..writeAsStringSync('AAAA') + ..setLastModifiedSync(DateTime(2020)); + File(p.join(remote.path, 'same.txt')).writeAsStringSync('BBBB'); // 같은 크기 · 더 새것 · 다른 내용 + final job = TransferJob(sources: [src], dest: 'dav://nas/', move: true, contents: true); + await job.run(); + expect(job.error, isNull); + expect(File(p.join(remote.path, 'same.txt')).readAsStringSync(), 'AAAA', reason: '원본 내용이 대상에 (예전에는 같다고 보고 원본만 지웠다)'); + expect(File(p.join(src, 'same.txt')).existsSync(), isFalse); + expect(File(p.join(remote.path, 'a.txt')).readAsStringSync(), 'a'); + }); + + test('214 d (WebDAV 로): 로컬 원본의 링크는 따라가지 않는다', () async { + HttpOverrides.global = null; + final outside = Directory(p.join(tmp.path, 'outside2'))..createSync(); + final precious = File(p.join(outside.path, 'precious.txt'))..writeAsStringSync('keep me'); + try { + Link(p.join(src, 'jn')).createSync(outside.path); + } on FileSystemException { + markTestSkipped('이 환경에서는 링크를 만들 수 없음'); + return; + } + final job = TransferJob(sources: [src], dest: 'dav://nas/', move: true, contents: true); + await job.run(); + expect(job.error, isNull); + expect(precious.existsSync(), isTrue); + expect(File(p.join(src, 'a.txt')).existsSync(), isFalse); + }); + }); +}