앱이 직접 하는 이동은 대상에 맞춘 원본 파일만 지운다 (214)
관리자 지시 (P0 데이터, 205 점검에서 찾음): WebDAV 가 끼거나 현재 방식으로 하는 "안의 것" 이동이 맞춘 뒤 원본 폴더를 다시 읽어 모든 파일을 지웠다. - 맞추기 (mirror) 가 지금 복사했거나 같다고 확인한 원본 파일만 알리고 (onSettled), 그 파일만 지운다. -u 로 건너뛴 것 (대상이 더 새것) · 맞추는 동안 새로 생긴 것 · 맞춘 뒤 바뀐 것은 원본에 남긴다. - 이동의 같음은 크기 + 바뀐 때 (±2초) 로만 본다 (WebDAV 의 "크기 같고 대상이 더 새것" 은 다시 보낸다 - 내용이 다른데 원본만 지우지 않게) - 로컬 원본의 링크 · junction 은 따라가지 않는다 (WebDAV 로 맞출 때도) - 시험: 건너뛴 것 · 도중에 생긴 것 · 링크 (로컬 · WebDAV) · 크기만 같고 내용이 다른 대상 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>fix_mkv
parent
3ad8becafa
commit
cd00d3ce3a
|
|
@ -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<void> _deleteFilesIn(String dir) async {
|
||||
for (final e in await vList(dir)) {
|
||||
/// 214: 이번 맞추기에서 대상에 맞춘 원본 파일 (경로 → 그때의 크기 · 바뀐 때)
|
||||
final settled = <String, (int, DateTime)>{};
|
||||
|
||||
/// 맞춘 뒤 원본의 파일을 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리).
|
||||
/// 214: 맞춘 파일만 - 건너뛴 것 (-u 로 대상이 더 새것) · 맞추는 동안 새로 생긴 것은 남긴다.
|
||||
/// 맞춘 뒤 바뀐 것 (크기 · 바뀐 때가 다름) 도 남긴다. 예전에는 원본 폴더를 다시 읽어 모든 파일을 지웠다
|
||||
Future<void> _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 = <String>[];
|
||||
|
||||
void _addLog(String line) {
|
||||
if (line.trim().isEmpty) return;
|
||||
log.add(line.trim());
|
||||
|
|
|
|||
|
|
@ -321,7 +321,11 @@ class FileOps {
|
|||
/// 건너뛴 항목 (원본 경로)
|
||||
final skipped = <String>[];
|
||||
|
||||
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<int> 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<void> 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<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
|
||||
Future<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false}) async {
|
||||
var n = 0;
|
||||
Future<void> 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) {
|
||||
|
|
|
|||
|
|
@ -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<void>.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue