JJ_MKVMaker/lib/core/app_trash.dart

185 lines
7.3 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import 'file_ops.dart' show isSameOrInside, samePath;
/// X-plore 비교 e (관리자 결정 10/18): Android 앱 휴지통. Windows 는 시스템 휴지통 그대로.
/// - 저장 장치마다 맨 위의 `.jj_trash` 폴더 (같은 저장 장치 안에서 이름만 바꿔 옮긴다 - 빠르고 공간이 들지 않는다).
/// - 항목마다 `<시각>_<무작위>/` 폴더에 원래 것 + `info.json` (원래 경로 · 지운 때 · 폴더인지 · 크기).
/// - `.nomedia` 를 두어 갤러리 · 음악 앱이 휴지통의 그림 · 동영상을 보이지 않게.
/// - 보관 일 (설정, 처음 14일) 이 지나면 지운다. 0 = 휴지통 끔 (늘 영구 삭제, 예전처럼).
const trashFolderName = '.jj_trash';
class TrashItem {
/// 휴지통 안의 항목 폴더 (`.jj_trash/<id>`)
final String entryDir;
/// 원래 경로
final String original;
final DateTime deletedAt;
final bool isDir;
final int size;
const TrashItem({required this.entryDir, required this.original, required this.deletedAt, required this.isDir, required this.size});
String get name => p.basename(original);
/// 휴지통 안에 있는 원래 것
String get stored => p.join(entryDir, name);
}
/// 휴지통으로 옮기지 못함 (다른 저장 장치 · 권한 · 공간). 부르는 쪽이 영구 삭제할지 묻는다
class TrashMoveException implements Exception {
final String path;
final Object cause;
const TrashMoveException(this.path, this.cause);
@override
String toString() => trf('휴지통으로 옮기지 못했습니다: {0} ({1})', [p.basename(path), cause]);
}
class AppTrash {
/// 저장 장치 맨 위들 (Android: /storage/emulated/0 · /storage/XXXX-XXXX)
final List<String> volumeRoots;
AppTrash(this.volumeRoots);
/// [path] 가 있는 저장 장치의 휴지통 폴더 (어느 저장 장치에도 없으면 null)
String? trashDirFor(String path) {
String? best;
for (final r in volumeRoots) {
if (isSameOrInside(path, r) && (best == null || r.length > best.length)) best = r;
}
return best == null ? null : p.join(best, trashFolderName);
}
/// 휴지통 안의 것인지 (휴지통 폴더 자신 포함 - 다시 휴지통으로 보내지 않게)
bool isInTrash(String path) {
final t = trashDirFor(path);
return t != null && isSameOrInside(path, t);
}
static Future<int> _sizeOf(String path) async {
final t = await FileSystemEntity.type(path, followLinks: false);
if (t == FileSystemEntityType.file) return File(path).length();
if (t != FileSystemEntityType.directory) return 0;
var n = 0;
await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) {
if (e is File) {
try {
n += await e.length();
} catch (_) {}
}
}
return n;
}
/// [path] 를 휴지통으로 (같은 저장 장치 안 이름 바꾸기). 못 옮기면 [TrashMoveException] - 원래 것은 그대로
Future<TrashItem> moveToTrash(String path, {DateTime? now}) async {
final trash = trashDirFor(path);
if (trash == null) throw TrashMoveException(path, tr('저장 장치를 알 수 없습니다'));
final when = now ?? DateTime.now();
final id = '${when.millisecondsSinceEpoch}_${Random().nextInt(1 << 30).toRadixString(36)}';
final entry = Directory(p.join(trash, id));
final isDir = await FileSystemEntity.isDirectory(path);
final size = await _sizeOf(path);
try {
await entry.create(recursive: true);
final nomedia = File(p.join(trash, '.nomedia'));
if (!await nomedia.exists()) await nomedia.writeAsString('');
// 정보 먼저 (옮긴 뒤 앱이 꺼져도 어디서 왔는지 남게)
final info = TrashItem(entryDir: entry.path, original: path, deletedAt: when, isDir: isDir, size: size);
await File(p.join(entry.path, 'info.json')).writeAsString(jsonEncode({
'original': path,
'deletedAt': when.toIso8601String(),
'isDir': isDir,
'size': size,
}));
if (isDir) {
await Directory(path).rename(info.stored);
} else {
await File(path).rename(info.stored);
}
return info;
} catch (e) {
// 옮기지 못했으면 (이름 바꾸기가 마지막 일이라 원래 것은 제자리) 만든 항목 폴더만 지운다
try {
if (await entry.exists()) await entry.delete(recursive: true);
} catch (_) {}
throw TrashMoveException(path, e);
}
}
/// 휴지통의 항목들 (지운 때가 최근인 것부터)
Future<List<TrashItem>> list() async {
final out = <TrashItem>[];
for (final r in volumeRoots) {
final dir = Directory(p.join(r, trashFolderName));
if (!await dir.exists()) continue;
await for (final e in dir.list(followLinks: false).handleError((_) {})) {
if (e is! Directory) continue;
try {
final j = jsonDecode(await File(p.join(e.path, 'info.json')).readAsString()) as Map<String, dynamic>;
out.add(TrashItem(
entryDir: e.path,
original: j['original'] as String,
deletedAt: DateTime.parse(j['deletedAt'] as String),
isDir: j['isDir'] == true,
size: (j['size'] as num?)?.toInt() ?? 0,
));
} catch (_) {
// 정보가 없는 항목 (깨짐) 은 건너뛴다 - 지우지 않는다
}
}
}
out.sort((a, b) => b.deletedAt.compareTo(a.deletedAt));
return out;
}
/// 되살리기: 원래 자리로. 그 자리에 같은 이름이 있으면 "이름 (되살림).확장자". 돌려준 것은 되살린 경로
Future<String> restore(TrashItem item) async {
var target = item.original;
if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
final ext = item.isDir ? '' : p.extension(target);
final base = item.isDir ? target : p.join(p.dirname(target), p.basenameWithoutExtension(target));
target = '$base (${tr('되살림')})$ext';
for (var i = 2; await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound; i++) {
target = '$base (${tr('되살림')} $i)$ext';
}
}
await Directory(p.dirname(target)).create(recursive: true);
if (item.isDir) {
await Directory(item.stored).rename(target);
} else {
await File(item.stored).rename(target);
}
await Directory(item.entryDir).delete(recursive: true);
return target;
}
/// 영구 삭제
Future<void> purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true);
/// 보관 일이 지난 것을 지운다. 지운 수
Future<int> purgeOlderThan(int days, {DateTime? now}) async {
final limit = (now ?? DateTime.now()).subtract(Duration(days: days));
var n = 0;
for (final x in await list()) {
if (x.deletedAt.isBefore(limit)) {
try {
await purge(x);
n++;
} catch (_) {}
}
}
return n;
}
/// 휴지통 크기 합
Future<int> totalSize() async => (await list()).fold<int>(0, (a, b) => a + b.size);
/// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에)
bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName)));
}