Files
phylum/client/lib/util/upload_utils.dart
2025-06-01 14:34:04 +05:30

362 lines
11 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/local_upload_errors.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/util/dialogs.dart';
import 'package:phylum/util/file_size.dart';
import 'package:provider/provider.dart';
final slashChar = '/'.runes.first;
final illegalChars = <int>{
...'\\/:*?"<>|'.runes,
};
bool validateName(String name) =>
!name.startsWith(' ') &&
name != '.' &&
name != '..' &&
name.trim().isNotEmpty &&
!name.runes.any((c) => c < 31 || illegalChars.contains(c));
Future<void> createDirectory(BuildContext context, Resource parent, {String? preset}) async {
final account = context.read<PhylumAccount>();
final name = await showInputDialog(
context,
title: 'Create New Folder',
labelText: 'Folder Name',
preset: preset,
validate: validateName,
);
if (name == null || !context.mounted) return;
await handleLocalErrors(
context,
name,
overwriteFn: (r) => nameConflictDelete,
(name, conflictResolution, ensurePermission) => account.resourceRepository.mkdir(
parent: parent.id,
name: name,
userPermission: parent.userPermission,
conflictResolution: conflictResolution,
ensurePermission: ensurePermission,
),
);
}
Future<void> pickAndUploadFiles(BuildContext context, String folderId) async {
final files = await openFiles();
if (!context.mounted) return;
return uploadXFiles(context, folderId, files);
}
Future<void> uploadXFiles(BuildContext context, String folderId, Iterable<XFile> files) async {
if (files.isEmpty) return;
final account = context.read<PhylumAccount>();
final size =
await Future.wait(files.map((files) => files.length())).then((sizes) => sizes.fold(0, (acc, s) => acc + s));
if (!context.mounted) return;
final confirm = await showAlertDialog(
context,
title: 'Upload ${files.length} Files?',
message: 'Total Upload Size: ${size.formatForDisplay()}',
positiveText: 'YES',
negativeText: 'NO',
) ??
false;
final userPermission = await account.db.getResource(folderId).then((r) => r?.userPermission) ?? 0;
if (!confirm || !context.mounted) return;
for (final file in files) {
String? id;
ensurefile:
while (id == null) {
id = await handleLocalErrors(
// ignore: use_build_context_synchronously
context,
p.basename(file.name),
overwriteFn: (r) => r.dir ? nameConflictDelete : nameConflictOverwrite,
(name, conflictResolution, ensurePermission) => account.resourceRepository.upload(
parent: folderId,
name: name,
file: file,
userPermission: userPermission,
conflictResolution: conflictResolution,
ensurePermission: ensurePermission,
),
);
if (id == null && context.mounted) {
final confirm =
await showAlertDialog(context, title: 'Skip file?', positiveText: 'No', negativeText: 'Yes') ?? false;
if (!confirm) break ensurefile;
}
}
}
}
Future<void> pickAndUploadDirectory(BuildContext context, String folderId) async {
final path = await getDirectoryPath();
if (path == null || !context.mounted) return;
return uploadPath(context, folderId, [path]);
}
Future<void> uploadPath(BuildContext context, String folderId, Iterable<String> paths) async {
final account = context.read<PhylumAccount>();
showProgressDialog(context, message: 'Counting Files');
DirTree? tree;
for (final path in paths) {
final stat = await DirTree.stat(path);
tree = tree?.mergeWith(stat) ?? stat;
}
if (!context.mounted) return;
Navigator.of(context).pop();
if (tree == null) return;
final confirm = await showAlertDialog(
context,
title: 'Upload Files?',
message:
'Found ${tree.files.length} files and ${tree.dirs.length} directories.\n\nTotal Upload Size: ${tree.totalSize.formatForDisplay()}',
positiveText: 'YES',
negativeText: 'NO',
) ??
false;
final userPermission = await account.db.getResource(folderId).then((r) => r?.userPermission) ?? 0;
if (!confirm || !context.mounted) return;
final dirIds = <String, String>{};
for (final path in tree.dirs) {
final parent = dirIds[p.dirname(path)] ?? folderId;
String? id;
while (id == null) {
id = await handleLocalErrors(
// ignore: use_build_context_synchronously
context,
p.basename(path),
overwriteFn: (r) => nameConflictDelete,
(name, conflictResolution, ensurePermission) => account.resourceRepository.mkdir(
parent: parent,
name: name,
userPermission: userPermission,
conflictResolution: conflictResolution,
ensurePermission: ensurePermission,
),
);
if (id == null) {
if (!context.mounted) return;
final confirm =
await showAlertDialog(context, title: 'Cancel operation?', positiveText: 'No', negativeText: 'Yes') ??
false;
if (!confirm) return;
}
}
dirIds[path] = id;
}
for (final path in tree.files) {
final parent = dirIds[p.dirname(path)] ?? folderId;
String? id;
ensurefile:
while (id == null) {
id = await handleLocalErrors(
// ignore: use_build_context_synchronously
context,
p.basename(path),
overwriteFn: (r) => r.dir ? nameConflictDelete : nameConflictOverwrite,
(name, conflictResolution, ensurePermission) => account.resourceRepository.upload(
parent: parent,
name: name,
path: path,
userPermission: userPermission,
conflictResolution: conflictResolution,
ensurePermission: ensurePermission,
),
);
if (id == null && context.mounted) {
final confirm =
await showAlertDialog(context, title: 'Skip file?', positiveText: 'No', negativeText: 'Yes') ?? false;
if (!confirm) break ensurefile;
}
}
}
}
Future<String?> handleLocalErrors(
BuildContext context,
String name,
Future<String> Function(String, NameConflictResolution, bool) performAction, {
NameConflictResolution? Function(Resource)? overwriteFn,
NameConflictResolution conflictResolution = nameConflictError,
bool ensurePermission = true,
}) async {
try {
return await performAction(name, conflictResolution, ensurePermission);
} on PermissionException {
if (!context.mounted) return null;
final skip = await showAlertDialog(
context,
title: 'Permissions Error',
message: 'You do not have permissions to write to this folder.',
positiveText: 'OK',
negativeText: 'Try Anyway',
) ??
true;
if (!skip && context.mounted) {
return handleLocalErrors(
context,
name,
performAction,
overwriteFn: overwriteFn,
conflictResolution: conflictResolution,
ensurePermission: false,
);
}
} on NameConflictException catch (e) {
if (!context.mounted) return null;
final overwrite = overwriteFn?.call(e.resource);
final newResolution = await showDialog<NameConflictResolution>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Name Conflict'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Text(
'There is already another item with the name \'$name\' in this folder.',
softWrap: true,
),
),
actions: <Widget>[
if (overwrite != null)
TextButton(
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: const Text('Overwrite'),
onPressed: () {
Navigator.of(context).pop(overwrite);
},
),
TextButton(
child: const Text('Change Name'),
onPressed: () {
Navigator.of(context).pop(nameConflictError);
},
),
ElevatedButton(
autofocus: true,
child: const Text('Auto Rename'),
onPressed: () {
Navigator.of(context).pop(nameConflictRename);
},
),
],
),
);
if (newResolution == null) {
return null;
}
if (newResolution == nameConflictError) {
if (!context.mounted) return null;
final newName = await showInputDialog(
context,
title: 'Change Name',
labelText: 'New Name',
preset: name,
validate: validateName,
);
if (newName == null) {
return null;
}
name = newName;
}
if (!context.mounted) return null;
return handleLocalErrors(
context,
name,
performAction,
overwriteFn: overwriteFn,
conflictResolution: newResolution,
ensurePermission: ensurePermission,
);
} on DestinationCycleException catch (e) {
if (context.mounted) {
await showAlertDialog(context, message: 'Cannot move ${e.resource.name} into itself');
}
} catch (e) {
if (context.mounted) {
await showAlertDialog(context, title: 'Unable to create $name', message: e.toString());
}
}
return null;
}
class DirTree {
final int numFiles;
final int numDirs;
final int totalSize;
final List<String> files;
final List<String> dirs;
DirTree({
required this.numFiles,
required this.numDirs,
required this.totalSize,
required this.files,
required this.dirs,
});
DirTree mergeWith(DirTree other) {
return DirTree(
numFiles: numFiles + other.numFiles,
numDirs: numDirs + other.numDirs,
totalSize: totalSize + other.totalSize,
files: List.of(files)..addAll(other.files),
dirs: List.of(dirs)..addAll(other.dirs),
);
}
static Future<DirTree> stat(String path) {
final s = FileStat.statSync(path);
if (s.type == FileSystemEntityType.file) {
return Future.value(DirTree(
numFiles: 1,
numDirs: 0,
totalSize: s.size,
files: [path],
dirs: const [],
));
}
final dir = Directory(path);
final completer = Completer<DirTree>();
final files = <String>[];
final dirs = <String>[path];
int numFiles = 0;
int numDirs = 0;
int totalSize = 0;
dir.list(recursive: true).listen(
(file) {
if (file is File) {
numFiles++;
totalSize += file.lengthSync();
files.add(file.path);
}
if (file is Directory) {
numDirs++;
dirs.add(file.path);
}
},
onDone: () => completer.complete(DirTree(
numFiles: numFiles,
numDirs: numDirs,
totalSize: totalSize,
files: files,
dirs: dirs,
)),
);
return completer.future;
}
}