From 197072b4821d29b2e9caee1a90c57c532c1eb669 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:25:46 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20A:=20file?= =?UTF-8?q?=5Fops=20(1,081)=20=EB=A5=BC=20lib/core/files/=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=208=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95?= =?UTF-8?q?=EB=A9=B4=20file=5Fops)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - file_listing (항목 · 정렬 · 폴더 읽기 · 찾기 · 크기 글) · windows_drives (드라이브 · 종류 · UNC 맨 위) - path_rules (같은 경로 · 안에 있음 · 바깥 것만 · 전송 전 확인 · 임시 이름) · name_ops (이름 규칙 · 이름 바꾸기 · 새 폴더) - transfer_guard (취소 · 원본을 읽을 수 없음 · 지우기 전 원본 확인 · 빈 폴더 정리) - transfer_control (취소 · 속도 제한 · 알림 · 같은 이름 처리 - FileOps 가 이것을 이음) - local_transfer (로컬끼리 복사 · 이동 · 맞추기 · 지우기) · any_transfer (원격이 섞인 복사 · 이동 · 맞추기) - file_ops.dart 는 FileOps (고르기만) 와 예전 이름 그대로 export - 부르는 쪽은 바꾸지 않음 - 약속 시험: test/file_ops_modules_contract_test.dart (실제 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/core/file_ops.dart | 1097 +--------------------- lib/core/files/any_transfer.dart | 273 ++++++ lib/core/files/file_listing.dart | 127 +++ lib/core/files/local_transfer.dart | 284 ++++++ lib/core/files/name_ops.dart | 79 ++ lib/core/files/path_rules.dart | 118 +++ lib/core/files/transfer_control.dart | 93 ++ lib/core/files/transfer_guard.dart | 129 +++ lib/core/files/windows_drives.dart | 50 + test/file_ops_modules_contract_test.dart | 138 +++ 10 files changed, 1343 insertions(+), 1045 deletions(-) create mode 100644 lib/core/files/any_transfer.dart create mode 100644 lib/core/files/file_listing.dart create mode 100644 lib/core/files/local_transfer.dart create mode 100644 lib/core/files/name_ops.dart create mode 100644 lib/core/files/path_rules.dart create mode 100644 lib/core/files/transfer_control.dart create mode 100644 lib/core/files/transfer_guard.dart create mode 100644 lib/core/files/windows_drives.dart create mode 100644 test/file_ops_modules_contract_test.dart diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index b5ee4ab..dbada26 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -1,631 +1,69 @@ -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 'files/any_transfer.dart'; +import 'files/local_transfer.dart' as local; +import 'files/name_ops.dart'; +import 'files/transfer_control.dart'; +import 'files/transfer_guard.dart'; import 'rsync_filter.dart'; import 'vfs.dart'; -import 'webdav.dart' show DavException, DavLockedException; -/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기. +export 'files/file_listing.dart' show FileEntry, SortBy, sortEntries, listEntries, searchFiles, formatSize; +export 'files/windows_drives.dart' show windowsDrives, driveType, isNetworkDrive, uncRoot; +export 'files/path_rules.dart' + show + NameConflict, + uniqueTarget, + samePath, + shortUnder, + isSameOrInside, + outermostPaths, + transferProblem, + isPartialFile, + isAppTempName, + appTempRsyncExcludes, + appTempRobocopyExcludes; +export 'files/name_ops.dart' show nameProblem, usesWindowsNames; +export 'files/transfer_guard.dart' show FileOpCancelled, SourceUnreadableException, ensureSourceForDelete, removeEmptyDirs; +export 'files/transfer_control.dart' show CopyProgress; -/// 폴더 안의 항목 하나 -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) -/// 80: [dirSize] 가 있으면 크기순에서 폴더도 그 크기로 (모르는 폴더는 가장 작게) -List sortEntries(List list, SortBy by, {bool descending = false, int? Function(FileEntry dir)? dirSize}) { - int sizeOf(FileEntry e) => e.isDir ? (dirSize?.call(e) ?? -1) : e.size; - 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 => sizeOf(a).compareTo(sizeOf(b)), - 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; -} - -/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1"). -/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로. -/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림) -String shortUnder(String root, String dir, {int keep = 2}) { - final parts = []; - var x = dir; - for (var i = 0; i < 256 && !samePath(x, root); i++) { - final up = vDirname(x); - if (up == x) return vDisplay(dir); - parts.insert(0, vBasename(x)); - x = up; - } - if (!samePath(x, root)) return vDisplay(dir); - // 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버) - final top = vDirname(root) == root; - parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root)); - final sep = isDav(dir) ? '/' : p.separator; - if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep); - return parts.join(sep); -} - -/// [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())); -} - -/// 79-c: 고른 것 중 다른 고른 폴더 안에 있는 것 · 같은 것을 빼고 바깥 것만 (차례는 그대로). -/// 폴더와 그 안의 파일을 함께 고르면 안의 파일을 두 번 처리하거나 (이동 뒤 "없음" 오류) 확인 창 개수가 부풀었다 -List outermostPaths(Iterable paths) { - final all = paths.toList(); - final out = []; - for (var i = 0; i < all.length; i++) { - final x = all[i]; - if (out.any((o) => samePath(o, x))) continue; - if (all.any((o) => !samePath(o, x) && isSameOrInside(x, o))) continue; - out.add(x); - } - return out; -} - -/// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - 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'); - -/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게 -bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_'); - -/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수) -const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*']; -const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*']; - -/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null). -/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자. -/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음 -String? nameProblem(String name, {required bool windowsRules}) { - if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름'); - if (!windowsRules) return null; - final base = name.split('.').first.trim().toUpperCase(); - if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]); - if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음'); - if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)'); - return null; -} - -/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고) -bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path); - -/// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지. -/// 문제가 있으면 [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 = []; - - /// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것. - /// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음) - final dirConflicts = []; +// 약속 (나누기 file_ops): 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분 - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/files/ 의 모듈이 한다: file_listing (폴더 읽기 · 정렬 · 찾기) · windows_drives · path_rules · name_ops · +// transfer_guard · transfer_control · local_transfer (로컬끼리) · any_transfer (원격이 섞임). +// [FileOps]: 입력 = 설정 (속도 제한 · 알림 · 같은 이름 처리), 원본들 · 받는 폴더. 출력 = 만든 경로 · 복사한 수. +// 오류 = 취소 [FileOpCancelled] · 원본을 읽지 못함 [SourceUnreadableException] · 파일 오류 (FileSystemException · DavException) 그대로. +/// 복사 · 이동 · 삭제. [cancel] 을 부르면 다음 조각에서 멈춘다. 한쪽이라도 원격 (WebDAV 등) 이면 어느 저장소든 판으로 +class FileOps extends TransferControl { FileOps({ - this.bandwidthKBps = 0, - this.onFileDone, - this.onBytes, - this.conflict = NameConflict.rename, - this.onSettled, - this.skipNewerInMerge = false, + super.bandwidthKBps, + super.onFileDone, + super.onBytes, + super.conflict, + super.onSettled, + super.skipNewerInMerge, }); - /// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기"). - /// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로) - final bool skipNewerInMerge; - - /// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다) - final keptNewer = []; - - /// 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" (어느 저장소든) 판으로 + /// 한쪽이라도 WebDAV 면 어느 저장소든 판으로 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; - } + static Future totalSize(List paths) => _anyDav(paths) ? anyTotalSize(paths) : local.localTotalSize(paths); /// [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, {bool inner = false}) 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)), inner: true); - } - } else { - // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 - if (inner && skipNewerInMerge && await isNewerThere(src, target)) { - keptNewer.add(src); - return; - } - 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; - } - - /// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다 - Future _deleteExcept(String dir, Set keep) async { - Future walk(Directory d) async { - var empty = true; - await for (final e in d.list(followLinks: false)) { - if (e is Directory) { - if (await walk(e)) { - await e.delete(); - } else { - empty = false; - } - } else if (keep.contains(p.normalize(e.path))) { - empty = false; - } else { - await e.delete(); - } - } - return empty; - } - - if (await walk(Directory(dir))) await Directory(dir).delete(); - } + Future> copy(List sources, String destDir, {CopyProgress? onProgress}) => _anyDav([...sources, destDir]) + ? anyCopy(this, sources, destDir, onProgress: onProgress) + : local.localCopy(this, sources, destDir, onProgress: onProgress); /// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지 - static 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))); - } + static Future isNewerThere(String src, String target) => local.isNewerThere(src, target); /// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만) - static Future countNewerInside(String src, String dst, {int limit = 5000}) async { - var n = 0, seen = 0; - Future walk(String s, String d) async { - await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) { - if (seen++ > limit) return; - final t = p.join(d, p.basename(e.path)); - if (e is Directory) { - if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t); - } else if (await isNewerThere(e.path, t)) { - n++; - } - } - } - - if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst); - return n; - } + static Future countNewerInside(String src, String dst, {int limit = 5000}) => + local.countNewerInside(src, dst, limit: limit); /// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 - 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)) { - // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 - final keptBefore = keptNewer.length; - made.addAll(await copy([s], destDir, onProgress: onProgress)); - _check(); - final kept = keptNewer.sublist(keptBefore); - if (kept.isEmpty) { - await delete([s]); - } else { - // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 - await _deleteExcept(s, kept.map(p.normalize).toSet()); - } - 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; - } + Future> move(List sources, String destDir, {CopyProgress? onProgress}) => _anyDav([...sources, destDir]) + ? anyMove(this, sources, destDir, onProgress: onProgress) + : local.localMove(this, sources, destDir, onProgress: onProgress); /// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다. /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. @@ -637,445 +75,14 @@ class FileOps { {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 _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter); - 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); - 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(); - 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') && !isAppTempName(p.basename(e.path))) { - await e.delete(recursive: true); - } - } - } - } - - await walk(srcDir, dstDir); - return n; + 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); } - 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(); - } - } - } + Future delete(List paths) => local.deletePaths(this, paths); /// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. - static Future rename(String path, String newName) async { - final name = newName.trim(); - final bad = nameProblem(name, windowsRules: usesWindowsNames(path)); - if (bad != null) throw FileSystemException(bad, 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() || _otherCaseExists(path, name))) { - 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 rename(String path, String newName) => renameInPlace(path, newName); - /// 데이터 검토 (10/18): 대소문자만 바꾸는 이름 (a.jpg → A.jpg) 인데 대소문자를 가리는 저장소 (Android 의 앱 폴더 · ext4 · 일부 NAS) 에 - /// 그 이름의 다른 파일이 따로 있으면 true - rename 이 그 파일을 말없이 덮지 않게 (가리지 않는 곳이면 목록에 정확히 그 이름이 없다) - static bool _otherCaseExists(String path, String name) { - if (p.basename(path) == name) return false; - try { - return Directory(p.dirname(path)).listSync(followLinks: false).any((e) => p.basename(e.path) == name); - } catch (_) { - return true; // 모르면 막는다 (덮는 것보다 낫다) - } - } - - static Future makeFolder(String dir, String name) async { - final n = name.trim(); - final bad = nameProblem(n, windowsRules: usesWindowsNames(dir)); - if (bad != null) throw FileSystemException(bad, 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, {bool inner = false}) 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)), inner: true); - } - } else { - // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 (WebDAV 도 로컬과 같게) - if (inner && skipNewerInMerge) { - final t = await vStat(target); - if (t != null && !t.isDir && t.modified.isAfter(st.modified.add(const Duration(seconds: 2)))) { - keptNewer.add(src); - return; - } - } - 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; - } - final keptBefore = keptNewer.length; - made.addAll(await _copyV([s], destDir, onProgress: onProgress)); - final kept = keptNewer.sublist(keptBefore); - if (kept.isEmpty) { - await vDelete(s); - } else { - // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 - await _deleteExceptV(s, kept.toSet()); - } - } - return made; - } - - /// 229: [dir] (로컬 · WebDAV) 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도) - Future _deleteExceptV(String dir, Set keep) async { - var empty = true; - for (final e in await vList(dir)) { - if (e.isDir) { - if (!await _deleteExceptV(e.path, keep)) empty = false; - } else if (keep.contains(e.path)) { - empty = false; - } else { - await vDelete(e.path); - } - } - if (empty) await vDelete(dir); - return empty; - } - - Future _mirrorV(String srcDir, String dstDir, - {bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { - var n = 0; - // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 - // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 - Future hasExcluded(String dir, String rel) async { - List<({String path, bool isDir, int size, DateTime modified})> items; - try { - items = await vList(dir); - } catch (_) { - return false; - } - for (final x in items) { - final r = '$rel/${vBasename(x.path)}'; - if (filter.protects(r, isDir: x.isDir)) return true; - if (x.isDir && await hasExcluded(x.path, r)) return true; - } - return false; - } - - Future deleteInside(String dir, String rel) async { - for (final x in await vList(dir)) { - _check(); - final name = vBasename(x.path); - if (name.endsWith('.jjsync') || isAppTempName(name)) continue; - final r = '$rel/$name'; - if (filter.protects(r, isDir: x.isDir)) continue; - if (x.isDir && await hasExcluded(x.path, r)) { - await deleteInside(x.path, r); - } else { - await vDelete(x.path); - } - } - } - - Future walk(String src, String dst, String rel) 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); - if (isAppTempName(name)) continue; // 78 보충 - final r = rel.isEmpty ? name : '$rel/$name'; - // 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded) - if (filter.excluded(r, isDir: e.isDir)) continue; - 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, r); - 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; - // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). - // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 - if (t != null && t.isDir) { - if (!delete) { - dirConflicts.add(target); - continue; - } - 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 t in there.entries) { - final name = t.key; - if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; - _check(); - final r = rel.isEmpty ? name : '$rel/$name'; - // 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움) - if (filter.protects(r, isDir: t.value.isDir)) continue; - if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) { - await deleteInside(t.value.path, r); - continue; - } - await vDelete(t.value.path); - } - } - } - - await walk(srcDir, dstDir, ''); - return n; - } -} - -/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. -/// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게). -/// 79: [accept] 가 있으면 그 조건 (크기 · 바뀐 날 · 종류) 에도 맞는 것만. 이때는 [query] 를 비워도 된다. -Stream searchFiles(String root, String query, - {bool showHidden = false, - int limit = 2000, - void Function(String dir, Object error)? onSkipped, - bool Function(FileEntry e)? accept}) async* { - final q = query.trim().toLowerCase(); - // 79: 이름 없이 조건만 ("1GB 넘는 30일 지난 영상") 으로도 찾는다 - if (q.isEmpty && accept == null) 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)) && (accept == null || accept(e))) { - 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'; + static Future makeFolder(String dir, String name) => createFolder(dir, name); } diff --git a/lib/core/files/any_transfer.dart b/lib/core/files/any_transfer.dart new file mode 100644 index 0000000..aadf43d --- /dev/null +++ b/lib/core/files/any_transfer.dart @@ -0,0 +1,273 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../l10n/tr.dart'; +import '../rsync_filter.dart'; +import '../vfs.dart'; +import 'path_rules.dart'; +import 'transfer_control.dart'; +import 'transfer_guard.dart'; + +// 약속 (나누기 file_ops): 어느 저장소든 (로컬 · WebDAV · SFTP · FTP 등 vfs 가 아는 곳) 의 복사 · 이동 · 맞추기 · 크기. +// 한쪽이라도 원격이면 [FileOps] 가 이쪽을 부른다. 같은 서버 안의 이동은 서버가 옮긴다. +// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더 (맞추기는 [RsyncFilter] 거르기도). 출력: 만든 경로들 · 보낸 파일 수. +// 오류: 취소 → [FileOpCancelled] (만들던 파일은 지움) · 원본 없음 · 자기 안으로 → [FileSystemException] · +// 맞추기에서 원본을 읽지 못함 → [SourceUnreadableException]. 원격 오류 (DavException 등) 는 그대로. + +/// 전체 크기 (진행률용) +Future anyTotalSize(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 _copyFile(TransferControl c, String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async { + c.check(); + final input = await vOpenRead(src); + final relay = StreamController>(); + final pump = () async { + try { + await for (final chunk in input) { + if (c.cancelled) { + relay.addError(const FileOpCancelled()); + break; + } + relay.add(chunk); + c.onBytes?.call(chunk.length); + onChunk?.call(chunk.length); + await c.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 (c.cancelled) { + try { + await vDelete(target); + } catch (_) {} + throw const FileOpCancelled(); + } + c.onFileDone?.call(src); +} + +/// [sources] 를 [destDir] 로 복사. 만든 경로들을 돌려준다 +Future> anyCopy(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final total = await anyTotalSize(sources); + var done = 0; + final made = []; + Future copyAny(String src, String target, {bool inner = false}) async { + c.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)), inner: true); + } + } else { + // 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)))) { + c.keptNewer.add(src); + return; + } + } + await _copyFile(c, 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 c.targetFor(s, destDir); + if (target == null) continue; + await copyAny(s, target); + made.add(target); + } + return made; +} + +/// 이동: 같은 WebDAV 서버 안이면 서버가 옮기고, 아니면 복사한 뒤 원본을 지운다 +Future> anyMove(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final made = []; + for (final s in sources) { + c.check(); + if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); + if (samePath(vDirname(s), destDir)) { + made.add(s); + continue; + } + final target = await c.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; + } + final keptBefore = c.keptNewer.length; + made.addAll(await anyCopy(c, [s], destDir, onProgress: onProgress)); + final kept = c.keptNewer.sublist(keptBefore); + if (kept.isEmpty) { + await vDelete(s); + } else { + // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 + await _deleteExcept(s, kept.toSet()); + } + } + return made; +} + +/// 229: [dir] (로컬 · WebDAV) 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도) +Future _deleteExcept(String dir, Set keep) async { + var empty = true; + for (final e in await vList(dir)) { + if (e.isDir) { + if (!await _deleteExcept(e.path, keep)) empty = false; + } else if (keep.contains(e.path)) { + empty = false; + } else { + await vDelete(e.path); + } + } + if (empty) await vDelete(dir); + return empty; +} + +/// 맞추기: [srcDir] 의 내용을 [dstDir] 에. [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. +/// [filter]: rsync 옵션의 거르기 (미리 보기 sync_preview 와 같은 규칙). 지우기 전 원본 확인은 부르는 쪽이 한다 +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; + // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 + // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 + Future hasExcluded(String dir, String rel) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(dir); + } catch (_) { + return false; + } + for (final x in items) { + final r = '$rel/${vBasename(x.path)}'; + if (filter.protects(r, isDir: x.isDir)) return true; + if (x.isDir && await hasExcluded(x.path, r)) return true; + } + return false; + } + + Future deleteInside(String dir, String rel) async { + for (final x in await vList(dir)) { + c.check(); + final name = vBasename(x.path); + if (name.endsWith('.jjsync') || isAppTempName(name)) continue; + final r = '$rel/$name'; + if (filter.protects(r, isDir: x.isDir)) continue; + if (x.isDir && await hasExcluded(x.path, r)) { + await deleteInside(x.path, r); + } else { + await vDelete(x.path); + } + } + } + + Future walk(String src, String dst, String rel) async { + c.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); + if (isAppTempName(name)) continue; // 78 보충 + final r = rel.isEmpty ? name : '$rel/$name'; + // 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded) + if (filter.excluded(r, isDir: e.isDir)) continue; + 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, r); + 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; + } + } + if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; + // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). + // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 + if (t != null && t.isDir) { + if (!delete) { + c.dirConflicts.add(target); + continue; + } + await vDelete(target); + } + await _copyFile(c, e.path, target, e.size, e.modified, null); + n++; + c.onSettled?.call(e.path, e.size, e.modified); + } + if (delete) { + for (final t in there.entries) { + final name = t.key; + if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; + c.check(); + final r = rel.isEmpty ? name : '$rel/$name'; + // 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움) + if (filter.protects(r, isDir: t.value.isDir)) continue; + if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) { + await deleteInside(t.value.path, r); + continue; + } + await vDelete(t.value.path); + } + } + } + + await walk(srcDir, dstDir, ''); + return n; +} diff --git a/lib/core/files/file_listing.dart b/lib/core/files/file_listing.dart new file mode 100644 index 0000000..263c359 --- /dev/null +++ b/lib/core/files/file_listing.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../playlist.dart' show naturalCompare; +import '../vfs.dart'; + +// 약속 (나누기 file_ops): 폴더 항목 · 정렬 · 폴더 읽기 · 이름 찾기 · 크기 글. +// 입력: 경로 (로컬 · WebDAV 등 vfs 가 아는 경로). 출력: [FileEntry] 목록 · 흐름 · 글. +// 오류: [listEntries] 는 하나도 읽지 못하면 그 오류를 던지고 (빈 폴더로 보이지 않게), 일부 읽은 뒤의 오류는 읽은 것까지 돌려준다. +// [searchFiles] 는 읽지 못한 폴더를 [onSkipped] 로 알리고 건너뛴다. [sortEntries] · [formatSize] 는 오류 없음. + +/// 폴더 안의 항목 하나 +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) +/// 80: [dirSize] 가 있으면 크기순에서 폴더도 그 크기로 (모르는 폴더는 가장 작게) +List sortEntries(List list, SortBy by, {bool descending = false, int? Function(FileEntry dir)? dirSize}) { + int sizeOf(FileEntry e) => e.isDir ? (dirSize?.call(e) ?? -1) : e.size; + 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 => sizeOf(a).compareTo(sizeOf(b)), + 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; +} + +/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. +/// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게). +/// 79: [accept] 가 있으면 그 조건 (크기 · 바뀐 날 · 종류) 에도 맞는 것만. 이때는 [query] 를 비워도 된다. +Stream searchFiles(String root, String query, + {bool showHidden = false, + int limit = 2000, + void Function(String dir, Object error)? onSkipped, + bool Function(FileEntry e)? accept}) async* { + final q = query.trim().toLowerCase(); + // 79: 이름 없이 조건만 ("1GB 넘는 30일 지난 영상") 으로도 찾는다 + if (q.isEmpty && accept == null) 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)) && (accept == null || accept(e))) { + 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'; +} diff --git a/lib/core/files/local_transfer.dart b/lib/core/files/local_transfer.dart new file mode 100644 index 0000000..f9cacfe --- /dev/null +++ b/lib/core/files/local_transfer.dart @@ -0,0 +1,284 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; +import 'path_rules.dart'; +import 'transfer_control.dart'; +import 'transfer_guard.dart'; + +// 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기. +// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더. 출력: 만든 경로들 · 복사한 파일 수. +// 오류: 취소 → [FileOpCancelled] (만들다 만 파일은 지움) · 폴더를 자기 안으로 → [FileSystemException] · +// 맞추기에서 원본 폴더를 읽지 못함 → [SourceUnreadableException] (그 폴더에서는 아무것도 지우지 않음). 그 밖의 파일 오류는 그대로. +// WebDAV 등이 섞이면 any_transfer 를 쓴다 (고르는 것은 [FileOps]). + +/// 전체 크기 (진행률용) +Future localTotalSize(List paths) async { + 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] 로 복사 (같은 이름이 있으면 [TransferControl.conflict] 대로). 만든 경로들을 돌려준다. +Future> localCopy(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final total = await localTotalSize(sources); + var done = 0; + final made = []; + // 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게 + Future copyFile(File f, String finalTarget) async { + c.check(); + final target = '$finalTarget.jjpart'; + final out = File(target).openWrite(); + try { + try { + await for (final chunk in f.openRead()) { + c.check(); + out.add(chunk); + done += chunk.length; + onProgress?.call(f.path, done, total); + c.onBytes?.call(chunk.length); + await c.throttle(chunk.length); + } + } finally { + await out.close(); + } + c.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 (_) {} + c.onFileDone?.call(f.path); + } + + Future copyAny(String src, String target, {bool inner = false}) 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)), inner: true); + } + } else { + // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 + if (inner && c.skipNewerInMerge && await isNewerThere(src, target)) { + c.keptNewer.add(src); + return; + } + await copyFile(File(src), target); + } + } + + for (final s in sources) { + if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { + throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); + } + final target = await c.targetFor(s, destDir); + if (target == null) continue; + await copyAny(s, target); + made.add(target); + } + return made; +} + +/// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다 +Future _deleteExcept(String dir, Set keep) async { + Future walk(Directory d) async { + var empty = true; + await for (final e in d.list(followLinks: false)) { + if (e is Directory) { + if (await walk(e)) { + await e.delete(); + } else { + empty = false; + } + } else if (keep.contains(p.normalize(e.path))) { + empty = false; + } else { + await e.delete(); + } + } + return empty; + } + + if (await walk(Directory(dir))) await Directory(dir).delete(); +} + +/// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지 +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))); +} + +/// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만) +Future countNewerInside(String src, String dst, {int limit = 5000}) async { + var n = 0, seen = 0; + Future walk(String s, String d) async { + await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) { + if (seen++ > limit) return; + final t = p.join(d, p.basename(e.path)); + if (e is Directory) { + if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t); + } else if (await isNewerThere(e.path, t)) { + n++; + } + } + } + + if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst); + return n; +} + +/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 +Future> localMove(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final made = []; + for (final s in sources) { + c.check(); + if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); + if (samePath(p.dirname(s), destDir)) { + made.add(s); // 이미 그 폴더에 있음 + continue; + } + final target = await c.targetFor(s, destDir); + if (target == null) continue; + final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound; + if (exists && FileSystemEntity.isDirectorySync(s)) { + // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 + final keptBefore = c.keptNewer.length; + made.addAll(await localCopy(c, [s], destDir, onProgress: onProgress)); + c.check(); + final kept = c.keptNewer.sublist(keptBefore); + if (kept.isEmpty) { + await deletePaths(c, [s]); + } else { + // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 + await _deleteExcept(s, kept.map(p.normalize).toSet()); + } + 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 localCopy(c, [s], destDir, onProgress: onProgress); + await deletePaths(c, [s]); + made.addAll(copied); + } + } + return made; +} + +/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다 (로컬끼리). +/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. +/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다. 지우기 전 원본 확인 (ensureSourceForDelete) 은 부르는 쪽이 한다. +Future localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async { + var n = 0; + Future walk(String src, String dst) async { + c.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); + 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(); + final same = exists && + (await t.length()) == st.size && + ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); + if (same) { + 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; + 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 deletePaths(TransferControl c, List paths) async { + for (final s in paths) { + c.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(); + } + } +} diff --git a/lib/core/files/name_ops.dart b/lib/core/files/name_ops.dart new file mode 100644 index 0000000..ed55c29 --- /dev/null +++ b/lib/core/files/name_ops.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; + +// 약속 (나누기 file_ops): 이름 규칙과 이름 일 - 쓸 수 없는 이름 · 이름 바꾸기 (같은 폴더 안) · 새 폴더. +// 입력: 경로 · 새 이름 (앞뒤 빈칸은 뺌). 출력: 문제 글 (괜찮으면 null) · 새 경로. +// 오류: 쓸 수 없는 이름 · 같은 이름이 이미 있음 → [FileSystemException] (그 글은 화면에 그대로 보인다). 저장소 오류는 그대로. + +/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null). +/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자. +/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음 +String? nameProblem(String name, {required bool windowsRules}) { + if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름'); + if (!windowsRules) return null; + final base = name.split('.').first.trim().toUpperCase(); + if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]); + if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음'); + if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)'); + return null; +} + +/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고) +bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path); + +/// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. +Future renameInPlace(String path, String newName) async { + final name = newName.trim(); + final bad = nameProblem(name, windowsRules: usesWindowsNames(path)); + if (bad != null) throw FileSystemException(bad, 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() || _otherCaseExists(path, name))) { + 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; +} + +/// 데이터 검토 (10/18): 대소문자만 바꾸는 이름 (a.jpg → A.jpg) 인데 대소문자를 가리는 저장소 (Android 의 앱 폴더 · ext4 · 일부 NAS) 에 +/// 그 이름의 다른 파일이 따로 있으면 true - rename 이 그 파일을 말없이 덮지 않게 (가리지 않는 곳이면 목록에 정확히 그 이름이 없다) +bool _otherCaseExists(String path, String name) { + if (p.basename(path) == name) return false; + try { + return Directory(p.dirname(path)).listSync(followLinks: false).any((e) => p.basename(e.path) == name); + } catch (_) { + return true; // 모르면 막는다 (덮는 것보다 낫다) + } +} + +/// [dir] 안에 새 폴더 [name]. 만든 경로를 돌려준다. +Future createFolder(String dir, String name) async { + final n = name.trim(); + final bad = nameProblem(n, windowsRules: usesWindowsNames(dir)); + if (bad != null) throw FileSystemException(bad, 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; +} diff --git a/lib/core/files/path_rules.dart b/lib/core/files/path_rules.dart new file mode 100644 index 0000000..ad6077f --- /dev/null +++ b/lib/core/files/path_rules.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; + +// 약속 (나누기 file_ops): 경로 규칙 - 같은 경로 · 안에 있음 · 바깥 것만 · 짧은 표시 · 겹치지 않는 이름 · 전송 전 확인 · 앱 임시 이름. +// 입력: 경로 글 (로컬 · WebDAV). 출력: 참/거짓 · 경로 · 알릴 글 (문제 없으면 null). +// 오류: 던지지 않는다. [uniqueTarget] · [transferProblem] 만 로컬 파일 시스템을 본다 (없으면 없는 대로). + +/// 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; +} + +/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1"). +/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로. +/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림) +String shortUnder(String root, String dir, {int keep = 2}) { + final parts = []; + var x = dir; + for (var i = 0; i < 256 && !samePath(x, root); i++) { + final up = vDirname(x); + if (up == x) return vDisplay(dir); + parts.insert(0, vBasename(x)); + x = up; + } + if (!samePath(x, root)) return vDisplay(dir); + // 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버) + final top = vDirname(root) == root; + parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root)); + final sep = isDav(dir) ? '/' : p.separator; + if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep); + return parts.join(sep); +} + +/// [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())); +} + +/// 79-c: 고른 것 중 다른 고른 폴더 안에 있는 것 · 같은 것을 빼고 바깥 것만 (차례는 그대로). +/// 폴더와 그 안의 파일을 함께 고르면 안의 파일을 두 번 처리하거나 (이동 뒤 "없음" 오류) 확인 창 개수가 부풀었다 +List outermostPaths(Iterable paths) { + final all = paths.toList(); + final out = []; + for (var i = 0; i < all.length; i++) { + final x = all[i]; + if (out.any((o) => samePath(o, x))) continue; + if (all.any((o) => !samePath(o, x) && isSameOrInside(x, o))) continue; + out.add(x); + } + return out; +} + +/// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - 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; +} + +/// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71) +bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync'); + +/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게 +bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_'); + +/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수) +const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*']; +const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*']; diff --git a/lib/core/files/transfer_control.dart b/lib/core/files/transfer_control.dart new file mode 100644 index 0000000..b112461 --- /dev/null +++ b/lib/core/files/transfer_control.dart @@ -0,0 +1,93 @@ +import '../vfs.dart'; +import 'path_rules.dart'; +import 'transfer_guard.dart' show FileOpCancelled; + +// 약속 (나누기 file_ops): 전송 한 번의 설정과 상태 - 취소 · 속도 제한 · 알림 · 같은 이름 처리 · 건너뛴 것. +// 로컬 전송 (local_transfer) 과 어느 저장소든 전송 (any_transfer) 이 함께 쓴다. [FileOps] 가 이것을 잇는다. +// 입력: 생성자의 설정. 출력: [targetFor] 의 받는 경로 (null = 건너뜀) · 쌓인 목록 ([skipped] · [dirConflicts] · [keptNewer]). +// 오류: [check] 는 취소됐으면 [FileOpCancelled]. 저장소 오류는 그대로. + +/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) +typedef CopyProgress = void Function(String current, int done, int total); + +class TransferControl { + TransferControl({ + this.bandwidthKBps = 0, + this.onFileDone, + this.onBytes, + this.conflict = NameConflict.rename, + this.onSettled, + this.skipNewerInMerge = false, + }); + + bool _cancel = false; + + /// 다음 조각에서 멈춘다 + void cancel() => _cancel = true; + + /// 멈추라고 했는지 + bool get cancelled => _cancel; + + /// 속도 제한 (KB/s, 0 = 제한 없음) + final int bandwidthKBps; + + /// 파일 하나를 다 복사할 때마다 (원본 경로) + final void Function(String source)? onFileDone; + + /// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용 + final void Function(int bytes)? onBytes; + + /// 48: 같은 이름이 있을 때 (맨 위 항목 기준 - 덮어쓰기면 폴더 안의 같은 이름 파일도 덮어씀) + final NameConflict conflict; + + /// 건너뛴 항목 (원본 경로) + final skipped = []; + + /// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것. + /// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음) + final dirConflicts = []; + + /// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기"). + /// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로) + final bool skipNewerInMerge; + + /// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다) + final keptNewer = []; + + /// 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; + } + + 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)); + } + + /// 취소됐으면 [FileOpCancelled] + void check() { + if (_cancel) throw const FileOpCancelled(); + } +} diff --git a/lib/core/files/transfer_guard.dart b/lib/core/files/transfer_guard.dart new file mode 100644 index 0000000..680c849 --- /dev/null +++ b/lib/core/files/transfer_guard.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; +import '../webdav.dart' show DavException, DavLockedException; + +// 약속 (나누기 file_ops): 전송의 안전장치 - 취소 표시 · "원본을 읽을 수 없음" · 지우기 전 원본 확인 · 빈 폴더 정리. +// 입력: 원본 · 대상 폴더 경로 (로컬 · WebDAV). 출력: 없음 / 지운 폴더 수. +// 오류: [ensureSourceForDelete] 는 원본을 읽지 못하거나 원본이 비었는데 대상에 파일이 있으면 [SourceUnreadableException] +// (그때는 아무것도 지우지 않게). [removeEmptyDirs] 는 지우지 못한 폴더를 남기고 던지지 않는다. + +/// 사용자가 복사 · 이동 · 맞추기를 멈췄다 +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']); +} + +/// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지. +/// 문제가 있으면 [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); +} + +/// [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; +} + +/// [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; +} diff --git a/lib/core/files/windows_drives.dart b/lib/core/files/windows_drives.dart new file mode 100644 index 0000000..3d0d83a --- /dev/null +++ b/lib/core/files/windows_drives.dart @@ -0,0 +1,50 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +// 약속 (나누기 file_ops): Windows 저장 장치 - 드라이브 목록 · 드라이브 종류 · 네트워크 공유의 맨 위. +// 입력: 드라이브 맨 위 ("C:\") · 경로. 출력: 목록 · 종류 번호 · 글. +// 오류: 던지지 않는다. Windows API 를 부르지 못하면 [windowsDrives] 는 C: 하나, [driveType] 은 0 (모름). +// 드라이브를 열어 보지 않는다 (끊긴 네트워크 드라이브에서 멈추지 않게). + +/// 저장 장치 (맨 위 폴더): 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; + +/// 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]}'; +} diff --git a/test/file_ops_modules_contract_test.dart b/test/file_ops_modules_contract_test.dart new file mode 100644 index 0000000..ca9b76d --- /dev/null +++ b/test/file_ops_modules_contract_test.dart @@ -0,0 +1,138 @@ +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/file_listing.dart'; +import 'package:jj_mkvmaker/core/files/local_transfer.dart'; +import 'package:jj_mkvmaker/core/files/name_ops.dart'; +import 'package:jj_mkvmaker/core/files/path_rules.dart'; +import 'package:jj_mkvmaker/core/files/transfer_control.dart'; +import 'package:jj_mkvmaker/core/files/transfer_guard.dart'; +import 'package:jj_mkvmaker/core/files/windows_drives.dart'; +import 'package:path/path.dart' as p; + +/// 나누기 file_ops 약속 시험: lib/core/files/ 모듈마다 맨 위 약속 (입력 · 출력 · 오류) - 실제 시험 폴더 · 실제 파일. +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_files_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + String mk(String rel, [String text = 'x']) { + final f = File(p.join(tmp.path, rel))..createSync(recursive: true); + f.writeAsStringSync(text); + return f.path; + } + + test('file_listing: 읽기 · 숨김 · 정렬 (폴더 먼저 · 숫자) · 찾기 · 읽지 못함은 던짐 · 크기 글', () async { + mk('d/file10.txt'); + mk('d/file2.txt'); + mk('d/.hidden'); + Directory(p.join(tmp.path, 'd', 'sub')).createSync(); + final d = p.join(tmp.path, 'd'); + final list = await listEntries(d); + expect(list.map((e) => e.name).toSet(), {'file10.txt', 'file2.txt', 'sub'}); + expect((await listEntries(d, showHidden: true)).length, 4); + expect(sortEntries(list, SortBy.name).map((e) => e.name), ['sub', 'file2.txt', 'file10.txt']); + expect(await searchFiles(tmp.path, 'FILE*').map((e) => e.name).toList(), ['file2.txt', 'file10.txt']); + await expectLater(listEntries(p.join(tmp.path, 'none')), throwsA(isA())); + expect([formatSize(-1), formatSize(12), formatSize(2048), formatSize(3 * 1024 * 1024 * 1024)], ['—', '12B', '2KB', '3.0GB']); + }); + + test('windows_drives: 목록은 비지 않음 · 종류는 열지 않고 숫자 · 공유 맨 위', () { + final drives = windowsDrives(); + expect(drives, isNotEmpty); + if (Platform.isWindows) expect(driveType(drives.first.$1), greaterThan(0)); + expect(uncRoot(r'\\nas\share\a\b'), r'\\nas\share'); + expect(uncRoot('x'), 'x'); + }); + + test('path_rules: 겹치지 않는 이름 · 같은 경로 · 안에 있음 · 바깥 것만 · 전송 전 확인', () { + mk('a.txt'); + expect(p.basename(uniqueTarget(tmp.path, 'a.txt')), 'a (2).txt'); + final a = p.join(tmp.path, 'A'), inner = p.join(a, 'b'); + Directory(inner).createSync(recursive: true); + expect(isSameOrInside(inner, a), isTrue); + expect(isSameOrInside(a, inner), isFalse); + expect(outermostPaths([inner, a, a]), [a]); + if (Platform.isWindows) expect(samePath(a, a.toLowerCase()), isTrue); + expect(transferProblem([a], inner, move: false), isNotNull, reason: '자기 안으로'); + expect(transferProblem([p.join(tmp.path, 'none')], tmp.path, move: false), isNotNull, reason: '원본 없음'); + expect(transferProblem([p.join(tmp.path, 'a.txt')], a, move: true), isNull); + expect(isAppTempName('~jjren_1') && isPartialFile('x.jjpart'), isTrue); + }); + + test('name_ops: 쓸 수 없는 이름 · 이름 바꾸기 · 새 폴더 · 같은 이름은 던짐', () async { + expect(nameProblem('a/b', windowsRules: false), isNotNull); + expect(nameProblem('CON.txt', windowsRules: true), isNotNull); + expect(nameProblem('ok.txt', windowsRules: true), isNull); + final f = mk('x.txt'); + mk('y.txt'); + expect(p.basename(await renameInPlace(f, ' z.txt ')), 'z.txt'); + await expectLater(renameInPlace(p.join(tmp.path, 'z.txt'), 'y.txt'), throwsA(isA())); + final dir = await createFolder(tmp.path, '새 폴더'); + expect(Directory(dir).existsSync(), isTrue); + await expectLater(createFolder(tmp.path, '새 폴더'), throwsA(isA())); + }); + + test('transfer_guard: 원본 없음 · 빈 원본은 던지고 지우지 않음 · 빈 폴더 정리', () async { + final src = p.join(tmp.path, 'src'), dst = p.join(tmp.path, 'dst'); + mk('dst/keep.txt'); + await expectLater(ensureSourceForDelete(src, dst), throwsA(isA())); + Directory(src).createSync(); + await expectLater(ensureSourceForDelete(src, dst), throwsA(isA().having((e) => e.empty, 'empty', isTrue))); + await ensureSourceForDelete(src, dst, allowEmpty: true); + Directory(p.join(src, 'e1', 'e2')).createSync(recursive: true); + expect(await removeEmptyDirs(src), 2); + expect(Directory(src).existsSync(), isTrue); + }); + + test('transfer_control: 같은 이름 건너뛰기는 null 과 기록 · 취소 뒤 check 는 던짐', () async { + final f = mk('s/a.txt'); + mk('d/a.txt'); + final c = TransferControl(conflict: NameConflict.skip); + expect(await c.targetFor(f, p.join(tmp.path, 'd')), isNull); + expect(c.skipped, [f]); + c.check(); + c.cancel(); + expect(c.cancelled, isTrue); + expect(c.check, throwsA(isA())); + }); + + test('local_transfer: 복사 (진행 · 바뀐 때) · 이동 · 맞추기 (지우기) · 취소는 반쯤 쓴 파일을 남기지 않음', () async { + final a = mk('s/dir/a.txt', 'hello'); + final dst = p.join(tmp.path, 'd'); + Directory(dst).createSync(); + var last = 0; + final made = await localCopy(TransferControl(), [p.join(tmp.path, 's', 'dir')], dst, onProgress: (_, done, _) => last = done); + expect(File(p.join(made.single, 'a.txt')).readAsStringSync(), 'hello'); + expect(last, 5); + expect(await localTotalSize([p.join(tmp.path, 's')]), 5); + final moved = await localMove(TransferControl(), [a], dst); + expect(File(a).existsSync(), isFalse); + 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(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())); + expect(Directory(p.join(tmp.path, 's')).listSync(recursive: true).where((e) => e.path.endsWith('.jjpart')), isEmpty); + }); + + test('any_transfer: 로컬 경로로도 복사 · 이동 · 맞추기 · 크기, 원본 없음은 던짐', () async { + mk('s/x/b.txt', 'abc'); + final dst = p.join(tmp.path, 'd'); + Directory(dst).createSync(); + expect(await anyTotalSize([p.join(tmp.path, 's')]), 3); + final made = await anyCopy(TransferControl(), [p.join(tmp.path, 's', 'x')], dst); + expect(File(p.join(made.single, 'b.txt')).readAsStringSync(), 'abc'); + final m = p.join(tmp.path, 'm'); + expect(await anyMirror(TransferControl(), p.join(tmp.path, 's'), m), 1); + expect(await anyMirror(TransferControl(), p.join(tmp.path, 's'), m), 0, reason: '같으면 다시 보내지 않음'); + final m2 = Directory(p.join(tmp.path, 'm2'))..createSync(); + final moved = await anyMove(TransferControl(), [p.join(tmp.path, 's', 'x')], m2.path); + expect(Directory(p.join(tmp.path, 's', 'x')).existsSync(), isFalse); + expect(File(p.join(moved.single, 'b.txt')).readAsStringSync(), 'abc'); + await expectLater(anyCopy(TransferControl(), [p.join(tmp.path, 'none')], dst), throwsA(isA())); + }); +}