나누기 core A: file_ops (1,081) 를 lib/core/files/ 모듈 8개로 (→ 정면 file_ops)

- 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 <noreply@anthropic.com>
split_core
RyuJongJin 2026-10-22 16:25:46 +09:00
parent d537d1c3e1
commit 197072b482
10 changed files with 1343 additions and 1045 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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<int> anyTotalSize(List<String> paths) async {
var total = 0;
Future<void> 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<void> _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<List<int>>();
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<List<String>> anyCopy(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final total = await anyTotalSize(sources);
var done = 0;
final made = <String>[];
Future<void> 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<List<String>> anyMove(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final made = <String>[];
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<bool> _deleteExcept(String dir, Set<String> 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<int> 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<bool> 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<void> 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<void> 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 = <String>{};
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;
}

View File

@ -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<FileEntry> sortEntries(List<FileEntry> 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<List<FileEntry>> 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 = <FileEntry>[];
// 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<FileEntry> 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 = <String>[root];
while (pending.isNotEmpty) {
final dir = pending.removeAt(0);
List<FileEntry> 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';
}

View File

@ -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<int> localTotalSize(List<String> 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<List<String>> localCopy(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final total = await localTotalSize(sources);
var done = 0;
final made = <String>[];
// 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게
Future<void> 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<void> 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<void> _deleteExcept(String dir, Set<String> keep) async {
Future<bool> 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<bool> 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<int> countNewerInside(String src, String dst, {int limit = 5000}) async {
var n = 0, seen = 0;
Future<void> 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<List<String>> localMove(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final made = <String>[];
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<int> localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
var n = 0;
Future<void> walk(String src, String dst) async {
c.check();
await Directory(dst).create(recursive: true);
final names = <String>{};
// 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다
List<FileSystemEntity> 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<void> deletePaths(TransferControl c, List<String> 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();
}
}
}

View File

@ -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<String> 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<String> 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;
}

View File

@ -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 = <String>[];
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<String> outermostPaths(Iterable<String> paths) {
final all = paths.toList();
final out = <String>[];
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<String> 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_*'];

View File

@ -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 = <String>[];
/// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것.
/// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음)
final dirConflicts = <String>[];
/// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기").
/// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로)
final bool skipNewerInMerge;
/// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다)
final keptNewer = <String>[];
/// 214: mirror 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때.
/// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다)
final void Function(String source, int size, DateTime modified)? onSettled;
/// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀).
/// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로.
Future<String?> 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<void> 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<void>.delayed(Duration(milliseconds: ahead));
}
/// 취소됐으면 [FileOpCancelled]
void check() {
if (_cancel) throw const FileOpCancelled();
}
}

View File

@ -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<void> 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<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot);
var n = 0;
Future<bool> 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<int> _removeEmptyDirsV(String root, {bool keepRoot = true}) async {
var n = 0;
Future<bool> 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;
}

View File

@ -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<Uint32 Function(), int Function()>('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<Uint32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>('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]}';
}

View File

@ -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<FileSystemException>()));
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<FileSystemException>()));
final dir = await createFolder(tmp.path, '새 폴더');
expect(Directory(dir).existsSync(), isTrue);
await expectLater(createFolder(tmp.path, '새 폴더'), throwsA(isA<FileSystemException>()));
});
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<SourceUnreadableException>()));
Directory(src).createSync();
await expectLater(ensureSourceForDelete(src, dst), throwsA(isA<SourceUnreadableException>().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<FileOpCancelled>()));
});
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<FileOpCancelled>()));
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<FileSystemException>()));
});
}