mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
feat(download): auto-categorize downloads into folders by file extension (#1572)
This commit is contained in:
1 parent
d125d4ab75
commit
9aae13453a
55 files changed
+4403
-217
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
part 'downloader_config.g.dart';
|
||||
|
||||
@@ -17,12 +18,16 @@ class DownloaderConfig {
|
||||
ApiServerConfig api = ApiServerConfig();
|
||||
bool autoStartTasks;
|
||||
bool autoDeleteMissingFileTasks;
|
||||
List<DownloadCategory> categories;
|
||||
bool autoCategorize;
|
||||
|
||||
DownloaderConfig({
|
||||
this.downloadDir = '',
|
||||
this.maxRunning = 0,
|
||||
this.autoStartTasks = false,
|
||||
this.autoDeleteMissingFileTasks = false,
|
||||
this.categories = const [],
|
||||
this.autoCategorize = false,
|
||||
});
|
||||
|
||||
factory DownloaderConfig.fromJson(Map<String, dynamic> json) => _$DownloaderConfigFromJson(json);
|
||||
@@ -192,7 +197,6 @@ class ExtraConfig {
|
||||
bool runAsMenubarApp;
|
||||
bool analyticsEnabled;
|
||||
String analyticsClientId;
|
||||
List<DownloadCategory> downloadCategories;
|
||||
|
||||
ExtraConfig({
|
||||
this.themeMode = '',
|
||||
@@ -211,7 +215,6 @@ class ExtraConfig {
|
||||
this.runAsMenubarApp = false,
|
||||
this.analyticsEnabled = true,
|
||||
this.analyticsClientId = '',
|
||||
this.downloadCategories = const [],
|
||||
}) : windowState = windowState ?? WindowStateConfig();
|
||||
|
||||
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
@@ -241,6 +244,9 @@ class DownloadCategory {
|
||||
bool isBuiltIn;
|
||||
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
|
||||
bool isDeleted; // Mark built-in categories as deleted instead of removing them
|
||||
// File extensions routed to this category, without leading dots, e.g. ['exe', 'msi'].
|
||||
// An empty list means the category never matches automatically.
|
||||
List<String> extensions;
|
||||
|
||||
DownloadCategory({
|
||||
required this.name,
|
||||
@@ -248,13 +254,93 @@ class DownloadCategory {
|
||||
this.isBuiltIn = false,
|
||||
this.nameKey,
|
||||
this.isDeleted = false,
|
||||
this.extensions = const [],
|
||||
});
|
||||
|
||||
factory DownloadCategory.fromJson(Map<String, dynamic> json) => _$DownloadCategoryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
|
||||
|
||||
/// The effective extension list. The result is always normalized: split on
|
||||
/// commas/whitespace, trimmed, lowercased, without leading dots and without
|
||||
/// duplicates; the original order is preserved. An empty list means the
|
||||
/// category never matches automatically.
|
||||
List<String> effectiveExtensions() => normalizeCategoryExtensions(extensions);
|
||||
}
|
||||
|
||||
/// Lowercases the ascii letters of [value] and leaves the rest alone. The
|
||||
/// backend lowercases the same way, because the unicode case tables of the two
|
||||
/// languages disagree for some code points and would let the two sides pick a
|
||||
/// different category for the same file.
|
||||
String asciiLower(String value) {
|
||||
final lowered = StringBuffer();
|
||||
for (final unit in value.codeUnits) {
|
||||
lowered.writeCharCode(unit >= 0x41 && unit <= 0x5A ? unit + 0x20 : unit);
|
||||
}
|
||||
return lowered.toString();
|
||||
}
|
||||
|
||||
/// Normalizes free form extension input into tokens: splits on commas and
|
||||
/// whitespace, trims, lowercases, strips leading dots, drops empties and
|
||||
/// deduplicates while keeping the original input order.
|
||||
List<String> normalizeCategoryExtensions(Iterable<String> raw) {
|
||||
final result = <String>{};
|
||||
for (final item in raw) {
|
||||
for (final part in item.split(RegExp(r'[,\s]+'))) {
|
||||
var extension = asciiLower(part.trim());
|
||||
while (extension.startsWith('.')) {
|
||||
extension = extension.substring(1);
|
||||
}
|
||||
if (extension.isNotEmpty) {
|
||||
result.add(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList();
|
||||
}
|
||||
|
||||
/// Default extension lists of the built-in categories, keyed by the i18n
|
||||
/// nameKey. Used to seed the built-in categories on first launch and to offer
|
||||
/// the restore-defaults action in the category dialog.
|
||||
const kDefaultCategoryExtensions = <String, List<String>>{
|
||||
'categoryProgram': ['exe', 'msi', 'msix', 'apk', 'dmg', 'deb', 'rpm', 'pkg', 'appimage'],
|
||||
'categoryVideo': ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', '3gp'],
|
||||
'categoryMusic': ['mp3', 'flac', 'wav', 'aac', 'ogg', 'm4a', 'wma', 'ape'],
|
||||
'categoryDocument': ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'epub', 'rtf'],
|
||||
};
|
||||
|
||||
/// Rebases built-in category paths that still live under [oldDir] onto
|
||||
/// [newDir] after the default download directory changed. Categories the
|
||||
/// user moved elsewhere, and user created categories, are left alone.
|
||||
void rebaseBuiltinCategoryPaths(DownloaderConfig config, {required String oldDir, required String newDir}) {
|
||||
if (oldDir.trim().isEmpty || newDir.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
// The backend seeds the categories with the separator of its platform, which
|
||||
// the path package of the client does not always understand, so both sides
|
||||
// are compared in the forward slash form.
|
||||
final from = _forwardSlash(oldDir);
|
||||
final to = _forwardSlash(newDir);
|
||||
if (path.equals(from, to)) {
|
||||
return;
|
||||
}
|
||||
for (final category in config.categories) {
|
||||
if (!category.isBuiltIn || category.isDeleted || category.path.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final current = _forwardSlash(category.path);
|
||||
if (path.equals(current, from)) {
|
||||
category.path = to;
|
||||
} else if (path.isWithin(from, current)) {
|
||||
category.path = path.join(to, path.relative(current, from: from));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the windows separator of [value] to the forward slash the path
|
||||
/// package uses.
|
||||
String _forwardSlash(String value) => value.replaceAll('\\', '/');
|
||||
|
||||
@JsonSerializable()
|
||||
class WebhookConfig {
|
||||
bool enable;
|
||||
|
||||
@@ -12,6 +12,12 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0,
|
||||
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
||||
autoDeleteMissingFileTasks: json['autoDeleteMissingFileTasks'] as bool? ?? false,
|
||||
categories:
|
||||
(json['categories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
autoCategorize: json['autoCategorize'] as bool? ?? false,
|
||||
)
|
||||
..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map<String, dynamic>?)
|
||||
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
|
||||
@@ -37,6 +43,8 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
|
||||
'api': instance.api.toJson(),
|
||||
'autoStartTasks': instance.autoStartTasks,
|
||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||
'categories': instance.categories.map((e) => e.toJson()).toList(),
|
||||
'autoCategorize': instance.autoCategorize,
|
||||
};
|
||||
|
||||
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> json) => ApiServerConfig(
|
||||
@@ -186,11 +194,6 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
downloadCategories:
|
||||
(json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynamic>{
|
||||
@@ -210,7 +213,6 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
|
||||
'runAsMenubarApp': instance.runAsMenubarApp,
|
||||
'analyticsEnabled': instance.analyticsEnabled,
|
||||
'analyticsClientId': instance.analyticsClientId,
|
||||
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
|
||||
@@ -231,6 +233,7 @@ DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => Downlo
|
||||
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||
nameKey: json['nameKey'] as String?,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
extensions: (json['extensions'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <String, dynamic>{
|
||||
@@ -239,6 +242,7 @@ Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <Str
|
||||
'isBuiltIn': instance.isBuiltIn,
|
||||
'nameKey': ?instance.nameKey,
|
||||
'isDeleted': instance.isDeleted,
|
||||
'extensions': instance.extensions,
|
||||
};
|
||||
|
||||
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) => WebhookConfig(
|
||||
|
||||
@@ -9,6 +9,10 @@ class Options {
|
||||
String name;
|
||||
String path;
|
||||
bool asDefaultPath;
|
||||
|
||||
/// Keep the base download directory instead of routing the task to the
|
||||
/// directory of the category its resolved file belongs to.
|
||||
bool skipAutoCategorize;
|
||||
List<int> selectFiles;
|
||||
Object? extra;
|
||||
|
||||
@@ -17,6 +21,7 @@ class Options {
|
||||
this.name = '',
|
||||
this.path = '',
|
||||
this.asDefaultPath = false,
|
||||
this.skipAutoCategorize = false,
|
||||
this.selectFiles = const [],
|
||||
this.extra,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
|
||||
name: json['name'] as String? ?? '',
|
||||
path: json['path'] as String? ?? '',
|
||||
asDefaultPath: json['asDefaultPath'] as bool? ?? false,
|
||||
skipAutoCategorize: json['skipAutoCategorize'] as bool? ?? false,
|
||||
selectFiles:
|
||||
(json['selectFiles'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
@@ -24,6 +25,7 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'asDefaultPath': instance.asDefaultPath,
|
||||
'skipAutoCategorize': instance.skipAutoCategorize,
|
||||
'selectFiles': instance.selectFiles,
|
||||
'extra': ?instance.extra,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,12 @@ class ResolveResult {
|
||||
String id;
|
||||
Resource res;
|
||||
|
||||
ResolveResult({this.id = "", required this.res});
|
||||
/// The category directory the created task would be routed to. Empty when
|
||||
/// auto categorize does not apply. It is a hint for the ui, the create call
|
||||
/// decides the final path.
|
||||
String suggestedDownloadDir;
|
||||
|
||||
ResolveResult({this.id = "", required this.res, this.suggestedDownloadDir = ""});
|
||||
|
||||
factory ResolveResult.fromJson(Map<String, dynamic> json) => _$ResolveResultFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ResolveResultToJson(this);
|
||||
|
||||
@@ -6,10 +6,14 @@ part of 'resolve_result.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) =>
|
||||
ResolveResult(id: json['id'] as String? ?? "", res: Resource.fromJson(json['res'] as Map<String, dynamic>));
|
||||
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) => ResolveResult(
|
||||
id: json['id'] as String? ?? "",
|
||||
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
|
||||
suggestedDownloadDir: json['suggestedDownloadDir'] as String? ?? "",
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ResolveResultToJson(ResolveResult instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'res': instance.res.toJson(),
|
||||
'suggestedDownloadDir': instance.suggestedDownloadDir,
|
||||
};
|
||||
Reference in new issue
Block a user