feat(download): auto-categorize downloads into folders by file extension (#1572)

This commit is contained in:
WuFeng authored and GitHub committed 2026-10-23 20:38:25 +08:00
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(
+5
View File
@@ -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,
});
+2
View File
@@ -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,
};
+6 -1
View File
@@ -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,
};