import 'dart:async'; import 'dart:ffi'; import 'dart:io'; import 'package:ffi/ffi.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'playlist.dart' show naturalCompare; import 'vfs.dart'; import 'webdav.dart' show DavException, DavLockedException; /// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기. /// 폴더 안의 항목 하나 class FileEntry { final String path; final bool isDir; final int size; final DateTime modified; const FileEntry(this.path, {required this.isDir, this.size = 0, required this.modified}); String get name => p.basename(path); bool get hidden => name.startsWith('.'); /// 확장자 (소문자, 점 없이) String get ext => isDir ? '' : p.extension(path).replaceFirst('.', '').toLowerCase(); } enum SortBy { name, date, size, type } /// 정렬: 폴더 먼저, 이름은 숫자를 숫자로 비교 (file2 < file10) List sortEntries(List list, SortBy by, {bool descending = false}) { int cmp(FileEntry a, FileEntry b) { if (a.isDir != b.isDir) return a.isDir ? -1 : 1; // 폴더는 거꾸로여도 늘 위 final r = switch (by) { SortBy.name => 0, SortBy.date => a.modified.compareTo(b.modified), SortBy.size => a.size.compareTo(b.size), SortBy.type => a.ext.compareTo(b.ext), }; final v = r != 0 ? r : naturalCompare(a.name, b.name); return descending ? -v : v; } return [...list]..sort(cmp); } /// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다. Future> listEntries(String dir, {bool showHidden = false}) async { if (isDav(dir)) { // WebDAV: 네트워크 오류 · 인증 실패는 그대로 던진다 (화면이 "읽을 수 없음" 과 이유를 보여 줌) return [ for (final e in await vList(dir)) if (showHidden || !vBasename(e.path).startsWith('.')) FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), ]; } final out = []; // E10: 폴더를 읽지 못하면 (권한 없음 · 빠진 USB 등) 빈 폴더로 보이지 않게 던진다 (화면이 "읽을 수 없습니다: 이유" 와 [다시 시도]). // 일부를 읽은 뒤의 오류는 읽은 것까지 보여 준다 Object? failed; await for (final e in Directory(dir).list(followLinks: false).handleError((Object err) => failed ??= err)) { try { final st = await e.stat(); final isDir = st.type == FileSystemEntityType.directory; final entry = FileEntry(e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified); if (!showHidden && entry.hidden) continue; out.add(entry); } catch (_) {} } if (failed != null && out.isEmpty) throw failed!; return out; } /// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록. /// 165: 드라이브 문자는 Windows 가 알려 주는 목록 (GetLogicalDrives) 으로 - 드라이브마다 열어 보지 않는다 /// (끊긴 · 느린 네트워크 드라이브 (NAS) 를 열어 보느라 탐색기가 10초씩 멈췄다) List<(String path, String label)> windowsDrives() { int mask; try { mask = DynamicLibrary.open('kernel32.dll').lookupFunction('GetLogicalDrives')(); } catch (_) { mask = 0; } if (mask == 0) return [(r'C:\', 'C:')]; return [ for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')) if (mask & (1 << (c.codeUnitAt(0) - 65)) != 0) ('$c:\\', '$c:'), ]; } /// 165: 드라이브 종류 (GetDriveTypeW: 2 꺼내는 장치 (카드 리더 · USB) · 3 고정 · 4 네트워크 · 5 CD). 드라이브를 열어 보지 않는다. 모르면 0 int driveType(String root) { if (!Platform.isWindows) return 0; final w = root.toNativeUtf16(); try { final f = DynamicLibrary.open('kernel32.dll') .lookupFunction), int Function(Pointer)>('GetDriveTypeW'); return f(w); } catch (_) { return 0; } finally { calloc.free(w); } } /// 네트워크 드라이브인지 bool isNetworkDrive(String root) => driveType(root) == 4; /// 48: 받는 폴더에 같은 이름이 있을 때 enum NameConflict { /// "이름 (2).확장자" 로 (예전 동작) rename, /// 덮어쓰기 (폴더는 합치고 안의 같은 이름 파일을 덮어씀) overwrite, /// 건너뛰기 (그 항목은 복사 · 이동하지 않음) skip, } /// 같은 이름이 있으면 "이름 (2).확장자" 로 String uniqueTarget(String dir, String name) { var target = p.join(dir, name); if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; final base = p.basenameWithoutExtension(name), ext = p.extension(name); for (var i = 2;; i++) { target = p.join(dir, '$base ($i)$ext'); if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; } } /// 같은 경로인지 (Windows 는 대소문자 무시) bool samePath(String a, String b) { final dav = davSame(a, b); if (dav != null) return dav; final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y; } /// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게) bool isSameOrInside(String a, String b) { final dav = davInside(a, b); if (dav != null) return dav; final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase())); } /// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - rsync · robocopy 는 스스로 막지 않는다). /// 문제가 있으면 알릴 글, 없으면 null. /// - 원본이 없음 (다른 곳에서 지워졌거나 옮겨짐) · 대상 폴더가 없음 /// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동 /// - 이미 그 폴더에 있는 것을 같은 폴더로 이동 /// [contents]: 폴더 "안의 것" 을 옮긴다 (Rsync 화면 · 원본/ → 대상/) - 바로 위 폴더로 옮기는 것도 된다 (205) String? transferProblem(List sources, String dest, {required bool move, bool contents = false}) { // WebDAV 경로는 네트워크라 여기서 있는지 확인하지 않는다 (없으면 복사할 때 알림) final missing = [for (final s in sources) if (vMissingSync(s)) s]; if (missing.isNotEmpty) { return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(vBasename).join(', ')]); } if (!isDav(dest) && !Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]); for (final s in sources) { if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), vBasename(s)]); } // 폴더째 옮길 때만: 이미 그 폴더 안에 있으면 옮길 것이 없다. // 205: 안의 것을 바로 위 폴더로 올리는 이동 (Down\Movie/ → Down) 은 막지 않는다 (예전에는 조용히 시작하지 않았다) if (move && !contents && samePath(vDirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [vBasename(s)]); } return null; } /// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수. /// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로. Future removeEmptyDirs(String root, {bool keepRoot = true}) async { if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot); var n = 0; Future walk(Directory d) async { var empty = true; final items = await d.list(followLinks: false).handleError((_) => empty = false).toList(); for (final e in items) { if (e is Directory && await walk(e)) { try { await e.delete(); n++; } catch (_) { empty = false; } } else { empty = false; } } return empty; } final d = Directory(root); if (!await d.exists()) return 0; if (await walk(d) && !keepRoot) { try { await d.delete(); n++; } catch (_) {} } return n; } /// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) typedef CopyProgress = void Function(String current, int done, int total); /// [removeEmptyDirs] 의 WebDAV 판 Future _removeEmptyDirsV(String root, {bool keepRoot = true}) async { var n = 0; Future walk(String dir) async { var empty = true; for (final e in await vList(dir)) { if (e.isDir && await walk(e.path)) { try { await vDelete(e.path); n++; } catch (_) { empty = false; } } else { empty = false; } } return empty; } if (!await vExists(root)) return 0; if (await walk(root) && !keepRoot) { try { await vDelete(root); n++; } catch (_) {} } return n; } class FileOpCancelled implements Exception { const FileOpCancelled(); } /// 동기화 · 미러: 원본을 읽지 못했거나 (권한 꺼짐 · SD 카드 빠짐 · 네트워크 끊김) 원본이 통째로 비었는데 대상에는 파일이 있다. /// "원본에 없는 것을 대상에서 지우기" 가 원본을 빈 것으로 보고 대상을 모두 지우지 않도록, 아무것도 지우지 않고 멈춘다. class SourceUnreadableException implements Exception { final String path; final Object? cause; /// 읽기는 됐지만 원본이 비어 있음 (대상에는 파일이 있음) final bool empty; const SourceUnreadableException(this.path, {this.cause, this.empty = false}); /// 128: 마스터 비밀번호를 기다리는 중 (읽지 못한 것이 아님) bool get locked => cause is DavLockedException; /// 원인을 짧게 (예외 원문 대신 - 68) String get detail { final c = cause; if (c == null) return ''; if (c is FileSystemException) { if (c.message == '없음' && c.osError == null) return tr('폴더가 없습니다'); return c.osError?.message.trim().isNotEmpty == true ? c.osError!.message.trim() : c.message; } if (c is DavException) return c.message; return '$c'.split('\n').first; } @override String toString() => empty ? trf('원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}', [path]) : trf('원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}', [path, detail.isEmpty ? '' : ' · $detail']); } /// Windows 네트워크 공유 경로의 맨 위 (\\서버\공유). 37 String uncRoot(String path) { final parts = path.replaceAll('/', r'\').split(r'\').where((s) => s.isNotEmpty).toList(); return parts.length < 2 ? path : '\\\\${parts[0]}\\${parts[1]}'; } /// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71) bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync'); /// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지. /// 문제가 있으면 [SourceUnreadableException] (아무것도 지우지 않게). /// [allowEmpty]: 사용자가 지울 목록을 보고 "원본이 비어도 그래도 맞추기" 를 고른 경우 (70) - 읽지 못하는 것은 그래도 막는다 Future ensureSourceForDelete(String src, String dst, {bool allowEmpty = false}) async { List<({String path, bool isDir, int size, DateTime modified})> items; try { if (!await vExists(src)) throw const FileSystemException('없음'); items = await vList(src, strict: true); } catch (e) { throw SourceUnreadableException(src, cause: e); } if (items.isNotEmpty || allowEmpty) return; var dstItems = const <({String path, bool isDir, int size, DateTime modified})>[]; try { dstItems = await vList(dst); } catch (_) {} if (dstItems.isNotEmpty) throw SourceUnreadableException(src, empty: true); } /// 복사 · 이동 · 삭제. [cancel] 을 부르면 다음 조각에서 멈춘다. class FileOps { bool _cancel = false; void cancel() => _cancel = true; /// 속도 제한 (KB/s, 0 = 제한 없음) final int bandwidthKBps; /// 파일 하나를 다 복사할 때마다 (원본 경로) final void Function(String source)? onFileDone; /// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용 final void Function(int bytes)? onBytes; /// 48: 같은 이름이 있을 때 (맨 위 항목 기준 - 덮어쓰기면 폴더 안의 같은 이름 파일도 덮어씀) final NameConflict conflict; /// 건너뛴 항목 (원본 경로) final skipped = []; 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 = 건너뜀). /// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로. Future _targetFor(String src, String destDir) async { final name = vBasename(src); final plain = vJoin(destDir, name); if (conflict == NameConflict.rename || !await vExists(plain)) return vUniqueTarget(destDir, name); if (samePath(src, plain)) return vUniqueTarget(destDir, name); if (conflict == NameConflict.skip) { skipped.add(src); return null; } if (await vIsDir(src) != await vIsDir(plain)) return vUniqueTarget(destDir, name); return plain; } /// 한쪽이라도 WebDAV 면 아래의 "V" (어느 저장소든) 판으로 static bool _anyDav(Iterable paths) => paths.any(isDav); final _clock = Stopwatch(); int _sent = 0; /// 속도 제한: 보낸 양이 허용량보다 앞서면 그만큼 쉰다 Future _throttle(int bytes) async { if (bandwidthKBps <= 0) return; if (!_clock.isRunning) _clock.start(); _sent += bytes; final wantMs = _sent * 1000 ~/ (bandwidthKBps * 1024); final ahead = wantMs - _clock.elapsedMilliseconds; if (ahead > 5) await Future.delayed(Duration(milliseconds: ahead)); } void _check() { if (_cancel) throw const FileOpCancelled(); } /// 전체 크기 (진행률용) static Future totalSize(List paths) async { if (_anyDav(paths)) return _totalSizeV(paths); var total = 0; for (final s in paths) { final t = FileSystemEntity.typeSync(s, followLinks: false); if (t == FileSystemEntityType.directory) { await for (final e in Directory(s).list(recursive: true, followLinks: false).handleError((_) {})) { if (e is File) { try { total += await e.length(); } catch (_) {} } } } else if (t == FileSystemEntityType.file) { total += await File(s).length(); } } return total; } /// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 "이름 (2)"). 만든 경로들을 돌려준다. Future> copy(List sources, String destDir, {CopyProgress? onProgress}) async { if (_anyDav([...sources, destDir])) return _copyV(sources, destDir, onProgress: onProgress); final total = await totalSize(sources); var done = 0; final made = []; // 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게 Future copyFile(File f, String finalTarget) async { _check(); final target = '$finalTarget.jjpart'; final out = File(target).openWrite(); try { try { await for (final chunk in f.openRead()) { _check(); out.add(chunk); done += chunk.length; onProgress?.call(f.path, done, total); onBytes?.call(chunk.length); await _throttle(chunk.length); } } finally { await out.close(); } _check(); } catch (_) { // 취소 · 실패: 만들다 만 파일을 남기지 않는다 try { await File(target).delete(); } catch (_) {} rethrow; } // 덮어쓰기: 새 파일을 다 쓴 뒤에야 옛 파일을 지운다 (도중에 실패하면 옛 파일이 남게) if (File(finalTarget).existsSync()) await File(finalTarget).delete(); await File(target).rename(finalTarget); try { await File(finalTarget).setLastModified(await f.lastModified()); } catch (_) {} onFileDone?.call(f.path); } Future copyAny(String src, String target) async { if (FileSystemEntity.isDirectorySync(src)) { await Directory(target).create(recursive: true); await for (final e in Directory(src).list(followLinks: false)) { await copyAny(e.path, p.join(target, p.basename(e.path))); } } else { await copyFile(File(src), target); } } for (final s in sources) { if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); } final target = await _targetFor(s, destDir); if (target == null) continue; await copyAny(s, target); made.add(target); } return made; } /// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 Future> move(List sources, String destDir, {CopyProgress? onProgress}) async { if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress); final made = []; for (final s in sources) { _check(); if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); if (samePath(p.dirname(s), destDir)) { made.add(s); // 이미 그 폴더에 있음 continue; } final target = await _targetFor(s, destDir); if (target == null) continue; final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound; if (exists && FileSystemEntity.isDirectorySync(s)) { // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 made.addAll(await copy([s], destDir, onProgress: onProgress)); _check(); await delete([s]); continue; } try { final t = FileSystemEntity.typeSync(s); // 덮어쓰기 (파일): 이름 바꾸기가 옛 파일을 바로 바꿔 놓는다 (다른 드라이브면 아래 복사가 덮어씀) if (t == FileSystemEntityType.directory) { await Directory(s).rename(target); } else { await File(s).rename(target); } made.add(target); } on FileSystemException { // 다른 드라이브 · 저장 장치: 복사 후 삭제 final copied = await copy([s], destDir, onProgress: onProgress); await delete([s]); made.addAll(copied); } } return made; } /// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다. /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. /// /// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게). /// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다 Future mirror(String srcDir, String dstDir, {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, strict: strict); var n = 0; Future walk(String src, String dst) async { _check(); await Directory(dst).create(recursive: true); final names = {}; // 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다 List 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); 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(); final same = exists && (await t.length()) == st.size && ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); 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(); try { await for (final chunk in e.openRead()) { _check(); out.add(chunk); onBytes?.call(chunk.length); await _throttle(chunk.length); } } finally { await out.close(); } await File(tmp).rename(target); try { await File(target).setLastModified(st.modified); } catch (_) {} n++; onFileDone?.call(e.path); 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')) { await e.delete(recursive: true); } } } } await walk(srcDir, dstDir); return n; } Future delete(List paths) async { for (final s in paths) { _check(); if (isDav(s)) { await vDelete(s); continue; } final t = FileSystemEntity.typeSync(s, followLinks: false); if (t == FileSystemEntityType.directory) { await Directory(s).delete(recursive: true); } else if (t != FileSystemEntityType.notFound) { await File(s).delete(); } } } /// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. static Future rename(String path, String newName) async { final name = newName.trim(); if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) { throw FileSystemException(tr('쓸 수 없는 이름입니다'), name); } if (isDav(path)) { final target = vJoin(vDirname(path), name); if (target == vNorm(path)) return target; if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); await vRename(path, target); return target; } final target = p.join(p.dirname(path), name); if (target == path) return path; if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && target.toLowerCase() != path.toLowerCase()) { throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); } final t = FileSystemEntity.typeSync(path); return t == FileSystemEntityType.directory ? (await Directory(path).rename(target)).path : (await File(path).rename(target)).path; } static Future makeFolder(String dir, String name) async { final n = name.trim(); if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), n); if (isDav(dir)) { final target = vJoin(dir, n); if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); await vMkdirs(target); return target; } final target = p.join(dir, n); if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); } return (await Directory(target).create()).path; } // ───────── 어느 저장소든 (로컬 · WebDAV) ───────── static Future _totalSizeV(List paths) async { var total = 0; Future walk(String path) async { final st = await vStat(path); if (st == null) return; if (!st.isDir) { total += st.size; return; } for (final e in await vList(path)) { if (e.isDir) { await walk(e.path); } else { total += e.size; } } } for (final s in paths) { await walk(s); } return total; } /// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행) Future _copyFileV(String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async { _check(); final input = await vOpenRead(src); final relay = StreamController>(); final pump = () async { try { await for (final chunk in input) { if (_cancel) { relay.addError(const FileOpCancelled()); break; } relay.add(chunk); onBytes?.call(chunk.length); onChunk?.call(chunk.length); await _throttle(chunk.length); } } catch (e, st) { relay.addError(e, st); } finally { await relay.close(); } }(); try { await vWrite(target, relay.stream, length: size, modified: modified); } finally { await pump; } if (_cancel) { try { await vDelete(target); } catch (_) {} throw const FileOpCancelled(); } onFileDone?.call(src); } Future> _copyV(List sources, String destDir, {CopyProgress? onProgress}) async { final total = await _totalSizeV(sources); var done = 0; final made = []; Future copyAny(String src, String target) async { _check(); final st = await vStat(src); if (st == null) throw FileSystemException(tr('원본이 없습니다'), src); if (st.isDir) { await vMkdirs(target); for (final e in await vList(src)) { await copyAny(e.path, vJoin(target, vBasename(e.path))); } } else { await _copyFileV(src, target, st.size, st.modified, (n) { done += n; onProgress?.call(src, done, total); }); } } for (final s in sources) { if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); final target = await _targetFor(s, destDir); if (target == null) continue; await copyAny(s, target); made.add(target); } return made; } Future> _moveV(List sources, String destDir, {CopyProgress? onProgress}) async { final made = []; for (final s in sources) { _check(); if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); if (samePath(vDirname(s), destDir)) { made.add(s); continue; } final target = await _targetFor(s, destDir); if (target == null) continue; final exists = await vExists(target); // 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - 덮어쓸 폴더는 합쳐야 하니 복사로 if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server && !(exists && await vIsDir(s))) { if (exists) await vDelete(target); await vRename(s, target); made.add(target); continue; } made.addAll(await _copyV([s], destDir, onProgress: onProgress)); await vDelete(s); } return made; } 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(); await vMkdirs(dst); final there = {for (final e in await vList(dst)) vBasename(e.path): e}; final names = {}; List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(src, strict: true); } catch (e) { throw SourceUnreadableException(src, cause: e); } for (final e in items) { 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) { 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) { if (!names.contains(name) && !name.endsWith('.jjsync')) await vDelete(vJoin(dst, name)); } } } await walk(srcDir, dstDir); return n; } } /// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. /// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게). Stream searchFiles(String root, String query, {bool showHidden = false, int limit = 2000, void Function(String dir, Object error)? onSkipped}) async* { final q = query.trim().toLowerCase(); if (q.isEmpty) return; final wild = q.contains('*') || q.contains('?'); final re = wild ? RegExp('^${RegExp.escape(q).replaceAll(r'\*', '.*').replaceAll(r'\?', '.')}\$') : null; var n = 0; final pending = [root]; while (pending.isNotEmpty) { final dir = pending.removeAt(0); List items; try { // strict: 읽지 못하면 빈 폴더로 치지 않고 알린다 (24) items = [ for (final e in await vList(dir, strict: true)) if (showHidden || !vBasename(e.path).startsWith('.')) FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), ]; } catch (e) { onSkipped?.call(dir, e); continue; } for (final e in sortEntries(items, SortBy.name)) { final name = e.name.toLowerCase(); if (re != null ? re.hasMatch(name) : name.contains(q)) { yield e; if (++n >= limit) return; } if (e.isDir) pending.add(e.path); } } } /// "1.2GB" · "350MB" · "12KB" String formatSize(int bytes) { // 203: 크기를 모름 (잠긴 시스템 파일 · 읽지 못함 → -1) 은 "-1B" 가 아니라 "—" if (bytes < 0) return '—'; const k = 1024; if (bytes >= k * k * k) return '${(bytes / (k * k * k)).toStringAsFixed(1)}GB'; if (bytes >= k * k) return '${(bytes / (k * k)).toStringAsFixed(bytes < 10 * k * k ? 1 : 0)}MB'; if (bytes >= k) return '${(bytes / k).round()}KB'; return '${bytes}B'; }