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,
};
@@ -1,11 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../../api/api.dart' as api;
import '../../api/model/downloader_config.dart';
import 'downloader_config_init.dart';
import '../../core/capabilities/app_capabilities.dart';
import '../../core/common/api_server_state.dart';
import '../../core/common/start_config.dart';
@@ -242,60 +242,14 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
return cfg;
}
Future<DownloaderConfig> _loadDownloaderConfig() async {
DownloaderConfig config;
try {
config = await ref.read(gopeedServiceProvider).getConfig();
} catch (error, stackTrace) {
logger.w('load downloader config failed', error, stackTrace);
config = DownloaderConfig();
}
await _initDownloaderConfig(config);
return config;
}
Future<void> _initDownloaderConfig(DownloaderConfig config) async {
final extra = config.extra;
if (extra.themeMode.isEmpty) {
extra.themeMode = 'system';
}
if (extra.themeColor.isEmpty) {
extra.themeColor = 'green';
}
if (config.proxy.scheme.isEmpty) {
config.proxy.scheme = 'http';
}
if (config.downloadDir.isEmpty) {
config.downloadDir = await _defaultDownloadDir();
}
if (extra.downloadCategories.isEmpty) {
extra.downloadCategories = [
DownloadCategory(
name: '',
path: path.join(config.downloadDir, 'Music'),
isBuiltIn: true,
nameKey: 'categoryMusic',
),
DownloadCategory(
name: '',
path: path.join(config.downloadDir, 'Video'),
isBuiltIn: true,
nameKey: 'categoryVideo',
),
DownloadCategory(
name: '',
path: path.join(config.downloadDir, 'Document'),
isBuiltIn: true,
nameKey: 'categoryDocument',
),
DownloadCategory(
name: '',
path: path.join(config.downloadDir, 'Program'),
isBuiltIn: true,
nameKey: 'categoryProgram',
),
];
}
Future<DownloaderConfig> _loadDownloaderConfig() {
return loadDownloaderConfig(
load: () => ref.read(gopeedServiceProvider).getConfig(),
save: (config) => ref.read(gopeedServiceProvider).putConfig(config),
defaultDownloadDir: _defaultDownloadDir,
onLoadError: (error, stackTrace) => logger.w('load downloader config failed', error, stackTrace),
onSaveError: (error, stackTrace) => logger.w('persist downloader config failed', error, stackTrace),
);
}
Future<void> _initAnalytics(DownloaderConfig config) async {
@@ -322,11 +276,13 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
}
Future<String> _defaultDownloadDir() async {
// An unknown directory stays empty: the config is sent to the backend, and
// a relative path would point at the backend working directory.
if (kIsWeb) {
return './';
return '';
}
if (Util.isDesktop()) {
return (await getDownloadsDirectory())?.path ?? './';
return (await getDownloadsDirectory())?.path ?? '';
}
if (Util.isAndroid()) {
return (await getExternalStorageDirectory())?.path ?? (await getApplicationDocumentsDirectory()).path;
@@ -334,7 +290,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
if (Util.isIOS()) {
return (await getApplicationDocumentsDirectory()).path;
}
return './';
return '';
}
String _runningAddress(StartConfig config, int runningPort) {
@@ -0,0 +1,92 @@
import 'package:path/path.dart' as path;
import '../../api/model/downloader_config.dart';
/// Reads the downloader config, applies first-launch defaults and persists
/// the result when the init changed it. Persisting is skipped when the
/// initial load failed: writing defaults over an unreadable store would
/// wipe the user's configuration.
Future<DownloaderConfig> loadDownloaderConfig({
required Future<DownloaderConfig> Function() load,
required Future<void> Function(DownloaderConfig config) save,
required Future<String> Function() defaultDownloadDir,
void Function(Object error, StackTrace stackTrace)? onLoadError,
void Function(Object error, StackTrace stackTrace)? onSaveError,
}) async {
DownloaderConfig config;
var loaded = true;
try {
config = await load();
} catch (error, stackTrace) {
onLoadError?.call(error, stackTrace);
config = DownloaderConfig();
loaded = false;
}
final changed = await initDownloaderConfig(config, defaultDownloadDir: defaultDownloadDir);
if (loaded && changed) {
// Persist the seeded categories so the backend can route
// downloads to them right away instead of waiting for the settings page
// to save the config.
try {
await save(config);
} catch (error, stackTrace) {
onSaveError?.call(error, stackTrace);
}
}
return config;
}
/// Applies defaults to a freshly loaded config: theme/tracker/proxy basics,
/// the default download directory, and seeding the built-in categories when
/// nothing exists yet. Returns whether the config changed and should be
/// persisted.
Future<bool> initDownloaderConfig(
DownloaderConfig config, {
required Future<String> Function() defaultDownloadDir,
}) async {
var changed = false;
final extra = config.extra;
if (extra.themeMode.isEmpty) {
extra.themeMode = 'system';
}
if (extra.themeColor.isEmpty) {
extra.themeColor = 'green';
}
if (config.proxy.scheme.isEmpty) {
config.proxy.scheme = 'http';
}
if (config.downloadDir.isEmpty) {
final downloadDir = await defaultDownloadDir();
// An unknown directory stays empty, the backend resolves its own.
if (downloadDir.isNotEmpty) {
config.downloadDir = downloadDir;
changed = true;
}
}
if (config.categories.isEmpty && config.downloadDir.isNotEmpty) {
config.categories = [
_builtinCategory('categoryMusic', path.join(config.downloadDir, 'Music')),
_builtinCategory('categoryVideo', path.join(config.downloadDir, 'Video')),
_builtinCategory('categoryDocument', path.join(config.downloadDir, 'Document')),
_builtinCategory('categoryProgram', path.join(config.downloadDir, 'Program')),
];
changed = true;
}
if (config.githubMirror.mirrors.isEmpty) {
config.githubMirror.mirrors = [
GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true),
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true),
];
}
return changed;
}
DownloadCategory _builtinCategory(String nameKey, String categoryPath) {
return DownloadCategory(
name: '',
path: categoryPath,
isBuiltIn: true,
nameKey: nameKey,
extensions: List<String>.from(kDefaultCategoryExtensions[nameKey] ?? const []),
);
}
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as path;
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:url_launcher/url_launcher.dart';
@@ -88,6 +89,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
DownloaderConfig? _config;
StartConfig? _startConfig;
String? _loadedSignature;
String? _appliedDownloadDir;
String? _loadedStartSignature;
Timer? _textSaveTimer;
bool _syncingControllers = false;
@@ -470,13 +472,21 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
SettingsItem(
title: context.l10n.downloadCategories,
child: DownloadCategoriesControl(
categories: config.extra.downloadCategories,
categories: config.categories,
displayName: _categoryName,
onAdd: () => unawaited(_editDownloadCategory()),
onEdit: (category) => unawaited(_editDownloadCategory(category)),
onDelete: (category) => unawaited(_deleteDownloadCategory(category)),
),
),
SettingsItem(
title: context.l10n.autoCategorizeByExtension,
subtitle: context.l10n.autoCategorizeByExtensionDescription,
child: shad.Switch(
value: config.autoCategorize,
onChanged: (value) => _mutateConfig((next) => next.autoCategorize = value),
),
),
SettingsItem(
title: context.l10n.maxRunning,
subtitle: context.l10n.maxRunningDescription,
@@ -996,6 +1006,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_config = DownloaderConfig.fromJson(config.toJson());
_syncingControllers = true;
_downloadDirController.text = _config!.downloadDir;
_appliedDownloadDir = _config!.downloadDir;
_maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString();
_httpUserAgentController.text = _config!.protocolConfig.http.userAgent;
_httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString();
@@ -1087,7 +1098,13 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
void _applyTextControllers(DownloaderConfig config) {
config.downloadDir = _downloadDirController.text.trim();
final downloadDir = _downloadDirController.text.trim();
final previousDownloadDir = _appliedDownloadDir;
_appliedDownloadDir = downloadDir;
if (previousDownloadDir != null && !path.equals(previousDownloadDir, downloadDir)) {
rebaseBuiltinCategoryPaths(config, oldDir: previousDownloadDir, newDir: downloadDir);
}
config.downloadDir = downloadDir;
config.maxRunning = _boundedInt(_maxRunningController, fallback: config.maxRunning, min: 1, max: 256);
config.protocolConfig.http.userAgent = _httpUserAgentController.text.trim();
config.protocolConfig.http.connections = _boundedInt(
@@ -1357,13 +1374,15 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
category: category,
initialName: category == null ? '' : _categoryName(category),
initialPath: _downloadDirController.text.trim(),
categories: _config?.categories ?? const [],
displayName: _categoryName,
);
if (draft == null || !mounted) return;
_mutateConfig((config) {
final categories = List<DownloadCategory>.from(config.extra.downloadCategories);
final categories = List<DownloadCategory>.from(config.categories);
if (category == null) {
categories.add(DownloadCategory(name: draft.name, path: draft.path));
categories.add(DownloadCategory(name: draft.name, path: draft.path, extensions: draft.extensions));
} else {
final index = categories.indexOf(category);
if (index < 0) return;
@@ -1371,10 +1390,11 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
category
..name = draft.name
..path = draft.path
..extensions = draft.extensions
..isDeleted = false;
if (nameChanged) category.nameKey = null;
}
config.extra.downloadCategories = categories;
config.categories = categories;
});
}
@@ -1386,7 +1406,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
category.isDeleted = true;
return;
}
config.extra.downloadCategories = List<DownloadCategory>.from(config.extra.downloadCategories)..remove(category);
config.categories = List<DownloadCategory>.from(config.categories)..remove(category);
});
}
@@ -11,12 +11,14 @@ import '../../../../shared/widgets/app_path_placeholder_button.dart';
import '../../../../shared/widgets/app_primary_button.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../l10n/l10n.dart';
import 'extension_tag_editor.dart';
class DownloadCategoryDraft {
const DownloadCategoryDraft({required this.name, required this.path});
const DownloadCategoryDraft({required this.name, required this.path, required this.extensions});
final String name;
final String path;
final List<String> extensions;
}
class DownloadCategoriesControl extends StatelessWidget {
@@ -93,6 +95,7 @@ class _CategoryRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final extensions = category.effectiveExtensions();
return Row(
key: ValueKey('download-category-${category.nameKey ?? category.name}-${category.path}'),
children: [
@@ -111,6 +114,16 @@ class _CategoryRow extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textSecondary, fontSize: 12),
),
if (extensions.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
extensions.join(', '),
key: const ValueKey('download-category-extensions'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textSecondary, fontSize: 11),
),
],
],
),
),
@@ -137,9 +150,29 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
DownloadCategory? category,
required String initialName,
required String initialPath,
required List<DownloadCategory> categories,
required String Function(DownloadCategory) displayName,
}) async {
final nameController = TextEditingController(text: initialName);
final pathController = TextEditingController(text: category?.path ?? initialPath);
final editorKey = GlobalKey<ExtensionTagEditorState>();
final nameKey = category?.nameKey;
final restoreDefaults = nameKey == null
? const <String>[]
: normalizeCategoryExtensions(kDefaultCategoryExtensions[nameKey] ?? const []);
// Extensions every other live category already owns, mapped to its
// display name; the edited category itself is exempt.
final takenExtensions = <String, String>{};
for (final other in categories) {
if (other.isDeleted || identical(other, category)) continue;
for (final extension in normalizeCategoryExtensions(other.effectiveExtensions())) {
takenExtensions.putIfAbsent(extension, () => displayName(other));
}
}
var extensionConflict = normalizeCategoryExtensions(
category?.effectiveExtensions() ?? const [],
).any(takenExtensions.containsKey);
String? validationMessage;
final overlay = const shad.DialogOverlayHandler().show<DownloadCategoryDraft?>(
@@ -150,14 +183,23 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
builder: (dialogContext, setDialogState) {
final palette = AppPalette.of(dialogContext);
void reportExtensionConflict(bool value) {
if (extensionConflict == value) return;
setDialogState(() => extensionConflict = value);
}
void submit() {
if (extensionConflict) return;
final name = nameController.text.trim();
final path = pathController.text.trim();
if (name.isEmpty || path.isEmpty) {
setDialogState(() => validationMessage = dialogContext.l10n.categoryFieldsRequired);
return;
}
shad.closeOverlay(dialogContext, DownloadCategoryDraft(name: name, path: path));
shad.closeOverlay(
dialogContext,
DownloadCategoryDraft(name: name, path: path, extensions: editorKey.currentState?.flush() ?? const []),
);
}
return shad.AlertDialog(
@@ -165,35 +207,49 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
content: SizedBox(
key: const ValueKey('download-category-dialog'),
width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 420.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
const SizedBox(height: 6),
AppTextField(key: const ValueKey('download-category-name'), controller: nameController),
const SizedBox(height: 14),
Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
const SizedBox(height: 6),
AppPathPickerField.downloadDirectory(
fieldKey: const ValueKey('download-category-path'),
controller: pathController,
desktopWidth: AppDesignTokens.settingsFormControlWidth,
allowAndroidEditing: true,
),
const SizedBox(height: AppDesignTokens.space4),
Align(
alignment: AlignmentDirectional.centerStart,
child: AppPathPlaceholderButton(
key: const ValueKey('download-category-placeholders'),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
const SizedBox(height: 6),
AppTextField(key: const ValueKey('download-category-name'), controller: nameController),
const SizedBox(height: 14),
Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
const SizedBox(height: 6),
AppPathPickerField.downloadDirectory(
fieldKey: const ValueKey('download-category-path'),
controller: pathController,
desktopWidth: AppDesignTokens.settingsFormControlWidth,
allowAndroidEditing: true,
),
),
if (validationMessage != null) ...[
const SizedBox(height: 8),
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
const SizedBox(height: AppDesignTokens.space4),
Align(
alignment: AlignmentDirectional.centerStart,
child: AppPathPlaceholderButton(
key: const ValueKey('download-category-placeholders'),
controller: pathController,
),
),
const SizedBox(height: 14),
ExtensionTagEditor(
key: editorKey,
inputKey: const ValueKey('download-category-extensions-input'),
initialTags: category?.effectiveExtensions() ?? const [],
labelText: dialogContext.l10n.categoryExtensions,
placeholderText: dialogContext.l10n.categoryExtensionsEmptyHint,
usagePlaceholderText: dialogContext.l10n.categoryExtensionsUsageHint,
restoreDefaults: restoreDefaults,
takenByCategory: takenExtensions,
onConflictChanged: reportExtensionConflict,
),
if (validationMessage != null) ...[
const SizedBox(height: 8),
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
],
],
],
),
),
),
actions: [
@@ -201,7 +257,7 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
onPressed: () => shad.closeOverlay(dialogContext),
child: Text(dialogContext.l10n.cancel),
),
AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.save)),
AppPrimaryButton(onPressed: extensionConflict ? null : submit, child: Text(dialogContext.l10n.save)),
],
);
},
@@ -0,0 +1,274 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../api/model/downloader_config.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_text_field.dart';
/// Tag style editor for file extension lists.
///
/// Typed text stays in the input until the submit action (Enter) commits it;
/// commas and whitespace split the committed text into several tags, so
/// `a b,c` becomes `a`, `b` and `c`. Every token goes through
/// [normalizeCategoryExtensions], so the tags are trimmed, lowercased, dot
/// free and deduplicated while keeping the input order. While typing, chips
/// whose tag matches the pending input are highlighted to surface
/// duplicates before they are committed. When the editor is empty and
/// [restoreDefaults] is provided, an outline restore action sits at the end
/// of the [labelText] row and refills the tags. [takenByCategory] lists
/// extensions already claimed by another category: those tokens are never
/// committed, their chips render with the error border, and the editor
/// reports the conflict through [onConflictChanged] so the host can block
/// submission while one is pending.
class ExtensionTagEditor extends StatefulWidget {
const ExtensionTagEditor({
super.key,
this.inputKey,
required this.initialTags,
this.labelText,
this.placeholderText,
this.usagePlaceholderText,
this.restoreDefaults,
this.takenByCategory,
this.onConflictChanged,
});
/// Key attached to the underlying text field so tests can target it.
final Key? inputKey;
final List<String> initialTags;
/// Field label rendered at the top of the editor with the restore action
/// aligned to its end. Null renders no label row unless the restore action
/// itself is visible.
final String? labelText;
/// Shown inside the text field while there is neither a tag nor typed text.
final String? placeholderText;
/// Shown inside the text field while there is no typed text but at least
/// one tag, carrying the usage rules once the empty-state text no longer
/// applies.
final String? usagePlaceholderText;
/// Default extensions offered by the restore action while the editor is
/// empty. Null or empty hides the action (e.g. custom categories).
final List<String>? restoreDefaults;
/// Normalized extensions already owned by another category, mapped to
/// that category's display name. Tokens in this map are rejected with an
/// inline error naming the owner instead of being added to the tags.
final Map<String, String>? takenByCategory;
/// Reports whether the editor currently holds an extension claimed by
/// another category so the host can disable its submit action.
final ValueChanged<bool>? onConflictChanged;
@override
State<ExtensionTagEditor> createState() => ExtensionTagEditorState();
}
class ExtensionTagEditorState extends State<ExtensionTagEditor> {
late final List<String> _tags;
late final TextEditingController _controller;
late bool _lastConflictNotified;
@override
void initState() {
super.initState();
_tags = normalizeCategoryExtensions(widget.initialTags);
_controller = TextEditingController();
// The host seeds its own initial state from the same data, so only
// subsequent changes need to be reported.
_lastConflictNotified = _firstTakenConflict() != null;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Commits any pending input text to the tags and returns the full list.
/// Extensions claimed by [takenByCategory] are left out.
List<String> flush() {
_update(_commitPending);
final taken = widget.takenByCategory;
return List<String>.unmodifiable(taken == null ? _tags : _tags.where((tag) => !taken.containsKey(tag)));
}
void _update(VoidCallback fn) {
setState(fn);
final conflicted = _firstTakenConflict() != null;
if (conflicted != _lastConflictNotified) {
_lastConflictNotified = conflicted;
widget.onConflictChanged?.call(conflicted);
}
}
void _commitPending() {
final rejected = normalizeCategoryExtensions([
_controller.text,
]).where((token) => widget.takenByCategory?.containsKey(token) ?? false).toList(growable: false);
_addTokens(_controller.text);
if (rejected.isEmpty) {
_controller.clear();
} else {
// Keep the rejected tokens in the input so the error stays visible
// until the user edits them away.
_controller.text = rejected.join(' ');
_controller.selection = TextSelection.collapsed(offset: _controller.text.length);
}
}
void _addTokens(String raw) {
for (final token in normalizeCategoryExtensions([raw])) {
if (_tags.contains(token)) continue;
if (widget.takenByCategory?.containsKey(token) ?? false) continue;
_tags.add(token);
}
}
String? _firstTakenConflict() {
final taken = widget.takenByCategory;
if (taken == null || taken.isEmpty) return null;
for (final token in normalizeCategoryExtensions([_controller.text])) {
if (taken.containsKey(token)) return token;
}
for (final tag in _tags) {
if (taken.containsKey(tag)) return tag;
}
return null;
}
void _handleChanged(String value) {
// Keep the placeholder and the duplicate highlight in sync while typing;
// nothing is committed until the submit action.
_update(() {});
}
void _handleSubmitted(String value) {
_update(_commitPending);
}
void _removeTag(String tag) {
_update(() => _tags.remove(tag));
}
void _restoreDefaults() {
_update(() {
_tags
..clear()
..addAll(normalizeCategoryExtensions(widget.restoreDefaults ?? const []));
});
}
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final showPlaceholder = widget.placeholderText != null && _tags.isEmpty && _controller.text.isEmpty;
final showRestore = showPlaceholder && (widget.restoreDefaults?.isNotEmpty ?? false);
String? placeholder;
if (_controller.text.isEmpty) {
if (showPlaceholder) {
placeholder = widget.placeholderText;
} else if (_tags.isNotEmpty && widget.usagePlaceholderText != null) {
placeholder = widget.usagePlaceholderText;
}
}
final pendingDuplicates = normalizeCategoryExtensions([_controller.text]).toSet();
final conflict = _firstTakenConflict();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.labelText != null || showRestore) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (widget.labelText != null)
Text(widget.labelText!, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
if (showRestore) _buildRestoreButton(palette),
],
),
const SizedBox(height: 6),
],
if (_tags.isNotEmpty) ...[
Wrap(
spacing: AppDesignTokens.space4,
runSpacing: AppDesignTokens.space4,
children: [
for (final tag in _tags)
_buildChip(
palette,
tag,
duplicate: pendingDuplicates.contains(tag) || (widget.takenByCategory?.containsKey(tag) ?? false),
),
],
),
const SizedBox(height: 6),
],
AppTextField(
key: widget.inputKey,
controller: _controller,
placeholder: placeholder != null
? Text(placeholder, style: TextStyle(fontSize: 12, color: palette.textMuted))
: null,
onChanged: _handleChanged,
onSubmitted: _handleSubmitted,
),
if (conflict != null) ...[
const SizedBox(height: 6),
Text(
context.l10n.categoryExtensionTaken(conflict, widget.takenByCategory![conflict]!),
style: TextStyle(color: palette.error, fontSize: 12),
),
],
],
);
}
Widget _buildRestoreButton(AppPalette palette) {
return shad.OutlineButton(
key: const ValueKey('extension-restore-defaults'),
onPressed: _restoreDefaults,
size: shad.ButtonSize.small,
density: shad.ButtonDensity.compact,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.restart_alt, size: 14, color: palette.brand),
const SizedBox(width: 4),
Text(context.l10n.categoryExtensionsRestoreDefaults, style: TextStyle(fontSize: 12, color: palette.brand)),
],
),
);
}
Widget _buildChip(AppPalette palette, String tag, {required bool duplicate}) {
return Container(
key: ValueKey('extension-chip-$tag'),
padding: const EdgeInsets.only(left: 8, right: 1),
decoration: BoxDecoration(
border: Border.all(color: duplicate ? palette.error : palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(tag, style: TextStyle(fontSize: 11, color: palette.textPrimary)),
const SizedBox(width: 2),
GestureDetector(
key: ValueKey('extension-chip-remove-$tag'),
onTap: () => _removeTag(tag),
behavior: HitTestBehavior.opaque,
child: SizedBox(width: 32, height: 32, child: Icon(Icons.close, size: 13, color: palette.textSecondary)),
),
],
),
);
}
}
@@ -92,6 +92,27 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
bool _draggingTorrent = false;
bool _asDefaultPath = false;
String _configuredDownloadDirectory = '';
/// Index into `_downloadCategories` as filtered by `_loadDefaults`
/// (`!isDeleted`), not into the raw config list; null when the directory
/// falls outside every category. Cached, so it can lag the field while the
/// debounce is pending — `onShortcutSelected` reads exactly this value.
int? _activeCategoryIndex;
Timer? _categoryHighlightTimer;
/// Whether the user picked a directory in this dialog. The backend routes a
/// task to its category directory only when this stays false, so the flag is
/// tracked on its own instead of comparing the field with the default dir.
bool _directoryManuallySet = false;
/// Guards the controller listener while the page fills the field itself.
bool _programmaticDirectoryWrite = false;
/// The text of the directory field the page knows about. The controller also
/// reports selection changes, e.g. when the caret moves, so the listener
/// compares the text to tell a user edit from a caret move.
String _lastDirectoryText = '';
String _fileDataUri = '';
bool _programmaticUrlChange = false;
String _lastUrlText = '';
@@ -100,6 +121,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
void initState() {
super.initState();
_urlController.addListener(_handleUrlChanged);
_directoryController.addListener(_handleDirectoryChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
@@ -127,9 +149,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
void dispose() {
_urlController.removeListener(_handleUrlChanged);
_urlController.dispose();
_directoryController.removeListener(_handleDirectoryChanged);
_categoryHighlightTimer?.cancel();
_categoryHighlightTimer = null;
_renameController.dispose();
_connectionsController.dispose();
_directoryController.dispose();
_connectionsController.dispose();
_proxyServerController.dispose();
_proxyPortController.dispose();
_proxyUsernameController.dispose();
@@ -200,7 +225,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
void _applyInitialTask(CreateTask? task) {
if (task == null || !mounted) return;
final req = task.req;
final opts = task.opts;
final opts = task.opts ?? api_options.Options();
setState(() {
_initialSize = 0;
_checksumAlgorithm = 'md5';
@@ -241,30 +266,35 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
break;
}
}
if (opts != null) {
_initialSize = opts.size;
if (opts.path.isNotEmpty) {
_directoryController.text = opts.path;
// Filling the field is not a manual choice, the caller decides whether
// the task keeps its directory.
_directoryManuallySet = opts.skipAutoCategorize;
_asDefaultPath = opts.skipAutoCategorize && opts.asDefaultPath;
_programmaticDirectoryWrite = true;
// A task without a directory uses the configured one, the field must
// not keep the directory of the previous task.
_directoryController.text = opts.path.isNotEmpty ? opts.path : _configuredDownloadDirectory;
_programmaticDirectoryWrite = false;
_initialSize = opts.size;
if (opts.extra is Map) {
final extra = api_options.OptsExtraHttp.fromJson(Map<String, dynamic>.from(opts.extra! as Map));
if (extra.connections > 0) {
_connectionsController.text = extra.connections.toString();
}
if (opts.extra is Map) {
final extra = api_options.OptsExtraHttp.fromJson(Map<String, dynamic>.from(opts.extra! as Map));
if (extra.connections > 0) {
_connectionsController.text = extra.connections.toString();
}
_autoTorrent = extra.autoTorrent;
_deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload;
_autoExtract = extra.autoExtract;
_archivePasswordController.text = extra.archivePassword;
_deleteAfterExtract = extra.deleteAfterExtract;
if (extra.checksum != null) {
if (extra.checksum!.algorithm.isNotEmpty) {
_checksumAlgorithm = extra.checksum!.algorithm;
}
_checksumHashController.text = extra.checksum!.expected;
_autoTorrent = extra.autoTorrent;
_deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload;
_autoExtract = extra.autoExtract;
_archivePasswordController.text = extra.archivePassword;
_deleteAfterExtract = extra.deleteAfterExtract;
if (extra.checksum != null) {
if (extra.checksum!.algorithm.isNotEmpty) {
_checksumAlgorithm = extra.checksum!.algorithm;
}
_checksumHashController.text = extra.checksum!.expected;
}
}
});
_applyCategoryHighlightNow();
}
void _applyInitialProxy(RequestProxy? proxy) {
@@ -429,15 +459,21 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
setState(() => _asDefaultPath = false);
}
},
onDirectoryPicked: (pickedDirectory) => setState(
() => _asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory),
),
onDirectoryPicked: (pickedDirectory) {
setState(() {
_directoryManuallySet = true;
_asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory);
});
_applyCategoryHighlightNow();
},
),
const SizedBox(height: 8),
_DirectoryOptions(
key: const ValueKey('create-task-directory-options-row'),
stacked: stacked,
asDefaultPath: _asDefaultPath,
canSetAsDefaultPath: _directoryManuallySet,
activeIndex: _activeCategoryIndex,
onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value),
shortcuts: [
for (final entry in _downloadCategories.indexed)
@@ -448,16 +484,22 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
),
],
onShortcutSelected: (shortcut) {
_directoryController.text = shortcut.path;
if (_asDefaultPath) {
setState(() => _asDefaultPath = false);
}
// Tapping the active shortcut again clears the selection and
// restores the saved default directory. Either tap is an
// explicit directory choice, so the task skips auto classify.
final turnOn = _activeCategoryIndex != shortcut.index;
setState(() {
_directoryManuallySet = true;
_asDefaultPath = false;
});
_directoryController.text = turnOn ? shortcut.path : _configuredDownloadDirectory;
_applyCategoryHighlightNow();
},
),
],
),
),
const SizedBox(height: 14),
const SizedBox(height: 8),
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
label: context.l10n.directDownload,
@@ -808,7 +850,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_configuredDownloadDirectory = config.downloadDir.trim();
if (config.downloadDir.isNotEmpty &&
(_directoryController.text.isEmpty || _directoryController.text == 'C:/Users/levi/Downloads')) {
_programmaticDirectoryWrite = true;
_directoryController.text = config.downloadDir;
_programmaticDirectoryWrite = false;
}
if (_asDefaultPath && _sameDirectory(_directoryController.text, _configuredDownloadDirectory)) {
_asDefaultPath = false;
@@ -818,10 +862,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_connectionsController.text = connections.toString();
}
_directDownload = config.extra.defaultDirectDownload;
_downloadCategories = config.extra.downloadCategories
.where((category) => !category.isDeleted)
.toList(growable: false);
_downloadCategories = config.categories.where((category) => !category.isDeleted).toList(growable: false);
});
_applyCategoryHighlightNow();
} catch (_) {
// Keep local defaults when the backend is not available yet.
}
@@ -1087,7 +1130,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
}
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
api_options.Options _buildOptions({
String? name,
List<int> selectFiles = const [],
bool omitChecksum = false,
bool? skipAutoCategorize,
}) {
final urls = _inputUrls();
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
final checksumExpected = _checksumHashController.text.trim();
@@ -1099,6 +1147,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(),
asDefaultPath: _asDefaultPath,
skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet,
selectFiles: selectFiles,
extra: api_options.OptsExtraHttp(
connections: connections,
@@ -1119,6 +1168,60 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return path.equals(path.normalize(normalizedLeft), path.normalize(normalizedRight));
}
/// Index of the category whose path matches [value], or null. Category paths
/// are resolved through `_renderPathPlaceholders` before comparing, while
/// [value] is taken verbatim, so a `%year%` pattern only matches once the
/// directory field already holds the rendered path.
int? _categoryIndexFor(String value) {
final target = value.trim();
if (target.isEmpty) return null;
for (final entry in _downloadCategories.indexed) {
if (_sameDirectory(_renderPathPlaceholders(entry.$2.path), target)) return entry.$1;
}
return null;
}
void _applyCategoryHighlight() {
final next = _categoryIndexFor(_directoryController.text);
if (next == _activeCategoryIndex) return;
setState(() => _activeCategoryIndex = next);
}
/// Debounced variant for paths typed into the directory field, so the
/// highlight does not flicker while the path is still incomplete.
/// A text change the user typed is an explicit directory choice; one the
/// page writes itself (initial fill, incoming task) is not. The controller
/// also notifies on selection changes, e.g. when the caret moves, so only a
/// changed text counts as a manual choice.
void _handleDirectoryChanged() {
final text = _directoryController.text;
if (text == _lastDirectoryText) {
return;
}
_lastDirectoryText = text;
if (!_programmaticDirectoryWrite && !_directoryManuallySet) {
setState(() => _directoryManuallySet = true);
}
_scheduleCategoryHighlight();
}
void _scheduleCategoryHighlight() {
_categoryHighlightTimer?.cancel();
_categoryHighlightTimer = Timer(_categoryHighlightDebounce, () {
if (!mounted) return;
_applyCategoryHighlight();
});
}
/// Immediate variant for shortcut clicks and programmatic writes, which
/// cancels a pending debounce instead of waiting for it.
void _applyCategoryHighlightNow() {
_categoryHighlightTimer?.cancel();
_categoryHighlightTimer = null;
if (!mounted) return;
_applyCategoryHighlight();
}
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort();
@@ -1136,6 +1239,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
if (result.res.files.isNotEmpty) {
// The extension returned a file list, so the resource keeps a directory
// structure. The split tasks must not be classified into a category, the
// structure of the resolved resource is kept. The decision is based on
// the resolved resource, not on the number of selected files.
final keepStructure = _directoryManuallySet || result.res.name.isNotEmpty || result.res.files.length > 1;
final reqs = selected.map((index) {
final file = result.res.files[index];
final fileRequest = file.req ?? request;
@@ -1145,6 +1253,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0,
name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
skipAutoCategorize: keepStructure,
extra: _buildOptions(omitChecksum: isMultiFile).extra,
),
);
@@ -1154,7 +1263,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
.createTaskBatch(
CreateTaskBatch(
reqs: reqs,
opts: _buildOptions(omitChecksum: isMultiFile),
opts: _buildOptions(omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
),
);
return;
@@ -1168,6 +1277,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
var selectedIndexes = List<int>.of(initialSelection);
var submitting = false;
String? errorText;
// The directory the task will be saved to. A directory the user named
// wins over the category suggestion of the resolve response, the backend
// keeps it and does not classify the task.
final effectiveDownloadDir = _directoryManuallySet
? _directoryController.text.trim()
: (result.suggestedDownloadDir.isNotEmpty ? result.suggestedDownloadDir : _directoryController.text.trim());
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
@@ -1223,6 +1338,16 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
: dialogContext.l10n.resolvedFileCount(result.res.files.length),
style: TextStyle(color: palette.textSecondary, fontSize: 12),
),
if (effectiveDownloadDir.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
dialogContext.l10n.resolvedCategoryDirectory(effectiveDownloadDir),
key: const ValueKey('resolve-category-directory'),
style: TextStyle(color: palette.textSecondary, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
SizedBox(
height: treeHeight.clamp(260.0, 460.0),
@@ -1512,9 +1637,10 @@ class _DirectDownloadToggle extends StatelessWidget {
}
class _AsDefaultPathToggle extends StatelessWidget {
const _AsDefaultPathToggle({required this.value, required this.onChanged});
const _AsDefaultPathToggle({required this.value, required this.enabled, required this.onChanged});
final bool value;
final bool enabled;
final ValueChanged<bool> onChanged;
@override
@@ -1523,7 +1649,7 @@ class _AsDefaultPathToggle extends StatelessWidget {
return GestureDetector(
key: const ValueKey('create-task-remember-download-directory'),
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(!value),
onTap: enabled ? () => onChanged(!value) : null,
child: SizedBox(
height: 24,
child: Row(
@@ -1532,7 +1658,7 @@ class _AsDefaultPathToggle extends StatelessWidget {
Checkbox(
key: const ValueKey('create-task-remember-download-directory-checkbox'),
state: value ? CheckboxState.checked : CheckboxState.unchecked,
onChanged: (_) => onChanged(!value),
onChanged: enabled ? (_) => onChanged(!value) : null,
),
const SizedBox(width: AppDesignTokens.checkboxLabelGap),
Flexible(
@@ -1564,6 +1690,8 @@ class _DirectoryOptions extends StatelessWidget {
super.key,
required this.stacked,
required this.asDefaultPath,
required this.canSetAsDefaultPath,
required this.activeIndex,
required this.onAsDefaultPathChanged,
required this.shortcuts,
required this.onShortcutSelected,
@@ -1571,14 +1699,28 @@ class _DirectoryOptions extends StatelessWidget {
final bool stacked;
final bool asDefaultPath;
/// The global default download directory is only saved for a directory the
/// user named explicitly, so the toggle stays disabled until then.
final bool canSetAsDefaultPath;
final int? activeIndex;
final ValueChanged<bool> onAsDefaultPathChanged;
final List<_DirectoryShortcut> shortcuts;
final ValueChanged<_DirectoryShortcut> onShortcutSelected;
@override
Widget build(BuildContext context) {
final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged);
final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected);
final toggle = _AsDefaultPathToggle(
value: asDefaultPath,
enabled: canSetAsDefaultPath,
onChanged: onAsDefaultPathChanged,
);
final shortcutList = _DirectoryShortcuts(
shortcuts: shortcuts,
activeIndex: activeIndex,
alignEnd: !stacked,
onSelected: onShortcutSelected,
);
if (stacked) {
return Column(
@@ -1613,9 +1755,15 @@ class _DirectoryOptions extends StatelessWidget {
}
class _DirectoryShortcuts extends StatelessWidget {
const _DirectoryShortcuts({required this.shortcuts, required this.alignEnd, required this.onSelected});
const _DirectoryShortcuts({
required this.shortcuts,
required this.activeIndex,
required this.alignEnd,
required this.onSelected,
});
final List<_DirectoryShortcut> shortcuts;
final int? activeIndex;
final bool alignEnd;
final ValueChanged<_DirectoryShortcut> onSelected;
@@ -1631,6 +1779,7 @@ class _DirectoryShortcuts extends StatelessWidget {
_DirectoryShortcutButton(
key: ValueKey('create-task-category-${shortcut.index}'),
shortcut: shortcut,
active: shortcut.index == activeIndex,
onPressed: () => onSelected(shortcut),
),
],
@@ -1651,6 +1800,7 @@ class _DirectoryShortcuts extends StatelessWidget {
_DirectoryShortcutButton(
key: ValueKey('create-task-category-${shortcut.index}'),
shortcut: shortcut,
active: shortcut.index == activeIndex,
onPressed: () => onSelected(shortcut),
),
],
@@ -1662,9 +1812,10 @@ class _DirectoryShortcuts extends StatelessWidget {
}
class _DirectoryShortcutButton extends StatefulWidget {
const _DirectoryShortcutButton({super.key, required this.shortcut, required this.onPressed});
const _DirectoryShortcutButton({super.key, required this.shortcut, required this.active, required this.onPressed});
final _DirectoryShortcut shortcut;
final bool active;
final VoidCallback onPressed;
@override
@@ -1677,10 +1828,12 @@ class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final active = widget.active;
return AppTooltip(
message: widget.shortcut.path,
child: Semantics(
button: true,
selected: active,
label: widget.shortcut.label,
child: MouseRegion(
cursor: SystemMouseCursors.click,
@@ -1696,21 +1849,25 @@ class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> {
constraints: const BoxConstraints(maxWidth: 168),
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: _hovered ? palette.surfaceSoft : palette.cardBg,
color: active ? palette.brandSoft : (_hovered ? palette.surfaceSoft : palette.cardBg),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
border: Border.all(color: palette.border),
border: Border.all(color: active ? palette.brand.withValues(alpha: 0.45) : palette.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 14, color: palette.textMuted),
Icon(Icons.folder_outlined, size: 14, color: active ? palette.brand : palette.textMuted),
const SizedBox(width: 5),
Flexible(
child: Text(
widget.shortcut.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textSecondary, fontSize: 11.5, fontWeight: FontWeight.w600),
style: TextStyle(
color: active ? palette.textPrimary : palette.textSecondary,
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
),
],
@@ -1842,6 +1999,8 @@ class _SegmentButton extends StatelessWidget {
}
}
const _categoryHighlightDebounce = Duration(milliseconds: 300);
const _advancedExpandDuration = Duration(milliseconds: 220);
const _advancedScrollDelay = Duration(milliseconds: 90);
const _advancedScrollDuration = Duration(milliseconds: 260);
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "برامج",
"categoryName": "اسم الفئة",
"categoryPath": "مسار الفئة",
"autoCategorizeByExtension": "تصنيف تلقائي حسب نوع الملف",
"autoCategorizeByExtensionDescription": "توجيه التنزيلات إلى مجلد الفئة تلقائيًا حسب امتداد الملف عند الحفظ في مجلد التنزيل الافتراضي.",
"categoryExtensions": "امتدادات الملفات",
"categoryExtensionsUsageHint": "افصل الامتدادات بفواصل أو مسافات ثم اضغط Enter.",
"categoryExtensionsEmptyHint": "بدون الامتدادات، لن تُحفظ الملفات في هذا التصنيف تلقائيًا.",
"categoryExtensionsRestoreDefaults": "استعادة الامتدادات الافتراضية",
"categoryExtensionTaken": "{extension} مستخدم بالفعل بواسطة {category}",
"builtInCategory": "لا يمكن حذف الفئة المضمنة",
"selectCategory": "اختر الفئة",
"quickDirectories": "مجلدات سريعة",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "اختار ملف واحد على الأقل",
"resolvedFiles": "الملفات المبتغاة",
"resolvedFileCount": "{count}",
"resolvedCategoryDirectory": "سيتم الحفظ في {path}",
"resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار",
"noFiles": "لا توجد ملفات",
"historyLinks": "روابط السجل",
+8
View File
@@ -210,6 +210,13 @@
"categoryProgram": "Proqram",
"categoryName": "Kateqoriyanın Adı",
"categoryPath": "Kateqoriyanın Yolu",
"autoCategorizeByExtension": "Fayl tipinə görə avtomatik kateqoriyalaşdırma",
"autoCategorizeByExtensionDescription": "Standart yükləmə qovluğuna yadda saxlanarkən yükləmələr fayl uzantısına görə avtomatik olaraq kateqoriya qovluğuna yönləndirilir.",
"categoryExtensions": "Fayl uzantıları",
"categoryExtensionsUsageHint": "Uzantıları vergül və ya boşluqla ayırın, sonra Enter-a basın.",
"categoryExtensionsEmptyHint": "Uzantı yoxdursa, fayllar bu kateqoriyaya avtomatik olaraq saxlanmır.",
"categoryExtensionsRestoreDefaults": "Standart uzantıları bərpa et",
"categoryExtensionTaken": "{extension} artıq {category} tərəfindən istifadə olunur",
"builtInCategory": "Daxili kateqoriya silinə bilməz",
"selectCategory": "Kateqoriya Seç",
"quickDirectories": "Sürətli qovluqlar",
@@ -377,6 +384,7 @@
"selectAtLeastOneFile": "Ən azı bir fayl seçin",
"resolvedFiles": "Tapılmış Fayllar",
"resolvedFileCount": "{count} fayl tapıldı",
"resolvedCategoryDirectory": "{path} qovluğuna saxlanılacaq",
"resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur",
"noFiles": "Fayl yoxdur",
"historyLinks": "Tarixçə keçidləri",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programa",
"categoryName": "Nom de la categoria",
"categoryPath": "Ruta de la categoria",
"autoCategorizeByExtension": "Categoritza automàticament segons el tipus de fitxer",
"autoCategorizeByExtensionDescription": "Les baixades es desen a la carpeta de categoria segons l'extensió del fitxer en utilitzar la carpeta de baixades predeterminada.",
"categoryExtensions": "Extensions de fitxer",
"categoryExtensionsUsageHint": "Separa les extensions amb comes o espais i prem Retorn.",
"categoryExtensionsEmptyHint": "Sense extensions, els fitxers no es desaran aquí automàticament.",
"categoryExtensionsRestoreDefaults": "Restaurar les extensions predeterminades",
"categoryExtensionTaken": "{extension} ja està utilitzat per {category}",
"builtInCategory": "La categoria integrada no es pot suprimir",
"selectCategory": "Seleccioneu una categoria",
"quickDirectories": "Carpetes ràpides",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer",
"resolvedFiles": "Fitxers resolts",
"resolvedFileCount": "{count}",
"resolvedCategoryDirectory": "Es desarà a {path}",
"resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar",
"noFiles": "Sense fitxers",
"historyLinks": "Enllaços de l'historial",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programm",
"categoryName": "Kategoriename",
"categoryPath": "Kategoriepfad",
"autoCategorizeByExtension": "Nach Dateityp automatisch kategorisieren",
"autoCategorizeByExtensionDescription": "Downloads werden beim Speichern im Standard-Download-Ordner anhand der Dateiendung automatisch einem Kategorieordner zugeordnet.",
"categoryExtensions": "Dateiendungen",
"categoryExtensionsUsageHint": "Trenne Endungen mit Kommas oder Leerzeichen und drücke Enter.",
"categoryExtensionsEmptyHint": "Ohne Endungen werden Dateien nicht automatisch dieser Kategorie zugeordnet.",
"categoryExtensionsRestoreDefaults": "Standard-Endungen wiederherstellen",
"categoryExtensionTaken": "{extension} wird bereits von {category} verwendet",
"builtInCategory": "Integrierte Kategorie kann nicht gelöscht werden",
"selectCategory": "Kategorie auswählen",
"quickDirectories": "Schnellordner",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus",
"resolvedFiles": "Aufgelöste Dateien",
"resolvedFileCount": "{count}-Dateien behoben",
"resolvedCategoryDirectory": "Wird in {path} gespeichert",
"resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste",
"noFiles": "Keine Dateien",
"historyLinks": "Verlaufslinks",
+25
View File
@@ -217,6 +217,23 @@
"categoryProgram": "Program",
"categoryName": "Category Name",
"categoryPath": "Category Path",
"autoCategorizeByExtension": "Auto-categorize by file type",
"autoCategorizeByExtensionDescription": "Automatically route downloads into a category folder based on the file name when saving to the default download folder.",
"categoryExtensions": "Associated Extensions",
"categoryExtensionsUsageHint": "Separate suffixes with commas or spaces, then press Enter.",
"categoryExtensionsEmptyHint": "Without extensions, files won't be saved into this category automatically.",
"categoryExtensionsRestoreDefaults": "Restore default extensions",
"categoryExtensionTaken": "{extension} is already used by {category}",
"@categoryExtensionTaken": {
"placeholders": {
"extension": {
"type": "String"
},
"category": {
"type": "String"
}
}
},
"builtInCategory": "Built-in category cannot be deleted",
"selectCategory": "Select Category",
"quickDirectories": "Quick folders",
@@ -387,6 +404,14 @@
"selectAtLeastOneFile": "Select at least one file",
"resolvedFiles": "Resolved Files",
"resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}",
"resolvedCategoryDirectory": "Will be saved to {path}",
"@resolvedCategoryDirectory": {
"placeholders": {
"path": {
"type": "String"
}
}
},
"resourceHasNoFiles": "The resource has no selectable file list",
"noFiles": "No files",
"historyLinks": "History Links",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programa",
"categoryName": "Nombre de categoría",
"categoryPath": "Ruta de categoría",
"autoCategorizeByExtension": "Categorizar automáticamente por tipo de archivo",
"autoCategorizeByExtensionDescription": "Las descargas se guardan automáticamente en la carpeta de categoría según la extensión del archivo al usar la carpeta de descargas predeterminada.",
"categoryExtensions": "Extensiones de archivo",
"categoryExtensionsUsageHint": "Separa las extensiones con comas o espacios y pulsa Intro.",
"categoryExtensionsEmptyHint": "Sin extensiones, los archivos no se guardarán automáticamente en esta categoría.",
"categoryExtensionsRestoreDefaults": "Restaurar extensiones predeterminadas",
"categoryExtensionTaken": "{extension} ya está en uso en {category}",
"builtInCategory": "La categoría incorporada no se puede eliminar",
"selectCategory": "Seleccionar categoría",
"quickDirectories": "Carpetas rápidas",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Seleccione al menos un archivo",
"resolvedFiles": "Archivos resueltos",
"resolvedFileCount": "{count} resueltos",
"resolvedCategoryDirectory": "Se guardará en {path}",
"resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable",
"noFiles": "No hay archivos",
"historyLinks": "Enlaces del historial",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "برنامه",
"categoryName": "نام دسته‌بندی",
"categoryPath": "مسیر دسته‌بندی",
"autoCategorizeByExtension": "دسته‌بندی خودکار بر اساس نوع فایل",
"autoCategorizeByExtensionDescription": "هنگام ذخیره در پوشه پیش‌فرض دانلود، دانلودها بر اساس پسوند فایل به پوشه دسته مربوطه منتقل می‌شوند.",
"categoryExtensions": "پسوندهای فایل",
"categoryExtensionsUsageHint": "پسوندها را با ویرگول یا فاصله جدا کنید و Enter را بزنید.",
"categoryExtensionsEmptyHint": "بدون پسوندها، فایل‌ها به‌صورت خودکار در این دسته ذخیره نمی‌شوند.",
"categoryExtensionsRestoreDefaults": "بازیابی پسوندهای پیش‌فرض",
"categoryExtensionTaken": "{extension} از قبل توسط {category} استفاده شده است",
"builtInCategory": "دسته‌بندی داخلی قابل حذف نیست",
"selectCategory": "انتخاب دسته‌بندی",
"quickDirectories": "پوشه‌های سریع",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید",
"resolvedFiles": "فایل های حل شده",
"resolvedFileCount": "{count} فایل شناسایی شد",
"resolvedCategoryDirectory": "در {path} ذخیره می‌شود",
"resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد",
"noFiles": "فایل ها",
"historyLinks": "پیوندهای تاریخچه",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programme",
"categoryName": "Nom de la catégorie",
"categoryPath": "Chemin de la catégorie",
"autoCategorizeByExtension": "Catégoriser automatiquement selon le type de fichier",
"autoCategorizeByExtensionDescription": "Les téléchargements sont placés dans un dossier de catégorie selon l'extension du fichier lors de l'enregistrement dans le dossier de téléchargement par défaut.",
"categoryExtensions": "Extensions de fichier",
"categoryExtensionsUsageHint": "Séparez les extensions par virgules ou espaces, puis appuyez sur Entrée.",
"categoryExtensionsEmptyHint": "Sans extensions, les fichiers ne seront pas enregistrés ici automatiquement.",
"categoryExtensionsRestoreDefaults": "Restaurer les extensions par défaut",
"categoryExtensionTaken": "{extension} est déjà utilisé par {category}",
"builtInCategory": "La catégorie intégrée ne peut pas être supprimée",
"selectCategory": "Sélectionner une catégorie",
"quickDirectories": "Dossiers rapides",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Sélectionnez au moins un fichier",
"resolvedFiles": "Fichiers résolus",
"resolvedFileCount": "{count} résolus",
"resolvedCategoryDirectory": "Sera enregistré dans {path}",
"resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable",
"noFiles": "Aucun fichier",
"historyLinks": "Liens de l'historique",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "program",
"categoryName": "Kategória neve",
"categoryPath": "Kategória útvonala",
"autoCategorizeByExtension": "Automatikus kategorizálás fájltípus szerint",
"autoCategorizeByExtensionDescription": "Az alapértelmezett letöltési mappa használatakor a letöltések a fájl kiterjesztése alapján automatikusan a kategória mappájába kerülnek.",
"categoryExtensions": "Fájlkiterjesztések",
"categoryExtensionsUsageHint": "A kiterjesztéseket vesszővel vagy szóközzel válaszd el, majd nyomd le az Enter billentyűt.",
"categoryExtensionsEmptyHint": "Kiterjesztések nélkül a fájlok nem kerülnek automatikusan ebbe a kategóriába.",
"categoryExtensionsRestoreDefaults": "Alapértelmezett kiterjesztések visszaállítása",
"categoryExtensionTaken": "A(z) {extension} már használatban van itt: {category}",
"builtInCategory": "A beépített kategória nem törölhető",
"selectCategory": "Kategória kiválasztása",
"quickDirectories": "Gyorsmappák",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Válasszon ki legalább egy fájlt",
"resolvedFiles": "Feloldott fájlok",
"resolvedFileCount": "{count} fájlok megoldva",
"resolvedCategoryDirectory": "Ide lesz mentve: {path}",
"resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája",
"noFiles": "Nincsenek fájlok",
"historyLinks": "Előzményhivatkozások",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Program",
"categoryName": "Nama Kategori",
"categoryPath": "Path Kategori",
"autoCategorizeByExtension": "Kategorikan otomatis berdasarkan jenis file",
"autoCategorizeByExtensionDescription": "Unduhan dialihkan ke folder kategori berdasarkan ekstensi file saat disimpan ke folder unduhan bawaan.",
"categoryExtensions": "Ekstensi file",
"categoryExtensionsUsageHint": "Pisahkan ekstensi dengan koma atau spasi, lalu tekan Enter.",
"categoryExtensionsEmptyHint": "Tanpa ekstensi, file tidak akan otomatis disimpan ke kategori ini.",
"categoryExtensionsRestoreDefaults": "Pulihkan ekstensi default",
"categoryExtensionTaken": "{extension} sudah digunakan oleh {category}",
"builtInCategory": "Kategori bawaan tidak dapat dihapus",
"selectCategory": "Pilih Kategori",
"quickDirectories": "Folder cepat",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Pilih paling tidak satu berkas",
"resolvedFiles": "Berkas Terpecahkan",
"resolvedFileCount": "{count} terselesaikan",
"resolvedCategoryDirectory": "Akan disimpan ke {path}",
"resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih",
"noFiles": "Tidak ada berkas",
"historyLinks": "Tautan Riwayat",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programma",
"categoryName": "Nome categoria",
"categoryPath": "Percorso categoria",
"autoCategorizeByExtension": "Categorizza automaticamente per tipo di file",
"autoCategorizeByExtensionDescription": "Il download viene instradato automaticamente nella cartella della categoria in base all'estensione quando si salva nella cartella di download predefinita.",
"categoryExtensions": "Estensioni file",
"categoryExtensionsUsageHint": "Separa le estensioni con virgole o spazi e premi Invio.",
"categoryExtensionsEmptyHint": "Senza estensioni, i file non verranno salvati qui automaticamente.",
"categoryExtensionsRestoreDefaults": "Ripristina estensioni predefinite",
"categoryExtensionTaken": "{extension} è già in uso in {category}",
"builtInCategory": "La categoria predefinita non può essere eliminata",
"selectCategory": "Seleziona categoria",
"quickDirectories": "Cartelle rapide",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Selezionare almeno un file",
"resolvedFiles": "File recuperati",
"resolvedFileCount": "{count} risolti",
"resolvedCategoryDirectory": "Verrà salvato in {path}",
"resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile",
"noFiles": "Nessun file",
"historyLinks": "Link della cronologia",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "プログラム",
"categoryName": "カテゴリ名",
"categoryPath": "カテゴリパス",
"autoCategorizeByExtension": "ファイル種別で自動分類",
"autoCategorizeByExtensionDescription": "既定のダウンロードフォルダーに保存する際、拡張子に基づいて分類フォルダーへ自動的に振り分けます。",
"categoryExtensions": "ファイル拡張子",
"categoryExtensionsUsageHint": "カンマやスペースで拡張子を区切り、Enterで追加します。",
"categoryExtensionsEmptyHint": "拡張子が未設定の場合、ファイルは自動的にこの分類へ保存されません。",
"categoryExtensionsRestoreDefaults": "デフォルトの拡張子を復元",
"categoryExtensionTaken": "{extension} は {category} で既に使用されています",
"builtInCategory": "内蔵カテゴリは削除できません",
"selectCategory": "カテゴリを選択",
"quickDirectories": "クイックフォルダー",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "少なくとも1つのファイルを選択します",
"resolvedFiles": "解決されたファイル",
"resolvedFileCount": "{count} ファイルが解決しました",
"resolvedCategoryDirectory": "{path} に保存されます",
"resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません",
"noFiles": "ファイルがない場合",
"historyLinks": "履歴リンク",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "프로그램",
"categoryName": "카테고리 이름",
"categoryPath": "카테고리 경로",
"autoCategorizeByExtension": "파일 형식별 자동 분류",
"autoCategorizeByExtensionDescription": "기본 다운로드 폴더에 저장할 때 확장자에 따라 분류 폴더로 자동 이동합니다.",
"categoryExtensions": "파일 확장자",
"categoryExtensionsUsageHint": "쉼표나 공백으로 확장자를 구분하고 Enter를 눌러 추가하세요.",
"categoryExtensionsEmptyHint": "확장자가 없으면 파일이 이 분류로 자동 저장되지 않습니다.",
"categoryExtensionsRestoreDefaults": "기본 확장자 복원",
"categoryExtensionTaken": "{extension}은(는) {category}에서 이미 사용 중입니다",
"builtInCategory": "기본 제공 카테고리는 삭제할 수 없습니다",
"selectCategory": "카테고리 선택",
"quickDirectories": "빠른 폴더",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오",
"resolvedFiles": "해결된 파일",
"resolvedFileCount": "{count} 파일 해결",
"resolvedCategoryDirectory": "{path}에 저장됩니다",
"resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다",
"noFiles": "파일 없음",
"historyLinks": "기록 링크",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programy",
"categoryName": "Nazwa kategorii",
"categoryPath": "Ścieżka kategorii",
"autoCategorizeByExtension": "Automatyczna kategorizacja według typu pliku",
"autoCategorizeByExtensionDescription": "Pobierane pliki są automatycznie zapisywane w folderze kategorii na podstawie rozszerzenia, gdy używany jest domyślny folder pobierania.",
"categoryExtensions": "Rozszerzenia plików",
"categoryExtensionsUsageHint": "Rozdzielaj rozszerzenia przecinkami lub spacjami i naciskaj Enter.",
"categoryExtensionsEmptyHint": "Bez rozszerzeń pliki nie będą automatycznie zapisywane w tej kategorii.",
"categoryExtensionsRestoreDefaults": "Przywróć domyślne rozszerzenia",
"categoryExtensionTaken": "{extension} jest już używane przez {category}",
"builtInCategory": "Wbudowanej kategorii nie można usunąć",
"selectCategory": "Wybierz kategorię",
"quickDirectories": "Szybkie foldery",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Wybierz co najmniej jeden plik",
"resolvedFiles": "Rozwiązywane pliki",
"resolvedFileCount": "{count} plików rozwiązanych",
"resolvedCategoryDirectory": "Zostanie zapisane w {path}",
"resourceHasNoFiles": "Zasób nie ma listy plików do wyboru",
"noFiles": "Brak plików",
"historyLinks": "Linki historii",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Programa",
"categoryName": "Nome da Categoria",
"categoryPath": "Caminho da Categoria",
"autoCategorizeByExtension": "Categorizar automaticamente por tipo de arquivo",
"autoCategorizeByExtensionDescription": "Envia os downloads para a pasta da categoria com base na extensão ao salvar na pasta de download padrão.",
"categoryExtensions": "Extensões de arquivo",
"categoryExtensionsUsageHint": "Separe as extensões com vírgulas ou espaços e prima Enter.",
"categoryExtensionsEmptyHint": "Sem extensões, os ficheiros não serão guardados aqui automaticamente.",
"categoryExtensionsRestoreDefaults": "Restaurar extensões padrão",
"categoryExtensionTaken": "{extension} já está em uso em {category}",
"builtInCategory": "Categoria integrada não pode ser excluída",
"selectCategory": "Selecionar Categoria",
"quickDirectories": "Pastas rápidas",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
"resolvedFiles": "Ficheiros Resolvidos",
"resolvedFileCount": "{count} ficheiros resolvidos",
"resolvedCategoryDirectory": "Será salvo em {path}",
"resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis",
"noFiles": "Sem arquivos",
"historyLinks": "Links do histórico",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Программа",
"categoryName": "Название категории",
"categoryPath": "Путь категории",
"autoCategorizeByExtension": "Автокатегоризация по типу файла",
"autoCategorizeByExtensionDescription": "Загрузки автоматически сохраняются в папку категории по расширению файла, если выбрана папка загрузки по умолчанию.",
"categoryExtensions": "Расширения файлов",
"categoryExtensionsUsageHint": "Разделяйте расширения запятыми или пробелами и нажимайте Enter.",
"categoryExtensionsEmptyHint": "Без расширений файлы не будут автоматически сохранены в эту категорию.",
"categoryExtensionsRestoreDefaults": "Восстановить расширения по умолчанию",
"categoryExtensionTaken": "{extension} уже используется в {category}",
"builtInCategory": "Встроенную категорию нельзя удалить",
"selectCategory": "Выбрать категорию",
"quickDirectories": "Быстрые папки",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Выберите хотя бы один файл",
"resolvedFiles": "Решенные файлы",
"resolvedFileCount": "{count} файлов, разрешенных",
"resolvedCategoryDirectory": "Будет сохранено в {path}",
"resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов",
"noFiles": "Нет файлов",
"historyLinks": "Ссылки в истории",
+8
View File
@@ -217,6 +217,13 @@
"categoryProgram": "Program",
"categoryName": "Názov kategórie",
"categoryPath": "Cesta kategórie",
"autoCategorizeByExtension": "Automatická kategorizácia podľa typu súboru",
"autoCategorizeByExtensionDescription": "Sťahovania sa pri ukladaní do predvoleného priečinka sťahovania automaticky presúvajú do priečinka kategórie podľa prípony súboru.",
"categoryExtensions": "Prípony súborov",
"categoryExtensionsUsageHint": "Oddeľujte prípony čiarkami alebo medzerami a stlačte Enter.",
"categoryExtensionsEmptyHint": "Bez prípon sa súbory automaticky neuložia do tejto kategórie.",
"categoryExtensionsRestoreDefaults": "Obnoviť predvolené prípony",
"categoryExtensionTaken": "{extension} je už používané v {category}",
"builtInCategory": "Vstavanú kategóriu nie je možné odstrániť",
"selectCategory": "Vybrať kategóriu",
"quickDirectories": "Rýchle priečinky",
@@ -386,6 +393,7 @@
"selectAtLeastOneFile": "Vyberte aspoň jeden súbor",
"resolvedFiles": "Vyriešené súbory",
"resolvedFileCount": "{count, plural, =1{vyriešený 1 súbor} other{vyriešených {count} súborov}}",
"resolvedCategoryDirectory": "Uloží sa do {path}",
"resourceHasNoFiles": "Zdroj nemá žiadny vyberateľný zoznam súborov",
"noFiles": "Žiadne súbory",
"historyLinks": "Odkazy v histórii",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "நிரல்",
"categoryName": "வகை பெயர்",
"categoryPath": "வகை பாதை",
"autoCategorizeByExtension": "கோப்பு வகை வாரியாக தானாக வகைப்படுத்து",
"autoCategorizeByExtensionDescription": "இயல்புநிலை பதிவிறக்கக் கோப்புறையில் சேமிக்கும்போது, கோப்பு நீட்டிப்பின் அடிப்படையில் வகைக் கோப்புறைக்கு தானாக அனுப்பப்படும்.",
"categoryExtensions": "கோப்பு நீட்டிப்புகள்",
"categoryExtensionsUsageHint": "நீட்டிப்புகளை காற்புள்ளிகள் அல்லது இடைவெளிகளால் பிரித்து Enter அழுத்தவும்.",
"categoryExtensionsEmptyHint": "நீட்டிப்புகள் இல்லாததால், கோப்புகள் இந்த வகைப்பாட்டில் தானாகச் சேமிக்கப்படாது.",
"categoryExtensionsRestoreDefaults": "இயல்புநிலை நீட்டிப்புகளை மீட்டமை",
"categoryExtensionTaken": "{extension} ஏற்கனவே {category} இல் பயன்படுத்தப்படுகிறது",
"builtInCategory": "உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது",
"selectCategory": "வகையைத் தேர்ந்தெடுக்கவும்",
"quickDirectories": "விரைவு கோப்புறைகள்",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்",
"resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்",
"resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன",
"resolvedCategoryDirectory": "{path} இல் சேமிக்கப்படும்",
"resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை",
"noFiles": "(% 1 மொத்தம்) Items (Folders, Files), Size",
"historyLinks": "வரலாற்று இணைப்புகள்",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Program",
"categoryName": "Kategori Adı",
"categoryPath": "Kategori Yolu",
"autoCategorizeByExtension": "Dosya tipine göre otomatik sınıflandır",
"autoCategorizeByExtensionDescription": "Varsayılan indirme klasörüne kaydedilirken dosya uzantısına göre ilgili klasöre otomatik yönlendirir.",
"categoryExtensions": "Dosya uzantıları",
"categoryExtensionsUsageHint": "Sonekleri virgül veya boşlukla ayırıp Enter'a basın.",
"categoryExtensionsEmptyHint": "Uzantı yoksa dosyalar bu kategoriye otomatik olarak kaydedilmez.",
"categoryExtensionsRestoreDefaults": "Varsayılan uzantıları geri yükle",
"categoryExtensionTaken": "{extension} zaten {category} içinde kullanılıyor",
"builtInCategory": "Yerleşik kategori silinemez",
"selectCategory": "Kategori Seç",
"quickDirectories": "Hızlı klasörler",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "En az bir dosya seçin",
"resolvedFiles": "Çözülen Dosyalar",
"resolvedFileCount": "{count} dosyaları çözüldü",
"resolvedCategoryDirectory": "Şuraya kaydedilecek: {path}",
"resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir",
"noFiles": "Hiçbir dosya yok",
"historyLinks": "Geçmiş Bağlantıları",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Програма",
"categoryName": "Назва категорії",
"categoryPath": "Шлях категорії",
"autoCategorizeByExtension": "Автокатегоризація за типом файлу",
"autoCategorizeByExtensionDescription": "Завантаження автоматично зберігаються в папку категорії за розширенням файлу, якщо вибрано папку завантаження за замовчуванням.",
"categoryExtensions": "Розширення файлів",
"categoryExtensionsUsageHint": "Розділяйте розширення комами або пробілами та натискайте Enter.",
"categoryExtensionsEmptyHint": "Без розширень файли не зберігатимуться в цю категорію автоматично.",
"categoryExtensionsRestoreDefaults": "Відновити розширення за мовоччанням",
"categoryExtensionTaken": "{extension} уже використовується в {category}",
"builtInCategory": "Вбудовану категорію неможливо видалити",
"selectCategory": "Вибрати категорію",
"quickDirectories": "Швидкі папки",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Виберіть принаймні один файл",
"resolvedFiles": "Знезалізовані файли",
"resolvedFileCount": "{count} файли вирішені",
"resolvedCategoryDirectory": "Буде збережено в {path}",
"resourceHasNoFiles": "Ресурс не має вибраного списку файлів",
"noFiles": "Немає файлів",
"historyLinks": "Посилання історії",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "Chương trình",
"categoryName": "Tên danh mục",
"categoryPath": "Đường dẫn danh mục",
"autoCategorizeByExtension": "Tự động phân loại theo loại tệp",
"autoCategorizeByExtensionDescription": "Tải xuống sẽ được chuyển vào thư mục danh mục dựa trên phần mở rộng tệp khi lưu vào thư mục tải xuống mặc định.",
"categoryExtensions": "Phần mở rộng tệp",
"categoryExtensionsUsageHint": "Phân tách phần mở rộng bằng dấu phẩy hoặc khoảng trắng rồi nhấn Enter.",
"categoryExtensionsEmptyHint": "Không có phần mở rộng, tệp sẽ không tự động được lưu vào danh mục này.",
"categoryExtensionsRestoreDefaults": "Khôi phục phần mở rộng mặc định",
"categoryExtensionTaken": "{extension} đã được sử dụng trong {category}",
"builtInCategory": "Không thể xóa danh mục tích hợp",
"selectCategory": "Chọn danh mục",
"quickDirectories": "Thư mục nhanh",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "Chọn ít nhất một tập tin",
"resolvedFiles": "Các tập tin đã giải quyết",
"resolvedFileCount": "{count} tập tin được giải quyết",
"resolvedCategoryDirectory": "Sẽ được lưu vào {path}",
"resourceHasNoFiles": "Không thể chọn danh sách tập tin",
"noFiles": "Không có tập tin",
"historyLinks": "Liên kết lịch sử",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "程序",
"categoryName": "分类名称",
"categoryPath": "分类路径",
"autoCategorizeByExtension": "按文件类型自动分类",
"autoCategorizeByExtensionDescription": "保存到默认下载目录时,根据文件后缀自动归入对应分类文件夹。",
"categoryExtensions": "文件后缀关联",
"categoryExtensionsUsageHint": "支持逗号/空格分隔后缀,按回车添加。",
"categoryExtensionsEmptyHint": "未设置后缀,文件不会自动归入此分类。",
"categoryExtensionsRestoreDefaults": "恢复默认后缀",
"categoryExtensionTaken": "{extension}已在{category}中配置",
"builtInCategory": "内置分类不能删除",
"selectCategory": "选择分类",
"quickDirectories": "快捷目录",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "请至少选择一个文件",
"resolvedFiles": "已解析文件",
"resolvedFileCount": "已解析 {count} 个文件",
"resolvedCategoryDirectory": "将保存到 {path}",
"resourceHasNoFiles": "该资源没有可选择的文件列表",
"noFiles": "没有文件",
"historyLinks": "历史链接",
+8
View File
@@ -203,6 +203,13 @@
"categoryProgram": "程式",
"categoryName": "分類名稱",
"categoryPath": "分類路徑",
"autoCategorizeByExtension": "按檔案類型自動分類",
"autoCategorizeByExtensionDescription": "儲存至預設下載目錄時,根據檔案副檔名自動歸入對應分類資料夾。",
"categoryExtensions": "檔案副檔名",
"categoryExtensionsUsageHint": "支援逗號/空白分隔後綴,按回車新增。",
"categoryExtensionsEmptyHint": "未設定後綴,檔案不會自動歸入此分類。",
"categoryExtensionsRestoreDefaults": "恢復預設副檔名",
"categoryExtensionTaken": "{extension}已在{category}中設定",
"builtInCategory": "內建分類無法刪除",
"selectCategory": "選擇分類",
"quickDirectories": "快捷目錄",
@@ -363,6 +370,7 @@
"selectAtLeastOneFile": "請至少選擇一個檔案",
"resolvedFiles": "已解析檔案",
"resolvedFileCount": "已解析 {count} 個檔案",
"resolvedCategoryDirectory": "將儲存到 {path}",
"resourceHasNoFiles": "該資源沒有可選擇的檔案列表",
"noFiles": "沒有檔案",
"historyLinks": "歷史連結",
@@ -134,9 +134,13 @@ class _AppPathPickerFieldState extends State<AppPathPickerField> {
Future<void> _pickPlatformDirectory() async {
final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim());
if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return;
widget.controller.text = path;
_syncTooltipPath(path);
if (!mounted || path == null || path.isEmpty) return;
if (path != widget.controller.text) {
widget.controller.text = path;
_syncTooltipPath(path);
}
// Picking a directory is an explicit choice even when the picked directory
// is the one already shown, so the callback always runs.
widget.onDirectoryPicked?.call(path);
}
@@ -1,5 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:path/path.dart' as path;
void main() {
test('auto-start tasks uses the backend-owned top-level config field', () {
@@ -18,6 +22,17 @@ void main() {
expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse);
});
test('legacy extra downloadCategories is ignored on parse', () {
final json = DownloaderConfig().toJson();
(json['extra'] as Map<String, dynamic>)['downloadCategories'] = [
{'name': 'Music', 'path': '/old/Music'},
];
final decoded = DownloaderConfig.fromJson(json);
expect(decoded.categories, isEmpty);
expect(decoded.toJson()['extra'], isNot(contains('downloadCategories')));
});
test('REST server settings round-trip through the Go downloader config', () {
final config = DownloaderConfig()
..api = ApiServerConfig(
@@ -78,4 +93,180 @@ void main() {
expect(decoded.analyticsEnabled, isFalse);
expect(decoded.analyticsClientId, 'client-id');
});
test('categories and the auto-categorize switch round-trip through the config', () {
final config = DownloaderConfig()
..categories = [
DownloadCategory(name: 'Program', path: '/dl/Program', extensions: const ['exe', 'msi']),
]
..autoCategorize = true;
final decoded = DownloaderConfig.fromJson(config.toJson());
expect(decoded.autoCategorize, isTrue);
expect(decoded.categories.single.name, 'Program');
expect(decoded.categories.single.extensions, ['exe', 'msi']);
});
test('config parses when the category fields are absent or null', () {
final missing = DownloaderConfig().toJson()
..remove('categories')
..remove('autoCategorize');
expect(DownloaderConfig.fromJson(missing).categories, isEmpty);
expect(DownloaderConfig.fromJson(missing).autoCategorize, isFalse);
// The Go backend marshals a nil category slice and an unset switch as null.
final nil = DownloaderConfig().toJson()
..['categories'] = null
..['autoCategorize'] = null;
expect(DownloaderConfig.fromJson(nil).categories, isEmpty);
expect(DownloaderConfig.fromJson(nil).autoCategorize, isFalse);
});
test('auto categorize can be turned off and round-trips', () {
final config = DownloaderConfig(autoCategorize: false);
expect(DownloaderConfig.fromJson(config.toJson()).autoCategorize, isFalse);
});
test('auto categorize is off unless the config turns it on', () {
expect(DownloaderConfig().autoCategorize, isFalse);
expect(DownloaderConfig(autoCategorize: true).autoCategorize, isTrue);
});
test('effectiveExtensions returns the stored list and never falls back', () {
expect(DownloadCategory(name: 'Custom', path: '/x', extensions: const ['zip']).effectiveExtensions(), ['zip']);
final builtin = DownloadCategory(name: '', nameKey: 'categoryMusic', path: '/m');
expect(builtin.effectiveExtensions(), isEmpty);
expect(DownloadCategory(name: 'Custom', path: '/x').effectiveExtensions(), isEmpty);
});
test('normalizeCategoryExtensions trims lowercases strips dots dedupes and splits on separators', () {
expect(normalizeCategoryExtensions([' .MP3 ', 'exe', '.Exe', 'FLAC', 'zip']), ['mp3', 'exe', 'flac', 'zip']);
expect(normalizeCategoryExtensions(['a b,c']), ['a', 'b', 'c']);
expect(normalizeCategoryExtensions(['', ' ', '..']), isEmpty);
});
test('effectiveExtensions normalizes stored lists in input order', () {
final stored = DownloadCategory(name: 'X', path: '/x', extensions: const [' .ZIP ', 'Exe', 'exe']);
expect(stored.effectiveExtensions(), ['zip', 'exe']);
});
group('rebaseBuiltinCategoryPaths', () {
DownloadCategory builtin(String categoryPath, {bool isDeleted = false}) => DownloadCategory(
name: '',
nameKey: 'categoryMusic',
path: categoryPath,
isBuiltIn: true,
isDeleted: isDeleted,
extensions: const ['mp3'],
);
test('rebases built-in categories that live under the old directory', () {
final config = DownloaderConfig()
..categories = [
builtin(path.join('/dl', 'Music')),
DownloadCategory(name: 'Mine', path: path.join('/dl', 'Mine')),
DownloadCategory(
name: 'Elsewhere',
path: path.join('/other', 'Music'),
isBuiltIn: true,
nameKey: 'categoryVideo',
),
builtin(path.join('/dl', 'Old'), isDeleted: true),
];
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data');
expect(config.categories[0].path, path.join('/data', 'Music'));
expect(config.categories[1].path, path.join('/dl', 'Mine'));
expect(config.categories[2].path, path.join('/other', 'Music'));
expect(config.categories[3].path, path.join('/dl', 'Old'));
});
test('rebases a built-in category that points at the old directory itself', () {
final config = DownloaderConfig()..categories = [builtin('/dl')];
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data');
expect(config.categories.single.path, '/data');
});
test('does nothing when the directories are empty or unchanged', () {
final config = DownloaderConfig()..categories = [builtin(path.join('/dl', 'Music'))];
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/dl');
rebaseBuiltinCategoryPaths(config, oldDir: '', newDir: '/data');
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '');
expect(config.categories.single.path, path.join('/dl', 'Music'));
});
test('rebases a windows path that the backend seeded with a backslash', () {
// The backend seeds the categories with the separator of its platform.
final config = DownloaderConfig()
..categories = [
builtin(r'C:\Users\me\Downloads\Music'),
DownloadCategory(name: 'Mine', path: r'C:\Users\me\Downloads\Mine'),
];
rebaseBuiltinCategoryPaths(config, oldDir: r'C:\Users\me\Downloads', newDir: r'C:\Users\me\New');
// The joined separator depends on the platform the test runs on, so the
// assertion compares the forward slash form.
expect(config.categories[0].path.replaceAll(r'\', '/'), 'C:/Users/me/New/Music');
expect(config.categories[1].path, r'C:\Users\me\Downloads\Mine');
});
});
test('go builtinCategoryExtensions stays in sync with kDefaultCategoryExtensions', () {
File? goSource;
for (final candidate in ['pkg/base/model.go', '../../pkg/base/model.go']) {
final file = File(candidate);
if (file.existsSync()) {
goSource = file;
break;
}
}
expect(goSource, isNotNull, reason: 'pkg/base/model.go not found from ${Directory.current}');
final block = RegExp(
r'builtinCategoryExtensions = map\[string\]\[\]string\{([\s\S]*?)\n\}',
).firstMatch(goSource!.readAsStringSync());
expect(block, isNotNull, reason: 'builtinCategoryExtensions map literal not found in pkg/base/model.go');
final entryPattern = RegExp(r'"(\w+)":\s*\{([^}]*)\}');
final extensionPattern = RegExp(r'"([^"]+)"');
final goDefaults = <String, Set<String>>{};
for (final entry in entryPattern.allMatches(block!.group(1)!)) {
goDefaults[entry.group(1)!] = extensionPattern.allMatches(entry.group(2)!).map((m) => m.group(1)!).toSet();
}
expect(goDefaults, isNotEmpty, reason: 'no built-in extension entries parsed from pkg/base/model.go');
final dartDefaults = kDefaultCategoryExtensions.map((key, value) => MapEntry(key, value.toSet()));
expect(goDefaults.keys.toSet(), dartDefaults.keys.toSet(), reason: 'built-in category nameKeys drifted');
for (final key in goDefaults.keys) {
expect(goDefaults[key], dartDefaults[key], reason: 'extension list drifted for $key');
}
});
test('runs the extension rule table shared with the backend', () {
// pkg/download/testdata/category_matching_cases.json is also read by the Go
// test, so a rule that only changes on one side breaks the other side.
final cases = jsonDecode(_readSharedRuleTable()) as Map<String, dynamic>;
for (final c in cases['normalizeExtensions'] as List) {
final input = (c['input'] as List).cast<String>();
final expected = (c['expected'] as List).cast<String>();
expect(normalizeCategoryExtensions(input), expected, reason: 'normalizeCategoryExtensions($input)');
}
});
}
/// The rule table is shared with the backend, see
/// pkg/download/testdata/category_matching_cases.json.
String _readSharedRuleTable() {
for (final candidate in [
'pkg/download/testdata/category_matching_cases.json',
'../../pkg/download/testdata/category_matching_cases.json',
]) {
final file = File(candidate);
if (file.existsSync()) {
return file.readAsStringSync();
}
}
throw StateError('category_matching_cases.json not found from ${Directory.current}');
}
@@ -0,0 +1,120 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/app/application/downloader_config_init.dart';
import 'package:path/path.dart' as path;
void main() {
DownloaderConfig configWith({List<DownloadCategory> categories = const [], String downloadDir = ''}) {
final config = DownloaderConfig(downloadDir: downloadDir)..categories = List.of(categories);
return config;
}
group('loadDownloaderConfig', () {
test('persists when the load succeeded and init changed the config', () async {
var saved = 0;
final config = await loadDownloaderConfig(
load: () async => configWith(),
save: (_) async => saved++,
defaultDownloadDir: () async => '/downloads',
);
expect(saved, 1);
expect(config.categories, hasLength(4));
});
test('does not persist when the initial load failed', () async {
var saved = 0;
final config = await loadDownloaderConfig(
load: () async => throw StateError('backend unavailable'),
save: (_) async => saved++,
defaultDownloadDir: () async => '/downloads',
);
expect(saved, 0);
// The in-memory fallback still gets usable defaults.
expect(config.downloadDir, '/downloads');
expect(config.categories, hasLength(4));
});
test('skips persist when init changed nothing', () async {
var saved = 0;
await loadDownloaderConfig(
load: () async => configWith(
categories: [
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
],
downloadDir: '/downloads',
),
save: (_) async => saved++,
defaultDownloadDir: () async => '/other',
);
expect(saved, 0);
});
test('persists the default download dir when it is missing', () async {
var saved = 0;
final config = await loadDownloaderConfig(
load: () async => configWith(
categories: [
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
],
),
save: (_) async => saved++,
defaultDownloadDir: () async => '/downloads',
);
expect(saved, 1);
expect(config.downloadDir, '/downloads');
});
});
group('initDownloaderConfig', () {
test('seeds built-in categories with default extensions when empty', () async {
final config = configWith();
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads');
expect(changed, isTrue);
expect(config.categories.map((c) => c.nameKey).toList(), [
'categoryMusic',
'categoryVideo',
'categoryDocument',
'categoryProgram',
]);
for (final category in config.categories) {
expect(category.isBuiltIn, isTrue);
expect(category.path, path.join('/downloads', category.nameKey!.replaceFirst('category', '')));
expect(category.extensions, kDefaultCategoryExtensions[category.nameKey]);
}
});
test('keeps existing categories untouched', () async {
final existing = DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']);
final config = configWith(categories: [existing], downloadDir: '/downloads');
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/other');
expect(changed, isFalse);
expect(config.categories, hasLength(1));
expect(config.categories.single, same(existing));
});
test('reports a change when only the default download dir was filled', () async {
final config = configWith(
categories: [
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
],
);
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads');
expect(changed, isTrue);
expect(config.downloadDir, '/downloads');
});
test('leaves the config untouched when the default download dir is unknown', () async {
final config = configWith();
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '');
// A relative or unknown directory would make the backend write into its
// own working directory, so it is left for the backend to resolve.
expect(changed, isFalse);
expect(config.downloadDir, isEmpty);
expect(config.categories, isEmpty);
});
});
}
@@ -0,0 +1,217 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:gopeed/features/settings/presentation/widgets/extension_tag_editor.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
Widget _wrap(Widget child) => shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: child),
);
List<String> _chipTexts(WidgetTester tester) => tester
.widgetList<Text>(find.descendant(of: find.byType(Wrap), matching: find.byType(Text)))
.map((text) => text.data ?? '')
.toList();
Color _chipBorder(WidgetTester tester, String tag) {
final chip = tester.widget<Container>(find.byKey(ValueKey('extension-chip-$tag')));
final border = (chip.decoration! as BoxDecoration).border! as Border;
return border.top.color;
}
void main() {
testWidgets('separators stay in the input until Enter commits them as tags', (WidgetTester tester) async {
await tester.pumpWidget(
_wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['.ZIP', ' Exe', 'exe'])),
);
await tester.pumpAndSettle();
expect(_chipTexts(tester), ['zip', 'exe']);
await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, flac');
await tester.pump();
expect(_chipTexts(tester), ['zip', 'exe']);
expect(find.text('mp3, flac'), findsOneWidget);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac']);
expect(find.text('mp3, flac'), findsNothing);
await tester.enterText(find.byKey(const ValueKey('field')), '.APE');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac', 'ape']);
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe')));
await tester.pump();
expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape']);
expect(find.byKey(const ValueKey('extension-chip-remove-exe')), findsNothing);
await tester.enterText(find.byKey(const ValueKey('field')), 'DO');
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
expect(state.flush(), ['zip', 'mp3', 'flac', 'ape', 'do']);
await tester.pump();
expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape', 'do']);
expect(tester.takeException(), isNull);
});
testWidgets('typing a duplicate highlights the existing chip without adding it', (WidgetTester tester) async {
await tester.pumpWidget(
_wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['zip', 'exe'])),
);
await tester.pumpAndSettle();
final zipIdle = _chipBorder(tester, 'zip');
await tester.enterText(find.byKey(const ValueKey('field')), ' EXE ');
await tester.pump();
expect(_chipBorder(tester, 'exe'), isNot(zipIdle));
expect(_chipBorder(tester, 'zip'), zipIdle);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(_chipTexts(tester), ['zip', 'exe']);
expect(_chipBorder(tester, 'exe'), zipIdle);
expect(tester.takeException(), isNull);
});
testWidgets('placeholder surfaces default extensions only while the editor is empty', (WidgetTester tester) async {
await tester.pumpWidget(
_wrap(
ExtensionTagEditor(
inputKey: const ValueKey('field'),
initialTags: const [],
placeholderText: 'empty hint',
usagePlaceholderText: 'usage hint',
),
),
);
await tester.pumpAndSettle();
expect(find.text('empty hint'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('field')), 'a');
await tester.pump();
expect(find.text('empty hint'), findsNothing);
expect(find.text('usage hint'), findsNothing);
expect(_chipTexts(tester), isEmpty);
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(_chipTexts(tester), ['a']);
expect(find.text('empty hint'), findsNothing);
expect(find.text('usage hint'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('field')), 'b');
await tester.pump();
expect(find.text('usage hint'), findsNothing);
await tester.enterText(find.byKey(const ValueKey('field')), '');
await tester.pump();
expect(find.text('usage hint'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-a')));
await tester.pump();
expect(find.text('empty hint'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('empty state offers a restore-defaults action', (WidgetTester tester) async {
await tester.pumpWidget(
_wrap(
ExtensionTagEditor(
inputKey: const ValueKey('field'),
initialTags: const [],
placeholderText: 'empty state',
restoreDefaults: const ['Exe', '.MSI', 'exe'],
),
),
);
await tester.pumpAndSettle();
expect(find.text('empty state'), findsOneWidget);
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('extension-restore-defaults')));
await tester.pump();
expect(_chipTexts(tester), ['exe', 'msi']);
expect(find.text('empty state'), findsNothing);
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing);
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-msi')));
await tester.pump();
expect(find.text('empty state'), findsOneWidget);
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('extensions owned by another category are blocked with an error', (WidgetTester tester) async {
var conflict = false;
await tester.pumpWidget(
_wrap(
ExtensionTagEditor(
inputKey: const ValueKey('field'),
initialTags: const ['zip'],
takenByCategory: const {'aaa': 'Music'},
onConflictChanged: (value) => conflict = value,
),
),
);
await tester.pumpAndSettle();
expect(conflict, isFalse);
await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, aaa');
await tester.pump();
expect(conflict, isTrue);
expect(find.text('aaa is already used by Music'), findsOneWidget);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(_chipTexts(tester), ['zip', 'mp3']);
expect(find.text('aaa'), findsOneWidget);
expect(find.text('aaa is already used by Music'), findsOneWidget);
expect(conflict, isTrue);
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
expect(state.flush(), ['zip', 'mp3']);
expect(find.text('aaa'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('field')), 'zzz');
await tester.pump();
expect(conflict, isFalse);
expect(find.text('aaa is already used by Music'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('a tag claimed by another category renders red until removed', (WidgetTester tester) async {
var conflict = true;
await tester.pumpWidget(
_wrap(
ExtensionTagEditor(
inputKey: const ValueKey('field'),
initialTags: const ['zip', 'aaa'],
takenByCategory: const {'aaa': 'Music'},
onConflictChanged: (value) => conflict = value,
),
),
);
await tester.pumpAndSettle();
final zipIdle = _chipBorder(tester, 'zip');
expect(_chipBorder(tester, 'aaa'), isNot(zipIdle));
expect(find.text('aaa is already used by Music'), findsOneWidget);
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
expect(state.flush(), ['zip']);
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-aaa')));
await tester.pump();
expect(_chipTexts(tester), ['zip']);
expect(find.text('aaa is already used by Music'), findsNothing);
expect(conflict, isFalse);
expect(tester.takeException(), isNull);
});
}
File diff suppressed because it is too large. Load diff
+100 -1
View File
@@ -1682,6 +1682,44 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('changing the download directory rebases built-in category paths', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final settingsController = RebaseSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final directoryInput = find.byKey(const ValueKey('download-directory-input'));
expect(directoryInput, findsOneWidget);
await tester.enterText(directoryInput, '/dl/new');
await tester.pump(const Duration(milliseconds: 700));
await tester.pumpAndSettle();
final saved = settingsController.state.requireValue.config;
expect(saved.downloadDir, '/dl/new');
expect(
{for (final category in saved.categories) category.name: category.path.replaceAll(r'\', '/')},
{
'Music': '/dl/new/Music',
'Video': '/dl/new/Video',
'Document': '/dl/new/Document',
'Program': '/dl/new/Program',
'Mine': '/dl/old/Mine',
},
);
expect(tester.takeException(), isNull);
});
testWidgets('android download directory picker only switches to verified raw paths', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android;
@@ -2526,6 +2564,50 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('extensions owned by another category block the add dialog', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(CategorySettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('add-download-category')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('download-category-name')), '影集');
await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/shows');
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'dat');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.text('dat is already used by 资料'), findsOneWidget);
AppPrimaryButton saveButton() => tester.widget<AppPrimaryButton>(
find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)),
);
expect(saveButton().onPressed, isNull);
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'mkv');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.text('dat is already used by 资料'), findsNothing);
expect(saveButton().onPressed, isNotNull);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('影集'), findsOneWidget);
expect(find.text('/tmp/shows'), findsOneWidget);
expect(find.text('dat is already used by 资料'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('settings content stretches responsively and stays centered', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1800, 900));
await tester.pumpWidget(
@@ -6082,7 +6164,24 @@ class CategorySettingsController extends FakeSettingsController {
@override
Future<SettingsState> build() async {
final config = DownloaderConfig();
config.extra.downloadCategories = [DownloadCategory(name: '资料', path: '/tmp/data')];
config.categories = [
DownloadCategory(name: '资料', path: '/tmp/data', extensions: ['dat']),
];
return SettingsState(config: config);
}
}
class RebaseSettingsController extends FakeSettingsController {
@override
Future<SettingsState> build() async {
final config = DownloaderConfig()..downloadDir = '/dl/old';
config.categories = [
DownloadCategory(name: 'Music', path: '/dl/old/Music', isBuiltIn: true, nameKey: 'categoryMusic'),
DownloadCategory(name: 'Video', path: '/dl/old/Video', isBuiltIn: true, nameKey: 'categoryVideo'),
DownloadCategory(name: 'Document', path: '/dl/old/Document', isBuiltIn: true, nameKey: 'categoryDocument'),
DownloadCategory(name: 'Program', path: '/dl/old/Program', isBuiltIn: true, nameKey: 'categoryProgram'),
DownloadCategory(name: 'Mine', path: '/dl/old/Mine'),
];
return SettingsState(config: config);
}
}