Repository: GopeedLab/gopeed

Issue:
[功能请求]希望下载目录能实现根据日期等归类的功能
曾经是fdm的用户，后来因为小毛病太多转来的gopeed，十分喜欢gopeed的简洁好用，但fdm中这个根据日期之类的宏的归类功能还是很喜欢，个人较偏好使用年-月的归类方式；类似的分类功能记得在idm下载器也有，有这个功能后下载目录可以变得更加整洁
<img width="1135" height="457" alt="Image" src="https://github.com/user-attachments/assets/fbe91826-9615-4fa8-949e-d129dd6aac4c" />
感谢作者百忙中抽空看这条建议，希望该条建议有价值

Hints from issue comments:
@monkeyWie: 没问题，下载目录这里确实也要优化优化了@w19996: > 没问题，下载目录这里确实也要优化优化了

根据文件类型归类可以考虑一下吗

Solution patch:
diff --git a/pkg/base/model.go b/pkg/base/model.go
index 5b006ed..8c18dc8 100644
--- a/pkg/base/model.go
+++ b/pkg/base/model.go
@@ -182,6 +182,7 @@ type DownloaderStoreConfig struct {
 	ProtocolConfig map[string]any         `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
 	Extra          map[string]any         `json:"extra"`
 	Proxy          *DownloaderProxyConfig `json:"proxy"`
+	Webhook        *WebhookConfig         `json:"webhook"` // Webhook is the webhook configuration
 }
 
 func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
@@ -194,6 +195,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
 	if cfg.Proxy == nil {
 		cfg.Proxy = &DownloaderProxyConfig{}
 	}
+	if cfg.Webhook == nil {
+		cfg.Webhook = &WebhookConfig{}
+	}
 	return cfg
 }
 
@@ -216,9 +220,18 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
 	if cfg.Proxy == nil {
 		cfg.Proxy = beforeCfg.Proxy
 	}
+	if cfg.Webhook == nil {
+		cfg.Webhook = beforeCfg.Webhook
+	}
 	return cfg
 }
 
+// WebhookConfig is the webhook configuration
+type WebhookConfig struct {
+	Enable bool     `json:"enable"` // Enable is the flag to enable/disable webhooks
+	URLs   []string `json:"urls"`   // URLs is the list of webhook URLs
+}
+
 type DownloaderProxyConfig struct {
 	Enable bool `json:"enable"`
 	// System is the flag that use system proxy
diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go
index 573ffac..4c5c897 100644
--- a/pkg/download/downloader.go
+++ b/pkg/download/downloader.go
@@ -943,6 +943,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
 		opts.Path = storeConfig.DownloadDir
 	}
 
+	// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
+	opts.Path = util.ReplacePathPlaceholders(opts.Path)
+
 	// if enable white download directory, check if the download directory is in the white list
 	if len(d.cfg.WhiteDownloadDirs) > 0 {
 		inWhiteList := false
diff --git a/pkg/download/webhook.go b/pkg/download/webhook.go
index d095b06..d223d21 100644
--- a/pkg/download/webhook.go
+++ b/pkg/download/webhook.go
@@ -35,10 +35,29 @@ type WebhookPayload struct {
 	Task *Task `json:"task"`
 }
 
-// getWebhookUrls extracts and converts webhook URLs from config
+// getWebhookUrls extracts webhook URLs from config
+// Supports both new webhook config format and legacy extra field for backward compatibility
 func (d *Downloader) getWebhookUrls() []string {
 	cfg := d.cfg.DownloaderStoreConfig
-	if cfg == nil || cfg.Extra == nil {
+	if cfg == nil {
+		return nil
+	}
+
+	// Try new webhook config first
+	if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {
+		urls := make([]string, 0, len(cfg.Webhook.URLs))
+		for _, url := range cfg.Webhook.URLs {
+			if url != "" {
+				urls = append(urls, url)
+			}
+		}
+		if len(urls) > 0 {
+			return urls
+		}
+	}
+
+	// Fall back to legacy extra field for backward compatibility
+	if cfg.Extra == nil {
 		return nil
 	}
 
diff --git a/pkg/util/path.go b/pkg/util/path.go
index 9855118..0939757 100644
--- a/pkg/util/path.go
+++ b/pkg/util/path.go
@@ -8,6 +8,7 @@ import (
 	syspath "path"
 	"path/filepath"
 	"strings"
+	"time"
 )
 
 func Dir(path string) string {
@@ -199,3 +200,28 @@ func ReplaceInvalidFilename(path string) string {
 	}
 	return path
 }
+
+// ReplacePathPlaceholders replaces date placeholders in a path with actual values
+// Supported placeholders:
+//   - %year%  - Current year (e.g., 2025)
+//   - %month% - Current month (01-12)
+//   - %day%   - Current day (01-31)
+//   - %date%  - Full date format (2025-01-01)
+func ReplacePathPlaceholders(path string) string {
+	if path == "" {
+		return ""
+	}
+
+	now := time.Now()
+	year := fmt.Sprintf("%d", now.Year())
+	month := fmt.Sprintf("%02d", now.Month())
+	day := fmt.Sprintf("%02d", now.Day())
+	date := fmt.Sprintf("%s-%s-%s", year, month, day)
+
+	path = strings.ReplaceAll(path, "%year%", year)
+	path = strings.ReplaceAll(path, "%month%", month)
+	path = strings.ReplaceAll(path, "%day%", day)
+	path = strings.ReplaceAll(path, "%date%", date)
+
+	return path
+}
diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart
index e62c7dd..8b5bc34 100644
--- a/ui/flutter/lib/api/model/downloader_config.dart
+++ b/ui/flutter/lib/api/model/downloader_config.dart
@@ -9,6 +9,7 @@ class DownloaderConfig {
   ProtocolConfig protocolConfig = ProtocolConfig();
   ExtraConfig extra = ExtraConfig();
   ProxyConfig proxy = ProxyConfig();
+  WebhookConfig webhook = WebhookConfig();
 
   DownloaderConfig({
     this.downloadDir = '',
@@ -82,7 +83,7 @@ class ExtraConfig {
   bool defaultDirectDownload;
   bool defaultBtClient;
   bool notifyWhenNewVersion;
-  List<String> webhookUrls;
+  List<DownloadCategory> downloadCategories;
 
   ExtraConfigBt bt = ExtraConfigBt();
 
@@ -93,7 +94,7 @@ class ExtraConfig {
     this.defaultDirectDownload = false,
     this.defaultBtClient = true,
     this.notifyWhenNewVersion = true,
-    this.webhookUrls = const [],
+    this.downloadCategories = const [],
   });
 
   factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
@@ -102,6 +103,44 @@ class ExtraConfig {
   Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
 }
 
+@JsonSerializable()
+class DownloadCategory {
+  String name;
+  String path;
+  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
+
+  DownloadCategory({
+    required this.name,
+    required this.path,
+    this.isBuiltIn = false,
+    this.nameKey,
+    this.isDeleted = false,
+  });
+
+  factory DownloadCategory.fromJson(Map<String, dynamic> json) =>
+      _$DownloadCategoryFromJson(json);
+
+  Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
+}
+
+@JsonSerializable()
+class WebhookConfig {
+  bool enable;
+  List<String> urls;
+
+  WebhookConfig({
+    this.enable = false,
+    this.urls = const [],
+  });
+
+  factory WebhookConfig.fromJson(Map<String, dynamic>? json) =>
+      json == null ? WebhookConfig() : _$WebhookConfigFromJson(json);
+
+  Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
+}
+
 @JsonSerializable()
 class ProxyConfig {
   bool enable;
diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart
index f5a3485..a4ccb4f 100644
--- a/ui/flutter/lib/api/model/downloader_config.g.dart
+++ b/ui/flutter/lib/api/model/downloader_config.g.dart
@@ -14,7 +14,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
       ..protocolConfig = ProtocolConfig.fromJson(
           json['protocolConfig'] as Map<String, dynamic>?)
       ..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
-      ..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>);
+      ..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
+      ..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?);
 
 Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
     <String, dynamic>{
@@ -23,6 +24,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
       'protocolConfig': instance.protocolConfig.toJson(),
       'extra': instance.extra.toJson(),
       'proxy': instance.proxy.toJson(),
+      'webhook': instance.webhook.toJson(),
     };
 
 ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
@@ -75,8 +77,8 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
       defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
       defaultBtClient: json['defaultBtClient'] as bool? ?? true,
       notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
-      webhookUrls: (json['webhookUrls'] as List<dynamic>?)
-              ?.map((e) => e as String)
+      downloadCategories: (json['downloadCategories'] as List<dynamic>?)
+              ?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
               .toList() ??
           const [],
     )..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
@@ -89,10 +91,53 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
       'defaultDirectDownload': instance.defaultDirectDownload,
       'defaultBtClient': instance.defaultBtClient,
       'notifyWhenNewVersion': instance.notifyWhenNewVersion,
-      'webhookUrls': instance.webhookUrls,
+      'downloadCategories':
+          instance.downloadCategories.map((e) => e.toJson()).toList(),
       'bt': instance.bt.toJson(),
     };
 
+DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) =>
+    DownloadCategory(
+      name: json['name'] as String,
+      path: json['path'] as String,
+      isBuiltIn: json['isBuiltIn'] as bool? ?? false,
+      nameKey: json['nameKey'] as String?,
+      isDeleted: json['isDeleted'] as bool? ?? false,
+    );
+
+Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) {
+  final val = <String, dynamic>{
+    'name': instance.name,
+    'path': instance.path,
+    'isBuiltIn': instance.isBuiltIn,
+  };
+
+  void writeNotNull(String key, dynamic value) {
+    if (value != null) {
+      val[key] = value;
+    }
+  }
+
+  writeNotNull('nameKey', instance.nameKey);
+  val['isDeleted'] = instance.isDeleted;
+  return val;
+}
+
+WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) =>
+    WebhookConfig(
+      enable: json['enable'] as bool? ?? false,
+      urls: (json['urls'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          const [],
+    );
+
+Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
+    <String, dynamic>{
+      'enable': instance.enable,
+      'urls': instance.urls,
+    };
+
 ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
       enable: json['enable'] as bool? ?? false,
       system: json['system'] as bool? ?? false,
diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
index 239c027..5670ef5 100644
--- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
+++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_foreground_task/flutter_foreground_task.dart';
 import 'package:get/get.dart';
 import 'package:launch_at_startup/launch_at_startup.dart';
+import 'package:path/path.dart' as path;
 import 'package:path_provider/path_provider.dart';
 import 'package:share_handler/share_handler.dart';
 import 'package:tray_manager/tray_manager.dart';
@@ -504,6 +505,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
       // default select all tracker subscribe urls
       extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
     }
+
     final proxy = config.proxy;
     if (proxy.scheme.isEmpty) {
       proxy.scheme = 'http';
@@ -521,6 +523,45 @@ class AppController extends GetxController with WindowListener, TrayListener {
         config.downloadDir = './';
       }
     }
+
+    // Initialize default download categories if empty
+    if (extra.downloadCategories.isEmpty) {
+      await _initDefaultDownloadCategories();
+    }
+  }
+
+  Future<void> _initDefaultDownloadCategories() async {
+    final extra = downloaderConfig.value.extra;
+    final downloadDir = downloaderConfig.value.downloadDir;
+
+    // Add default built-in categories with i18n keys
+    // No need to set initial name value, it will be retrieved via nameKey
+    extra.downloadCategories.addAll([
+      DownloadCategory(
+        name: '',
+        path: path.join(downloadDir, 'Music'),
+        isBuiltIn: true,
+        nameKey: 'categoryMusic',
+      ),
+      DownloadCategory(
+        name: '',
+        path: path.join(downloadDir, 'Video'),
+        isBuiltIn: true,
+        nameKey: 'categoryVideo',
+      ),
+      DownloadCategory(
+        name: '',
+        path: path.join(downloadDir, 'Document'),
+        isBuiltIn: true,
+        nameKey: 'categoryDocument',
+      ),
+      DownloadCategory(
+        name: '',
+        path: path.join(downloadDir, 'Program'),
+        isBuiltIn: true,
+        nameKey: 'categoryProgram',
+      ),
+    ]);
   }
 
   Future<void> _initLaunchAtStartup() async {
diff --git a/ui/flutter/lib/app/modules/create/views/create_view.dart b/ui/flutter/lib/app/modules/create/views/create_view.dart
index 72e8b30..67cdca8 100644
--- a/ui/flutter/lib/app/modules/create/views/create_view.dart
+++ b/ui/flutter/lib/app/modules/create/views/create_view.dart
@@ -12,6 +12,7 @@ import 'package:rounded_loading_button_plus/rounded_loading_button.dart';
 import '../../../../api/api.dart';
 import '../../../../api/model/create_task.dart';
 import '../../../../api/model/create_task_batch.dart';
+import '../../../../api/model/downloader_config.dart';
 import '../../../../api/model/options.dart';
 import '../../../../api/model/request.dart';
 import '../../../../api/model/resolve_result.dart';
@@ -72,7 +73,9 @@ class CreateView extends GetView<CreateController> {
           .toString();
     }
     if (_pathController.text.isEmpty) {
-      _pathController.text = appController.downloaderConfig.value.downloadDir;
+      // Render placeholders when initializing the path
+      final downloadDir = appController.downloaderConfig.value.downloadDir;
+      _pathController.text = renderPathPlaceholders(downloadDir);
     }
 
     final CreateTask? routerParams = Get.rootDelegate.arguments();
@@ -328,6 +331,8 @@ class CreateView extends GetView<CreateController> {
                         DirectorySelector(
                           controller: _pathController,
                         ),
+                        // Category selector
+                        _buildCategorySelector(appController),
                         Obx(
                           () => Visibility(
                             visible: controller.showAdvanced.value,
@@ -912,4 +917,66 @@ class CreateView extends GetView<CreateController> {
               ],
             ));
   }
+
+  Widget _buildCategorySelector(AppController appController) {
+    final categories =
+        appController.downloaderConfig.value.extra.downloadCategories
+            .where((c) => !c.isDeleted) // Filter out deleted categories
+            .toList();
+    if (categories.isEmpty) {
+      return const SizedBox.shrink();
+    }
+
+    // Helper to get display name
+    String getCategoryDisplayName(DownloadCategory category) {
+      if (category.nameKey != null && category.nameKey!.isNotEmpty) {
+        return category.nameKey!.tr;
+      }
+      return category.name;
+    }
+
+    return Padding(
+      padding: const EdgeInsets.only(top: 8),
+      child: Row(
+        children: [
+          Text(
+            'selectCategory'.tr,
+            style: TextStyle(
+              color: Get.theme.hintColor,
+              fontSize: 12,
+            ),
+          ),
+          const SizedBox(width: 8),
+          Expanded(
+            child: SingleChildScrollView(
+              scrollDirection: Axis.horizontal,
+              child: Row(
+                children: categories.map((category) {
+                  return Padding(
+                    padding: const EdgeInsets.only(right: 8),
+                    child: OutlinedButton(
+                      onPressed: () {
+                        _pathController.text = renderPathPlaceholders(category.path);
+                      },
+                      style: OutlinedButton.styleFrom(
+                        padding: const EdgeInsets.symmetric(
+                          horizontal: 12,
+                          vertical: 4,
+                        ),
+                        minimumSize: Size.zero,
+                      ),
+                      child: Text(
+                        getCategoryDisplayName(category),
+                        style: const TextStyle(fontSize: 12),
+                      ),
+                    ),
+                  );
+                }).toList(),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
 }
diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart
index f0d2abf..f50e798 100644
--- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart
+++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart
@@ -35,6 +35,14 @@ final _divider = const Divider().paddingOnly(left: 10, right: 10);
 class SettingView extends GetView<SettingController> {
   const SettingView({Key? key}) : super(key: key);
 
+  // Helper function to get display name for a category
+  static String _getCategoryDisplayName(DownloadCategory category) {
+    if (category.nameKey != null && category.nameKey!.isNotEmpty) {
+      return category.nameKey!.tr;
+    }
+    return category.name;
+  }
+
   @override
   Widget build(BuildContext context) {
     final appController = Get.find<AppController>();
@@ -71,20 +79,25 @@ class SettingView extends GetView<SettingController> {
         'downloadDir', () => downloaderCfg.value.downloadDir, (Key key) {
       final downloadDirController =
           TextEditingController(text: downloaderCfg.value.downloadDir);
-      downloadDirController.addListener(() async {
+
+      // Update config only when editing is done (on focus lost or submit)
+      void onEditComplete() {
         if (downloadDirController.text != downloaderCfg.value.downloadDir) {
           downloaderCfg.value.downloadDir = downloadDirController.text;
           if (Util.isDesktop()) {
             controller.clearTap();
           }
-
-          await debounceSave();
+          debounceSave();
         }
-      });
+      }
+
       return DirectorySelector(
         controller: downloadDirController,
         showLabel: false,
         showAndoirdToggle: true,
+        allowEdit: true,
+        showPlaceholderButton: true,
+        onEditComplete: onEditComplete,
       );
     });
     final buildMaxRunning = _buildConfigItem(
@@ -135,6 +148,133 @@ class SettingView extends GetView<SettingController> {
       );
     });
 
+    // Download categories configuration
+    buildDownloadCategories() {
+      final categories = downloaderCfg.value.extra.downloadCategories
+          .where((c) => !c.isDeleted) // Filter out deleted categories
+          .toList();
+      return ListTile(
+        title: Text('downloadCategories'.tr),
+        subtitle: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            if (categories.isEmpty)
+              Padding(
+                padding: const EdgeInsets.only(top: 8),
+                child: Text(
+                  'add'.tr,
+                  style: TextStyle(color: Theme.of(context).hintColor),
+                ),
+              ),
+            ...categories.map((category) {
+              return Padding(
+                padding: const EdgeInsets.only(top: 8),
+                child: Row(
+                  children: [
+                    Expanded(
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Text(
+                            _getCategoryDisplayName(category),
+                            style: const TextStyle(fontWeight: FontWeight.bold),
+                          ),
+                          Text(
+                            category.path,
+                            style: TextStyle(
+                              fontSize: 12,
+                              color: Theme.of(context).hintColor,
+                            ),
+                            overflow: TextOverflow.ellipsis,
+                          ),
+                        ],
+                      ),
+                    ),
+                    IconButton(
+                      icon: Icon(
+                        Icons.edit,
+                        size: 20,
+                        color: Theme.of(context).hintColor,
+                      ),
+                      onPressed: () {
+                        _showCategoryDialog(
+                          context,
+                          debounceSave,
+                          downloaderCfg,
+                          category: category,
+                        );
+                      },
+                    ),
+                    IconButton(
+                      icon: Icon(
+                        Icons.delete,
+                        size: 20,
+                        color: Theme.of(context).hintColor,
+                      ),
+                      onPressed: () async {
+                        // Show confirmation dialog
+                        final confirmed = await showDialog<bool>(
+                          context: context,
+                          builder: (context) => AlertDialog(
+                            title: Text('tip'.tr),
+                            content: Text('confirmDelete'.tr),
+                            actions: [
+                              TextButton(
+                                onPressed: () =>
+                                    Navigator.of(context).pop(false),
+                                child: Text('cancel'.tr),
+                              ),
+                              TextButton(
+                                onPressed: () =>
+                                    Navigator.of(context).pop(true),
+                                child: Text('confirm'.tr),
+                              ),
+                            ],
+                          ),
+                        );
+
+                        if (confirmed == true) {
+                          if (category.isBuiltIn) {
+                            // Mark built-in category as deleted instead of removing it
+                            downloaderCfg.update((val) {
+                              category.isDeleted = true;
+                            });
+                          } else {
+                            // Remove custom categories completely
+                            downloaderCfg.update((val) {
+                              val!.extra.downloadCategories = val
+                                  .extra.downloadCategories
+                                  .where((c) => c != category)
+                                  .toList();
+                            });
+                          }
+                          debounceSave();
+                        }
+                      },
+                    ),
+                  ],
+                ),
+              );
+            }),
+            Padding(
+              padding: const EdgeInsets.only(top: 8),
+              child: OutlinedButton.icon(
+                icon: const Icon(Icons.add, size: 18),
+                label: Text('add'.tr),
+                onPressed: () {
+                  _showCategoryDialog(
+                    context,
+                    debounceSave,
+                    downloaderCfg,
+                  );
+                },
+              ),
+            ),
+          ],
+        ),
+      );
+    }
+
     buildBrowserExtension() {
       return ListTile(
           title: Text('browserExtension'.tr),
@@ -896,22 +1036,37 @@ class SettingView extends GetView<SettingController> {
     // advanced config webhook items
     final buildWebhook = _buildConfigItem(
       'webhook',
-      () => 'items'.trParams(
-          {'count': downloaderCfg.value.extra.webhookUrls.length.toString()}),
+      () => downloaderCfg.value.webhook.enable ? 'on'.tr : 'off'.tr,
       (Key key) {
         return Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
+            Row(
+              children: [
+                Text(
+                  'webhookEnable'.tr,
+                  style: Theme.of(Get.context!).textTheme.bodyMedium,
+                ),
+                const Spacer(),
+                Switch(
+                  value: downloaderCfg.value.webhook.enable,
+                  onChanged: (value) {
+                    downloaderCfg.update((val) {
+                      val!.webhook.enable = value;
+                    });
+                    debounceSave();
+                  },
+                ),
+              ],
+            ),
+            _padding,
             Text(
               'webhookDesc'.tr,
               style: Theme.of(Get.context!).textTheme.bodySmall,
             ),
             _padding,
             // List of existing webhook URLs
-            ...downloaderCfg.value.extra.webhookUrls
-                .asMap()
-                .entries
-                .map((entry) {
+            ...downloaderCfg.value.webhook.urls.asMap().entries.map((entry) {
               final index = entry.key;
               final url = entry.value;
               return Padding(
@@ -936,8 +1091,12 @@ class SettingView extends GetView<SettingController> {
                       icon: const Icon(Icons.delete, size: 20),
                       tooltip: 'delete'.tr,
                       onPressed: () async {
-                        downloaderCfg.value.extra.webhookUrls.removeAt(index);
-                        downloaderCfg.refresh();
+                        // Create new list to avoid unmodifiable list error
+                        final urls = List<String>.from(downloaderCfg.value.webhook.urls);
+                        urls.removeAt(index);
+                        downloaderCfg.update((val) {
+                          val!.webhook.urls = urls;
+                        });
                         await debounceSave();
                       },
                     ),
@@ -1018,6 +1177,7 @@ class SettingView extends GetView<SettingController> {
                             child: Column(
                           children: _addDivider([
                             buildDownloadDir(),
+                            buildDownloadCategories(),
                             buildMaxRunning(),
                             buildDefaultDirectDownload(),
                             buildBrowserExtension(),
@@ -1205,12 +1365,16 @@ class SettingView extends GetView<SettingController> {
 
                   saveController.start();
                   try {
+                    // Create new list to avoid unmodifiable list error
+                    final urls = List<String>.from(downloaderCfg.value.webhook.urls);
                     if (isEdit) {
-                      downloaderCfg.value.extra.webhookUrls[index] = url;
+                      urls[index] = url;
                     } else {
-                      downloaderCfg.value.extra.webhookUrls.add(url);
+                      urls.add(url);
                     }
-                    downloaderCfg.refresh();
+                    downloaderCfg.update((val) {
+                      val!.webhook.urls = urls;
+                    });
                     await appController.saveConfig();
                     if (dialogContext.mounted) {
                       Navigator.of(dialogContext).pop();
@@ -1302,6 +1466,88 @@ class SettingView extends GetView<SettingController> {
         return 'themeSystem'.tr;
     }
   }
+
+  void _showCategoryDialog(
+    BuildContext context,
+    Future<bool> Function() debounceSave,
+    Rx<DownloaderConfig> downloaderCfg, {
+    DownloadCategory? category,
+  }) {
+    final isEdit = category != null;
+    final nameController = TextEditingController(
+      text: isEdit ? _getCategoryDisplayName(category) : '',
+    );
+    final pathController = TextEditingController(text: category?.path ?? '');
+
+    showDialog(
+      context: context,
+      builder: (context) => AlertDialog(
+        title: Text(isEdit ? 'edit'.tr : 'add'.tr),
+        content: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            TextField(
+              controller: nameController,
+              decoration: InputDecoration(
+                labelText: 'categoryName'.tr,
+              ),
+            ),
+            const SizedBox(height: 16),
+            DirectorySelector(
+              controller: pathController,
+              showLabel: true,
+              allowEdit: true,
+              showPlaceholderButton: true,
+            ),
+          ],
+        ),
+        actions: [
+          TextButton(
+            onPressed: () => Navigator.of(context).pop(),
+            child: Text('cancel'.tr),
+          ),
+          TextButton(
+            onPressed: () {
+              if (nameController.text.isEmpty || pathController.text.isEmpty) {
+                return;
+              }
+
+              if (isEdit) {
+                // Trigger UI update by wrapping changes in update()
+                downloaderCfg.update((val) {
+                  // If name changed, clear nameKey so it won't be re-translated
+                  final nameChanged =
+                      nameController.text != _getCategoryDisplayName(category);
+                  category.name = nameController.text;
+                  category.path = pathController.text;
+                  if (nameChanged) {
+                    category.nameKey = null;
+                  }
+                  // If editing a deleted built-in category, unmark it as deleted
+                  if (category.isBuiltIn && category.isDeleted) {
+                    category.isDeleted = false;
+                  }
+                });
+              } else {
+                downloaderCfg.update((val) {
+                  val!.extra.downloadCategories = [
+                    ...val.extra.downloadCategories,
+                    DownloadCategory(
+                      name: nameController.text,
+                      path: pathController.text,
+                    ),
+                  ];
+                });
+              }
+              debounceSave();
+              Navigator.of(context).pop();
+            },
+            child: Text('confirm'.tr),
+          ),
+        ],
+      ),
+    );
+  }
 }
 
 enum ProxyModeEnum {
diff --git a/ui/flutter/lib/app/views/directory_selector.dart b/ui/flutter/lib/app/views/directory_selector.dart
index d10f68c..43fef85 100644
--- a/ui/flutter/lib/app/views/directory_selector.dart
+++ b/ui/flutter/lib/app/views/directory_selector.dart
@@ -14,17 +14,86 @@ import '../../util/util.dart';
 
 final deviceInfo = DeviceInfoPlugin();
 
+// Placeholder information for download directory
+class PathPlaceholder {
+  final String placeholder;
+  final String description;
+  final String example;
+
+  const PathPlaceholder({
+    required this.placeholder,
+    required this.description,
+    required this.example,
+  });
+}
+
+// Available placeholders for download directory
+List<PathPlaceholder> getPathPlaceholders() {
+  final now = DateTime.now();
+  final year = now.year.toString();
+  final month = now.month.toString().padLeft(2, '0');
+  final day = now.day.toString().padLeft(2, '0');
+
+  return [
+    PathPlaceholder(
+      placeholder: '%year%',
+      description: 'placeholderYear'.tr,
+      example: year,
+    ),
+    PathPlaceholder(
+      placeholder: '%month%',
+      description: 'placeholderMonth'.tr,
+      example: month,
+    ),
+    PathPlaceholder(
+      placeholder: '%day%',
+      description: 'placeholderDay'.tr,
+      example: day,
+    ),
+    PathPlaceholder(
+      placeholder: '%date%',
+      description: 'placeholderDate'.tr,
+      example: '$year-$month-$day',
+    ),
+  ];
+}
+
+// Render placeholders in a path with actual values
+String renderPathPlaceholders(String path) {
+  if (path.isEmpty) return path;
+
+  final now = DateTime.now();
+  final year = now.year.toString();
+  final month = now.month.toString().padLeft(2, '0');
+  final day = now.day.toString().padLeft(2, '0');
+  final date = '$year-$month-$day';
+
+  return path
+      .replaceAll('%year%', year)
+      .replaceAll('%month%', month)
+      .replaceAll('%day%', day)
+      .replaceAll('%date%', date);
+}
+
 class DirectorySelector extends StatefulWidget {
   final TextEditingController controller;
   final bool showLabel;
   final bool showAndoirdToggle;
+  final bool allowEdit;
+  final bool showPlaceholderButton;
+  final VoidCallback? onEditComplete;
+  final bool showRenderedPlaceholders;
 
-  const DirectorySelector(
-      {Key? key,
-      required this.controller,
-      this.showLabel = true,
-      this.showAndoirdToggle = false})
-      : super(key: key);
+  const DirectorySelector({
+    Key? key,
+    required this.controller,
+    this.showLabel = true,
+    this.showAndoirdToggle = false,
+    this.allowEdit = false,
+    this.showPlaceholderButton = false,
+    this.onEditComplete,
+    this.showRenderedPlaceholders = false,
+  }) : super(key: key);
 
   @override
   State<DirectorySelector> createState() => _DirectorySelectorState();
@@ -108,22 +177,117 @@ class _DirectorySelectorState extends State<DirectorySelector> {
       return null;
     }
 
+    Widget? buildPlaceholderButton() {
+      if (!widget.showPlaceholderButton) return null;
+
+      return PopupMenuButton<String>(
+        icon: const Icon(Icons.data_object),
+        tooltip: 'insertPlaceholder'.tr,
+        onSelected: (String placeholder) {
+          final currentText = widget.controller.text;
+          final selection = widget.controller.selection;
+          final cursorPosition = selection.baseOffset >= 0
+              ? selection.baseOffset
+              : currentText.length;
+
+          final newText = currentText.substring(0, cursorPosition) +
+              placeholder +
+              currentText.substring(cursorPosition);
+          widget.controller.text = newText;
+          widget.controller.selection = TextSelection.fromPosition(
+            TextPosition(offset: cursorPosition + placeholder.length),
+          );
+        },
+        itemBuilder: (BuildContext context) {
+          final placeholders = getPathPlaceholders();
+          return placeholders.map((p) {
+            return PopupMenuItem<String>(
+              value: p.placeholder,
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text(
+                    '${p.placeholder} - ${p.description}',
+                    style: const TextStyle(fontWeight: FontWeight.bold),
+                  ),
+                  Text(
+                    'example'.trParams({'value': p.example}),
+                    style: TextStyle(
+                      fontSize: 12,
+                      color: Theme.of(context).hintColor,
+                    ),
+                  ),
+                ],
+              ),
+            );
+          }).toList();
+        },
+      );
+    }
+
     return Row(
       children: [
         Expanded(
-            child: TextFormField(
-          readOnly: Util.isWeb() ? false : true,
-          controller: widget.controller,
-          decoration: widget.showLabel
-              ? InputDecoration(
-                  labelText: 'downloadDir'.tr,
-                )
-              : null,
-          validator: (v) {
-            return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr;
+            child: ValueListenableBuilder<TextEditingValue>(
+          valueListenable: widget.controller,
+          builder: (context, value, child) {
+            Widget? suffix;
+            if (widget.showRenderedPlaceholders && value.text.contains('%')) {
+              final renderedPath = renderPathPlaceholders(value.text);
+              // Show rendered path as a chip/badge in the input field
+              suffix = Container(
+                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+                margin: const EdgeInsets.only(right: 8),
+                decoration: BoxDecoration(
+                  color: Colors.blue[50],
+                  borderRadius: BorderRadius.circular(4),
+                  border: Border.all(color: Colors.blue[200]!),
+                ),
+                child: Row(
+                  mainAxisSize: MainAxisSize.min,
+                  children: [
+                    Icon(Icons.arrow_forward, size: 14, color: Colors.blue[700]),
+                    const SizedBox(width: 4),
+                    Flexible(
+                      child: Text(
+                        renderedPath,
+                        style: TextStyle(
+                          color: Colors.blue[700],
+                          fontSize: 12,
+                          fontWeight: FontWeight.w500,
+                        ),
+                        overflow: TextOverflow.ellipsis,
+                      ),
+                    ),
+                  ],
+                ),
+              );
+            }
+            
+            return TextFormField(
+              readOnly: widget.allowEdit ? false : (Util.isWeb() ? false : true),
+              controller: widget.controller,
+              decoration: widget.showLabel
+                  ? InputDecoration(
+                      labelText: 'downloadDir'.tr,
+                      suffix: suffix,
+                    )
+                  : InputDecoration(
+                      suffix: suffix,
+                    ),
+              validator: (v) {
+                return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr;
+              },
+              onEditingComplete: widget.onEditComplete,
+              onTapOutside: (event) {
+                // Call onEditComplete when user taps outside the field
+                widget.onEditComplete?.call();
+              },
+            );
           },
         )),
-        buildSelectWidget()
+        buildSelectWidget(),
+        buildPlaceholderButton(),
       ].where((e) => e != null).map((e) => e!).toList(),
     );
   }
diff --git a/ui/flutter/lib/i18n/langs/de_de.dart b/ui/flutter/lib/i18n/langs/de_de.dart
index cc2b648..ae977e8 100644
--- a/ui/flutter/lib/i18n/langs/de_de.dart
+++ b/ui/flutter/lib/i18n/langs/de_de.dart
@@ -4,6 +4,7 @@ const deDE = {
     'error': 'Fehler',
     'tip': 'Tipp',
     'confirm': 'Bestätigen',
+    'confirmDelete': 'Möchten Sie wirklich löschen?',
     'cancel': 'Abbrechen',
     'on': 'An',
     'off': 'Aus',
@@ -21,10 +22,13 @@ const deDE = {
     'followSettings': 'Einstellungen Folgen',
     'downloadLink': 'Download Link',
     'downloadLinkValid': 'Bitte geben Sie den Download-Link ein',
-    'downloadLinkHit': 'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',
-    'downloadLinkHitDesktop': ', oder ziehen Sie die Torrent-Datei direkt hierher',
+    'downloadLinkHit':
+        'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',
+    'downloadLinkHitDesktop':
+        ', oder ziehen Sie die Torrent-Datei direkt hierher',
     'download': 'Download',
-    'noFileSelected': 'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',
+    'noFileSelected':
+        'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',
     'noStoragePermission': 'Speicherberechtigung erforderlich',
     'selectFile': 'Datei auswählen',
     'rename': 'Umbenennen',
@@ -45,7 +49,8 @@ const deDE = {
     'updateDaily': 'Tägliche Aktualisierung',
     'lastUpdate': 'Letzte Aktualisierung: @time',
     'addTracker': 'Tracker hinzufügen',
-    'addTrackerHit': 'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',
+    'addTrackerHit':
+        'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',
     'ui': 'Benutzeroberfläche',
     'theme': 'Thema',
     'themeSystem': 'System',
@@ -122,8 +127,24 @@ const deDE = {
     'username_required': 'Bitte geben Sie Ihren Benutzernamen ein',
     'password_required': 'Bitte geben Sie Ihr Passwort ein',
     'login_success': 'Anmeldung erfolgreich',
-    'login_failed': 'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',
+    'login_failed':
+        'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',
     'login_failed_network':
         'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung',
+    'insertPlaceholder': 'Platzhalter einfügen',
+    'placeholderYear': 'Aktuelles Jahr',
+    'placeholderMonth': 'Aktueller Monat (01-12)',
+    'placeholderDay': 'Aktueller Tag (01-31)',
+    'placeholderDate': 'Vollständiges Datum (JJJJ-MM-TT)',
+    'example': 'z.B. @value',
+    'downloadCategories': 'Download-Kategorien',
+    'categoryMusic': 'Musik',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Dokument',
+    'categoryProgram': 'Programm',
+    'categoryName': 'Kategoriename',
+    'categoryPath': 'Kategoriepfad',
+    'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
+    'selectCategory': 'Kategorie auswählen',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart
index f18a6e8..1fb9901 100644
--- a/ui/flutter/lib/i18n/langs/en_us.dart
+++ b/ui/flutter/lib/i18n/langs/en_us.dart
@@ -4,6 +4,7 @@ const enUS = {
     'error': 'Error',
     'tip': 'Tip',
     'confirm': 'Confirm',
+    'confirmDelete': 'Are you sure you want to delete?',
     'cancel': 'Cancel',
     'on': 'On',
     'off': 'Off',
@@ -66,6 +67,7 @@ const enUS = {
     'developer': 'Developer',
     'logDirectory': 'Log Directory',
     'webhook': 'Webhook',
+    'webhookEnable': 'Enable Webhook',
     'webhookDesc': 'Send HTTP POST notifications when tasks complete or fail',
     'webhookUrlHint': 'Enter webhook URL',
     'webhookTest': 'Test',
@@ -136,5 +138,20 @@ const enUS = {
     'login_failed': 'Login failed, please check your username and password',
     'login_failed_network':
         'Login failed, please check your network connection',
+    'insertPlaceholder': 'Insert Placeholder',
+    'placeholderYear': 'Current year',
+    'placeholderMonth': 'Current month (01-12)',
+    'placeholderDay': 'Current day (01-31)',
+    'placeholderDate': 'Full date (YYYY-MM-DD)',
+    'example': 'e.g. @value',
+    'downloadCategories': 'Download Categories',
+    'categoryMusic': 'Music',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Document',
+    'categoryProgram': 'Program',
+    'categoryName': 'Category Name',
+    'categoryPath': 'Category Path',
+    'builtInCategory': 'Built-in category cannot be deleted',
+    'selectCategory': 'Select Category',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/es_es.dart b/ui/flutter/lib/i18n/langs/es_es.dart
index 8fa8961..2de5c01 100644
--- a/ui/flutter/lib/i18n/langs/es_es.dart
+++ b/ui/flutter/lib/i18n/langs/es_es.dart
@@ -4,6 +4,7 @@ const esES = {
     'error': 'Error',
     'tip': 'Consejo',
     'confirm': 'Confirmar',
+    'confirmDelete': '¿Está seguro de que desea eliminar?',
     'cancel': 'Cancelar',
     'on': 'Activado',
     'off': 'Desactivado',
@@ -21,9 +22,11 @@ const esES = {
     'downloadLinkValid': 'Por favor, introduce el enlace de descarga',
     'downloadLinkHit':
         'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append',
-    'downloadLinkHitDesktop': ', o arrastra el archivo torrent aquí directamente',
+    'downloadLinkHitDesktop':
+        ', o arrastra el archivo torrent aquí directamente',
     'download': 'Descargar',
-    'noFileSelected': 'Por favor, selecciona al menos un archivo para continuar.',
+    'noFileSelected':
+        'Por favor, selecciona al menos un archivo para continuar.',
     'noStoragePermission': 'Se requiere permiso de almacenamiento',
     'selectFile': 'Seleccionar Archivo',
     'rename': 'Renombrar',
@@ -43,7 +46,8 @@ const esES = {
     'updateDaily': 'Actualizar diariamente',
     'lastUpdate': 'Última actualización: @time',
     'addTracker': 'Añadir Tracker',
-    'addTrackerHit': 'Por favor, introduce la URL del servidor tracker, una por línea',
+    'addTrackerHit':
+        'Por favor, introduce la URL del servidor tracker, una por línea',
     'ui': 'Interfaz',
     'theme': 'Tema',
     'themeSystem': 'Sistema',
@@ -104,5 +108,20 @@ const esES = {
     'taskUrl': 'URL de la Tarea',
     'downloadPath': 'Ruta de Descarga',
     'skipVerifyCert': 'Omitir Verificación de Certificado',
+    'insertPlaceholder': 'Insertar marcador de posición',
+    'placeholderYear': 'Año actual',
+    'placeholderMonth': 'Mes actual (01-12)',
+    'placeholderDay': 'Día actual (01-31)',
+    'placeholderDate': 'Fecha completa (AAAA-MM-DD)',
+    'example': 'ej. @value',
+    'downloadCategories': 'Categorías de descarga',
+    'categoryMusic': 'Música',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Documento',
+    'categoryProgram': 'Programa',
+    'categoryName': 'Nombre de categoría',
+    'categoryPath': 'Ruta de categoría',
+    'builtInCategory': 'La categoría incorporada no se puede eliminar',
+    'selectCategory': 'Seleccionar categoría',
   },
-};
\ No newline at end of file
+};
diff --git a/ui/flutter/lib/i18n/langs/fa_ir.dart b/ui/flutter/lib/i18n/langs/fa_ir.dart
index 96815ef..06898ea 100644
--- a/ui/flutter/lib/i18n/langs/fa_ir.dart
+++ b/ui/flutter/lib/i18n/langs/fa_ir.dart
@@ -4,6 +4,7 @@ const faIR = {
     'error': 'اررور',
     'tip': 'Tip',
     'confirm': 'تایید',
+    'confirmDelete': 'آیا مطمئن هستید که می خواهید حذف کنید؟',
     'cancel': 'انصراف',
     'selectAll': 'انتخاب همه',
     'task': 'کار',
@@ -60,7 +61,22 @@ const faIR = {
     'thanks': 'تشکر',
     'thanksDesc':
         'از تمامی افرادی که در ساخت و توسعه‌ی جامعه‌ی Gopeed مشارکت کرده‌اند، سپاسگزاری می‌کنیم!',
-    'browserExtension': 'فایل های دانلود شده را نگه دارد',
+    'browserExtension': 'افزونه مرورگر',
     'skipVerifyCert': 'رد کردن تأیید گواهینامه',
+    'insertPlaceholder': 'درج نگهدارنده',
+    'placeholderYear': 'سال جاری',
+    'placeholderMonth': 'ماه جاری (01-12)',
+    'placeholderDay': 'روز جاری (01-31)',
+    'placeholderDate': 'تاریخ کامل (YYYY-MM-DD)',
+    'example': 'مثلا @value',
+    'downloadCategories': 'دسته‌بندی‌های دانلود',
+    'categoryMusic': 'موسیقی',
+    'categoryVideo': 'ویدیو',
+    'categoryDocument': 'سند',
+    'categoryProgram': 'برنامه',
+    'categoryName': 'نام دسته‌بندی',
+    'categoryPath': 'مسیر دسته‌بندی',
+    'builtInCategory': 'دسته‌بندی داخلی قابل حذف نیست',
+    'selectCategory': 'انتخاب دسته‌بندی',
   }
 };
diff --git a/ui/flutter/lib/i18n/langs/fr_fr.dart b/ui/flutter/lib/i18n/langs/fr_fr.dart
index 75f630f..d20cde6 100644
--- a/ui/flutter/lib/i18n/langs/fr_fr.dart
+++ b/ui/flutter/lib/i18n/langs/fr_fr.dart
@@ -4,6 +4,7 @@ const frFR = {
     'error': 'Erreur',
     'tip': 'Conseil',
     'confirm': 'Confirmer',
+    'confirmDelete': 'Êtes-vous sûr de vouloir supprimer ?',
     'cancel': 'Annuler',
     'on': 'Activé',
     'off': 'Désactivé',
@@ -19,10 +20,12 @@ const frFR = {
     'advancedOptions': 'Options avancées',
     'downloadLink': 'Lien de téléchargement',
     'downloadLinkValid': 'Veuillez entrer le lien de téléchargement',
-    'downloadLinkHit': 'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge',
+    'downloadLinkHit':
+        'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge',
     'downloadLinkHitDesktop': ', ou glissez directement le fichier torrent ici',
     'download': 'Télécharger',
-    'noFileSelected': 'Veuillez sélectionner au moins un fichier pour continuer.',
+    'noFileSelected':
+        'Veuillez sélectionner au moins un fichier pour continuer.',
     'noStoragePermission': 'Permission de stockage requise',
     'selectFile': 'Sélectionner un fichier',
     'rename': 'Renommer',
@@ -32,16 +35,19 @@ const frFR = {
     'downloadDir': 'Répertoire de téléchargement',
     'downloadDirValid': 'Veuillez sélectionner le répertoire de téléchargement',
     'connections': 'Connexions',
-    'useServerCtime': 'Utiliser l\'heure du serveur pour la création de fichier',
+    'useServerCtime':
+        'Utiliser l\'heure du serveur pour la création de fichier',
     'maxRunning': 'Tâches en cours maximum',
     'items': '@count éléments',
     'subscribeTracker': 'S\'abonner au tracker',
-    'subscribeFail': 'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard',
+    'subscribeFail':
+        'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard',
     'update': 'Mettre à jour',
     'updateDaily': 'Mettre à jour quotidiennement',
     'lastUpdate': 'Dernière mise à jour : @time',
     'addTracker': 'Ajouter un tracker',
-    'addTrackerHit': 'Veuillez entrer l\'URL du serveur de tracker, une par ligne',
+    'addTrackerHit':
+        'Veuillez entrer l\'URL du serveur de tracker, une par ligne',
     'ui': 'Interface utilisateur',
     'theme': 'Thème',
     'themeSystem': 'Système',
@@ -89,9 +95,25 @@ const frFR = {
     'username': 'Nom d\'utilisateur',
     'password': 'Mot de passe',
     'thanks': 'Merci',
-    'thanksDesc': 'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !',
+    'thanksDesc':
+        'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !',
     'browserExtension': 'Extension du navigateur',
     'launchAtStartup': 'Lancer au démarrage',
-    'skipVerifyCert': 'Bypass Verifikasi Sertifikat',
+    'skipVerifyCert': 'Ignorer la vérification du certificat',
+    'insertPlaceholder': 'Insérer un espace réservé',
+    'placeholderYear': 'Année en cours',
+    'placeholderMonth': 'Mois en cours (01-12)',
+    'placeholderDay': 'Jour en cours (01-31)',
+    'placeholderDate': 'Date complète (AAAA-MM-JJ)',
+    'example': 'ex. @value',
+    'downloadCategories': 'Catégories de téléchargement',
+    'categoryMusic': 'Musique',
+    'categoryVideo': 'Vidéo',
+    'categoryDocument': 'Document',
+    'categoryProgram': 'Programme',
+    'categoryName': 'Nom de la catégorie',
+    'categoryPath': 'Chemin de la catégorie',
+    'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
+    'selectCategory': 'Sélectionner une catégorie',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/hu_hu.dart b/ui/flutter/lib/i18n/langs/hu_hu.dart
index 7f5cc56..84c95f6 100644
--- a/ui/flutter/lib/i18n/langs/hu_hu.dart
+++ b/ui/flutter/lib/i18n/langs/hu_hu.dart
@@ -4,6 +4,7 @@ const huHU = {
     'error': 'Hiba',
     'tip': 'Tipp',
     'confirm': 'Megerősít',
+    'confirmDelete': 'Biztosan törölni szeretné?',
     'cancel': 'Mégsem',
     'on': 'Bekapcsolva',
     'off': 'Kikapcsolva',
@@ -21,7 +22,8 @@ const huHU = {
     'followSettings': 'Beállítások követése',
     'downloadLink': 'Letöltési hivatkozás',
     'downloadLinkValid': 'Kérjük, adja meg a letöltési hivatkozást',
-    'downloadLinkHit': 'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append',
+    'downloadLinkHit':
+        'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append',
     'downloadLinkHitDesktop': ', vagy húzza ide a torrent fájlt közvetlenül',
     'download': 'Letöltés',
     'noFileSelected': 'Kérjük, válasszon ki legalább egy fájlt a folytatáshoz.',
@@ -36,7 +38,8 @@ const huHU = {
     'connections': 'Kapcsolatok',
     'useServerCtime': 'Használja a szerveridőt a fájl létrehozásához',
     'maxRunning': 'Maximális futó feladatok',
-    'defaultDirectDownload': 'Alapértelmezettként ellenőrizze a közvetlen letöltést',
+    'defaultDirectDownload':
+        'Alapértelmezettként ellenőrizze a közvetlen letöltést',
     'items': '@count elem',
     'subscribeTracker': 'Nyomkövető előfizetése',
     'subscribeFail':
@@ -45,7 +48,8 @@ const huHU = {
     'updateDaily': 'Napi frissítés',
     'lastUpdate': 'Utolsó frissítés: @time',
     'addTracker': 'Nyomkövető hozzáadása',
-    'addTrackerHit': 'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet',
+    'addTrackerHit':
+        'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet',
     'ui': 'Felhasználói felület',
     'theme': 'Téma',
     'themeSystem': 'Rendszer',
@@ -60,7 +64,8 @@ const huHU = {
     'apiToken': 'API token',
     'notSet': 'NS',
     'set': 'BEÁLLÍT',
-    'portInUse': 'A [@port] port használatban van, kérjük, változtassa meg a portot',
+    'portInUse':
+        'A [@port] port használatban van, kérjük, változtassa meg a portot',
     'effectAfterRestart': 'Kihatás indítás után',
     'developer': 'Fejlesztő',
     'logDirectory': 'Napló könyvtár',
@@ -118,5 +123,20 @@ const huHU = {
     'fileSelectedSize': 'Méret: ',
     'httpHeaderName': 'Fejléc neve',
     'httpHeaderValue': 'Fejléc értéke',
+    'insertPlaceholder': 'Helyőrző beszúrása',
+    'placeholderYear': 'Aktuális év',
+    'placeholderMonth': 'Aktuális hónap (01-12)',
+    'placeholderDay': 'Aktuális nap (01-31)',
+    'placeholderDate': 'Teljes dátum (ÉÉÉÉ-HH-NN)',
+    'example': 'pl. @value',
+    'downloadCategories': 'Letöltési kategóriák',
+    'categoryMusic': 'Zene',
+    'categoryVideo': 'Videó',
+    'categoryDocument': 'Dokumentum',
+    'categoryProgram': 'Program',
+    'categoryName': 'Kategória neve',
+    'categoryPath': 'Kategória útvonala',
+    'builtInCategory': 'A beépített kategória nem törölhető',
+    'selectCategory': 'Kategória kiválasztása',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/id_id.dart b/ui/flutter/lib/i18n/langs/id_id.dart
index 3b11b3a..4af06c6 100644
--- a/ui/flutter/lib/i18n/langs/id_id.dart
+++ b/ui/flutter/lib/i18n/langs/id_id.dart
@@ -4,6 +4,7 @@ const idID = {
     'error': 'Kesalahan',
     'tip': 'Tips',
     'confirm': 'Konfirmasi',
+    'confirmDelete': 'Apakah Anda yakin ingin menghapus?',
     'cancel': 'Batal',
     'on': 'Aktif',
     'off': 'Nonaktif',
@@ -22,7 +23,8 @@ const idID = {
     'downloadLinkValid': 'Silakan masukkan tautan unduhan',
     'downloadLinkHit':
         'Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append',
-    'downloadLinkHitDesktop': ', atau seret file torrent ke sini secara langsung',
+    'downloadLinkHitDesktop':
+        ', atau seret file torrent ke sini secara langsung',
     'download': 'Download',
     'noFileSelected': 'Silakan pilih setidaknya satu file untuk melanjutkan.',
     'noStoragePermission': 'Izin penyimpanan diperlukan',
@@ -108,5 +110,20 @@ const idID = {
     'taskUrl': 'URL Tugas',
     'downloadPath': 'Direktori Unduhan',
     'skipVerifyCert': 'Lupakan verifikasi sertifikat',
+    'insertPlaceholder': 'Sisipkan placeholder',
+    'placeholderYear': 'Tahun saat ini',
+    'placeholderMonth': 'Bulan saat ini (01-12)',
+    'placeholderDay': 'Hari saat ini (01-31)',
+    'placeholderDate': 'Tanggal lengkap (YYYY-MM-DD)',
+    'example': 'cth. @value',
+    'downloadCategories': 'Kategori Unduhan',
+    'categoryMusic': 'Musik',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Dokumen',
+    'categoryProgram': 'Program',
+    'categoryName': 'Nama Kategori',
+    'categoryPath': 'Path Kategori',
+    'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
+    'selectCategory': 'Pilih Kategori',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/it_it.dart b/ui/flutter/lib/i18n/langs/it_it.dart
index a6e9ffb..7905c81 100644
--- a/ui/flutter/lib/i18n/langs/it_it.dart
+++ b/ui/flutter/lib/i18n/langs/it_it.dart
@@ -4,6 +4,7 @@ const itIT = {
     'error': 'Errore',
     'tip': 'Suggerimento',
     'confirm': 'Conferma',
+    'confirmDelete': 'Sei sicuro di voler eliminare?',
     'cancel': 'Annullla',
     'on': 'Acceso',
     'off': 'Spento',
@@ -97,5 +98,20 @@ const itIT = {
     'browserExtension': 'Estensione del browser',
     'launchAtStartup': "Lancia all'avvio",
     'skipVerifyCert': 'Salta la verifica del certificato',
+    'insertPlaceholder': 'Inserisci segnaposto',
+    'placeholderYear': 'Anno corrente',
+    'placeholderMonth': 'Mese corrente (01-12)',
+    'placeholderDay': 'Giorno corrente (01-31)',
+    'placeholderDate': 'Data completa (AAAA-MM-GG)',
+    'example': 'es. @value',
+    'downloadCategories': 'Categorie di download',
+    'categoryMusic': 'Musica',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Documento',
+    'categoryProgram': 'Programma',
+    'categoryName': 'Nome categoria',
+    'categoryPath': 'Percorso categoria',
+    'builtInCategory': 'La categoria predefinita non può essere eliminata',
+    'selectCategory': 'Seleziona categoria',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/ja_jp.dart b/ui/flutter/lib/i18n/langs/ja_jp.dart
index 26d999d..008902c 100644
--- a/ui/flutter/lib/i18n/langs/ja_jp.dart
+++ b/ui/flutter/lib/i18n/langs/ja_jp.dart
@@ -4,6 +4,7 @@ const jaJP = {
     'error': 'エラー',
     'tip': 'ヒント',
     'confirm': '確認',
+    'confirmDelete': '削除してもよろしいですか？',
     'cancel': 'キャンセル',
     'selectAll': 'すべてを選択',
     'task': 'タスク',
@@ -63,5 +64,20 @@ const jaJP = {
     'thanksDesc': 'Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します！',
     'browserExtension': 'ブラウザ拡張機能',
     'skipVerifyCert': '証明書の検証をスキップ',
+    'insertPlaceholder': 'プレースホルダーを挿入',
+    'placeholderYear': '現在の年',
+    'placeholderMonth': '現在の月 (01-12)',
+    'placeholderDay': '現在の日 (01-31)',
+    'placeholderDate': '完全な日付 (年-月-日)',
+    'example': '例: @value',
+    'downloadCategories': 'ダウンロードカテゴリ',
+    'categoryMusic': '音楽',
+    'categoryVideo': '動画',
+    'categoryDocument': 'ドキュメント',
+    'categoryProgram': 'プログラム',
+    'categoryName': 'カテゴリ名',
+    'categoryPath': 'カテゴリパス',
+    'builtInCategory': '内蔵カテゴリは削除できません',
+    'selectCategory': 'カテゴリを選択',
   }
 };
diff --git a/ui/flutter/lib/i18n/langs/pl_pl.dart b/ui/flutter/lib/i18n/langs/pl_pl.dart
index c346dde..bfb5d20 100644
--- a/ui/flutter/lib/i18n/langs/pl_pl.dart
+++ b/ui/flutter/lib/i18n/langs/pl_pl.dart
@@ -4,6 +4,7 @@ const plPL = {
     'error': 'Błąd',
     'tip': 'Tip',
     'confirm': 'Potwierdź',
+    'confirmDelete': 'Czy na pewno chcesz usunąć?',
     'cancel': 'Anuluj',
     'on': 'Włącz',
     'off': 'Wyłącz',
@@ -95,5 +96,20 @@ const plPL = {
     'browserExtension': 'Wtyczka dla przeglądarki',
     'launchAtStartup': 'Uruchom na starcie',
     'skipVerifyCert': 'Pomiń weryfikację certyfikatu',
+    'insertPlaceholder': 'Wstaw placeholder',
+    'placeholderYear': 'Bieżący rok',
+    'placeholderMonth': 'Bieżący miesiąc (01-12)',
+    'placeholderDay': 'Bieżący dzień (01-31)',
+    'placeholderDate': 'Pełna data (RRRR-MM-DD)',
+    'example': 'np. @value',
+    'downloadCategories': 'Kategorie pobierania',
+    'categoryMusic': 'Muzyka',
+    'categoryVideo': 'Wideo',
+    'categoryDocument': 'Dokument',
+    'categoryProgram': 'Program',
+    'categoryName': 'Nazwa kategorii',
+    'categoryPath': 'Ścieżka kategorii',
+    'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
+    'selectCategory': 'Wybierz kategorię',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/pt_br.dart b/ui/flutter/lib/i18n/langs/pt_br.dart
index f538356..765126b 100644
--- a/ui/flutter/lib/i18n/langs/pt_br.dart
+++ b/ui/flutter/lib/i18n/langs/pt_br.dart
@@ -4,6 +4,7 @@ const ptBR = {
     'error': 'Erro',
     'tip': 'Dica',
     'confirm': 'Confirmar',
+    'confirmDelete': 'Tem certeza de que deseja excluir?',
     'cancel': 'Cancelar',
     'on': 'Ligado',
     'off': 'Desligado',
@@ -21,7 +22,8 @@ const ptBR = {
     'followSettings': 'Follow Settings',
     'downloadLink': 'Link de Download',
     'downloadLinkValid': 'Por favor, insira o link de download',
-    'downloadLinkHit': 'Por favor, insira o link de download, um por linha@append',
+    'downloadLinkHit':
+        'Por favor, insira o link de download, um por linha@append',
     'downloadLinkHitDesktop': ', ou arraste o arquivo torrent aqui',
     'download': 'Download',
     'noFileSelected': 'Por favor, selecione ao menos um arquivo para continuar',
@@ -118,5 +120,20 @@ const ptBR = {
     'fileSelectedSize': 'Tamanho: ',
     'httpHeaderName': 'Nome do Header',
     'httpHeaderValue': 'Valor do Header',
+    'insertPlaceholder': 'Inserir marcador',
+    'placeholderYear': 'Ano atual',
+    'placeholderMonth': 'Mês atual (01-12)',
+    'placeholderDay': 'Dia atual (01-31)',
+    'placeholderDate': 'Data completa (AAAA-MM-DD)',
+    'example': 'ex. @value',
+    'downloadCategories': 'Categorias de Download',
+    'categoryMusic': 'Música',
+    'categoryVideo': 'Vídeo',
+    'categoryDocument': 'Documento',
+    'categoryProgram': 'Programa',
+    'categoryName': 'Nome da Categoria',
+    'categoryPath': 'Caminho da Categoria',
+    'builtInCategory': 'Categoria integrada não pode ser excluída',
+    'selectCategory': 'Selecionar Categoria',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/ru_ru.dart b/ui/flutter/lib/i18n/langs/ru_ru.dart
index 6bb59ce..a80d294 100644
--- a/ui/flutter/lib/i18n/langs/ru_ru.dart
+++ b/ui/flutter/lib/i18n/langs/ru_ru.dart
@@ -4,6 +4,7 @@ const ruRU = {
     'error': 'Ошибка',
     'tip': 'Подсказка',
     'confirm': 'Подтвердить',
+    'confirmDelete': 'Вы уверены, что хотите удалить?',
     'cancel': 'Отмена',
     'on': 'Вкл.',
     'off': 'Откл.',
@@ -119,5 +120,20 @@ const ruRU = {
     'fileSelectedSize': 'Размер: ',
     'httpHeaderName': 'Название заголовка HTTP',
     'httpHeaderValue': 'Значение заголовка HTTP',
+    'insertPlaceholder': 'Вставить заполнитель',
+    'placeholderYear': 'Текущий год',
+    'placeholderMonth': 'Текущий месяц (01-12)',
+    'placeholderDay': 'Текущий день (01-31)',
+    'placeholderDate': 'Полная дата (ГГГГ-ММ-ДД)',
+    'example': 'напр. @value',
+    'downloadCategories': 'Категории загрузки',
+    'categoryMusic': 'Музыка',
+    'categoryVideo': 'Видео',
+    'categoryDocument': 'Документ',
+    'categoryProgram': 'Программа',
+    'categoryName': 'Название категории',
+    'categoryPath': 'Путь категории',
+    'builtInCategory': 'Встроенную категорию нельзя удалить',
+    'selectCategory': 'Выбрать категорию',
   }
 };
diff --git a/ui/flutter/lib/i18n/langs/ta_ta.dart b/ui/flutter/lib/i18n/langs/ta_ta.dart
index b720f91..7ea6bc4 100644
--- a/ui/flutter/lib/i18n/langs/ta_ta.dart
+++ b/ui/flutter/lib/i18n/langs/ta_ta.dart
@@ -4,6 +4,7 @@ const taTA = {
     'error': 'பிழை',
     'tip': 'குறிப்பு',
     'confirm': 'சரி',
+    'confirmDelete': 'நிச்சயமாக நீக்க விரும்புகிறீர்களா?',
     'cancel': 'ரத்துசெய்',
     'on': 'On',
     'off': 'Off',
@@ -21,7 +22,8 @@ const taTA = {
     'downloadLinkValid': 'பதிவிறக்க இணைப்பை உள்ளிடவும்',
     'downloadLinkHit':
         'பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append',
-    'downloadLinkHitDesktop': ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்',
+    'downloadLinkHitDesktop':
+        ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்',
     'download': 'பதிவிறக்கு',
     'noFileSelected': 'குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்.',
     'noStoragePermission': 'சேமிப்பக அனுமதி தேவை',
@@ -96,5 +98,20 @@ const taTA = {
     'browserExtension': 'உலாவி நீட்டிப்பு',
     'launchAtStartup': 'தொடக்கத்தின் போது இயங்கு',
     'skipVerifyCert': 'திருத்தி சான்று சரிபார்க்கவும்',
+    'insertPlaceholder': 'இடங்காட்டி செருக',
+    'placeholderYear': 'தற்போதைய ஆண்டு',
+    'placeholderMonth': 'தற்போதைய மாதம் (01-12)',
+    'placeholderDay': 'தற்போதைய நாள் (01-31)',
+    'placeholderDate': 'முழு தேதி (YYYY-MM-DD)',
+    'example': 'எ.கா. @value',
+    'downloadCategories': 'பதிவிறக்க வகைகள்',
+    'categoryMusic': 'இசை',
+    'categoryVideo': 'காணொளி',
+    'categoryDocument': 'ஆவணம்',
+    'categoryProgram': 'நிரல்',
+    'categoryName': 'வகை பெயர்',
+    'categoryPath': 'வகை பாதை',
+    'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
+    'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/tr_tr.dart b/ui/flutter/lib/i18n/langs/tr_tr.dart
index 3bd994c..a5b0bc4 100644
--- a/ui/flutter/lib/i18n/langs/tr_tr.dart
+++ b/ui/flutter/lib/i18n/langs/tr_tr.dart
@@ -4,6 +4,7 @@ const trTR = {
     'error': 'Hata',
     'tip': 'İpucu',
     'confirm': 'Onayla',
+    'confirmDelete': 'Silmek istediğinizden emin misiniz?',
     'cancel': 'İptal',
     'on': 'Açık',
     'off': 'Kapalı',
@@ -22,7 +23,8 @@ const trTR = {
     'downloadLink': 'İndirme Bağlantısı',
     'downloadLinkValid': 'Lütfen indirme bağlantısını girin',
     'downloadLinkHit': 'Lütfen her satıra bir indirme bağlantısı girin@append',
-    'downloadLinkHitDesktop': ', veya doğrudan torrent dosyasını buraya sürükleyin',
+    'downloadLinkHitDesktop':
+        ', veya doğrudan torrent dosyasını buraya sürükleyin',
     'download': 'İndir',
     'noFileSelected': 'Devam etmek için en az bir dosya seçin.',
     'noStoragePermission': 'Depolama izni gerekli',
@@ -39,12 +41,14 @@ const trTR = {
     'defaultDirectDownload': 'Varsayılan olarak doğrudan indirmeyi seç',
     'items': '@count öğe',
     'subscribeTracker': 'Tracker Aboneliği',
-    'subscribeFail': 'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin',
+    'subscribeFail':
+        'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin',
     'update': 'Güncelle',
     'updateDaily': 'Günlük güncelle',
     'lastUpdate': 'Son güncelleme: @time',
     'addTracker': 'Tracker Ekle',
-    'addTrackerHit': 'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
+    'addTrackerHit':
+        'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
     'ui': 'Arayüz',
     'theme': 'Tema',
     'themeSystem': 'Sistem',
@@ -96,8 +100,8 @@ const trTR = {
     'username': 'Kullanıcı Adı',
     'password': 'Şifre',
     'thanks': 'Teşekkürler',
-    'thanksDesc': 
-    'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
+    'thanksDesc':
+        'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
     'browserExtension': 'Tarayıcı Eklentisi',
     'launchAtStartup': 'Başlangıçta Çalıştır',
     'seedConfig': 'Seed Ayarları',
@@ -117,5 +121,20 @@ const trTR = {
     'fileSelectedSize': 'Boyut: ',
     'httpHeaderName': 'Başlık Adı',
     'httpHeaderValue': 'Başlık Değeri',
+    'insertPlaceholder': 'Yer tutucu ekle',
+    'placeholderYear': 'Geçerli yıl',
+    'placeholderMonth': 'Geçerli ay (01-12)',
+    'placeholderDay': 'Geçerli gün (01-31)',
+    'placeholderDate': 'Tam tarih (YYYY-AA-GG)',
+    'example': 'örn. @value',
+    'downloadCategories': 'İndirme Kategorileri',
+    'categoryMusic': 'Müzik',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Belge',
+    'categoryProgram': 'Program',
+    'categoryName': 'Kategori Adı',
+    'categoryPath': 'Kategori Yolu',
+    'builtInCategory': 'Yerleşik kategori silinemez',
+    'selectCategory': 'Kategori Seç',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/uk_ua.dart b/ui/flutter/lib/i18n/langs/uk_ua.dart
index ee1b525..09bd387 100644
--- a/ui/flutter/lib/i18n/langs/uk_ua.dart
+++ b/ui/flutter/lib/i18n/langs/uk_ua.dart
@@ -4,6 +4,7 @@ const ukUA = {
     'error': 'Помилка',
     'tip': 'Підказка',
     'confirm': 'Підтвердити',
+    'confirmDelete': 'Ви впевнені, що хочете видалити?',
     'cancel': 'Скасувати',
     'on': 'Увімк.',
     'off': 'Вимк.',
@@ -119,5 +120,20 @@ const ukUA = {
     'fileSelectedSize': 'Розмір: ',
     'httpHeaderName': 'Назва заголовка HTTP',
     'httpHeaderValue': 'Значення заголовка HTTP',
+    'insertPlaceholder': 'Вставити заповнювач',
+    'placeholderYear': 'Поточний рік',
+    'placeholderMonth': 'Поточний місяць (01-12)',
+    'placeholderDay': 'Поточний день (01-31)',
+    'placeholderDate': 'Повна дата (РРРР-ММ-ДД)',
+    'example': 'напр. @value',
+    'downloadCategories': 'Категорії завантажень',
+    'categoryMusic': 'Музика',
+    'categoryVideo': 'Відео',
+    'categoryDocument': 'Документ',
+    'categoryProgram': 'Програма',
+    'categoryName': 'Назва категорії',
+    'categoryPath': 'Шлях категорії',
+    'builtInCategory': 'Вбудовану категорію неможливо видалити',
+    'selectCategory': 'Вибрати категорію',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/vi_vn.dart b/ui/flutter/lib/i18n/langs/vi_vn.dart
index 309fdf6..4eae4e0 100644
--- a/ui/flutter/lib/i18n/langs/vi_vn.dart
+++ b/ui/flutter/lib/i18n/langs/vi_vn.dart
@@ -4,6 +4,7 @@ const viVN = {
     'error': 'Lỗi',
     'tip': 'Mẹo',
     'confirm': 'Xác nhận',
+    'confirmDelete': 'Bạn có chắc chắn muốn xóa?',
     'cancel': 'Hủy',
     'on': 'Bật',
     'off': 'Tắt',
@@ -90,5 +91,20 @@ const viVN = {
         'Cảm ơn tất cả những người đóng góp đã giúp xây dựng và phát triển cộng đồng Gopeed!',
     'browserExtension': 'Tiện ích mở rộng trình duyệt',
     'skipVerifyCert': 'Bỏ qua xác thực chứng chỉ',
+    'insertPlaceholder': 'Chèn placeholder',
+    'placeholderYear': 'Năm hiện tại',
+    'placeholderMonth': 'Tháng hiện tại (01-12)',
+    'placeholderDay': 'Ngày hiện tại (01-31)',
+    'placeholderDate': 'Ngày đầy đủ (YYYY-MM-DD)',
+    'example': 'vd. @value',
+    'downloadCategories': 'Danh mục tải xuống',
+    'categoryMusic': 'Nhạc',
+    'categoryVideo': 'Video',
+    'categoryDocument': 'Tài liệu',
+    'categoryProgram': 'Chương trình',
+    'categoryName': 'Tên danh mục',
+    'categoryPath': 'Đường dẫn danh mục',
+    'builtInCategory': 'Không thể xóa danh mục tích hợp',
+    'selectCategory': 'Chọn danh mục',
   },
 };
diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart
index 252ddc0..e778add 100644
--- a/ui/flutter/lib/i18n/langs/zh_cn.dart
+++ b/ui/flutter/lib/i18n/langs/zh_cn.dart
@@ -4,6 +4,7 @@ const zhCN = {
     'error': '错误',
     'tip': '提示',
     'confirm': '确认',
+    'confirmDelete': '确定要删除吗？',
     'cancel': '取消',
     'on': '开启',
     'off': '关闭',
@@ -65,6 +66,7 @@ const zhCN = {
     'developer': '开发者',
     'logDirectory': '日志目录',
     'webhook': 'Webhook 推送',
+    'webhookEnable': '启用 Webhook',
     'webhookDesc': '在任务完成或失败时发送 HTTP POST 通知',
     'webhookUrlHint': '请输入 Webhook URL',
     'webhookTest': '测试',
@@ -133,5 +135,20 @@ const zhCN = {
     'login_success': '登录成功',
     'login_failed': '登录失败，请检查用户名和密码',
     'login_failed_network': '登录失败，请检查网络连接',
+    'insertPlaceholder': '插入占位符',
+    'placeholderYear': '当前年份',
+    'placeholderMonth': '当前月份 (01-12)',
+    'placeholderDay': '当前日期 (01-31)',
+    'placeholderDate': '完整日期 (年-月-日)',
+    'example': '例如 @value',
+    'downloadCategories': '下载目录分类',
+    'categoryMusic': '音乐',
+    'categoryVideo': '视频',
+    'categoryDocument': '文档',
+    'categoryProgram': '程序',
+    'categoryName': '分类名称',
+    'categoryPath': '分类路径',
+    'builtInCategory': '内置分类不能删除',
+    'selectCategory': '选择分类',
   }
 };
diff --git a/ui/flutter/lib/i18n/langs/zh_tw.dart b/ui/flutter/lib/i18n/langs/zh_tw.dart
index 35701c1..51d18a1 100644
--- a/ui/flutter/lib/i18n/langs/zh_tw.dart
+++ b/ui/flutter/lib/i18n/langs/zh_tw.dart
@@ -4,6 +4,7 @@ const zhTW = {
     'error': '錯誤',
     'tip': '提示',
     'confirm': '確認',
+    'confirmDelete': '確定要刪除嗎？',
     'cancel': '取消',
     'on': '開啟',
     'off': '關閉',
@@ -116,5 +117,20 @@ const zhTW = {
     'fileSelectedSize': '大小：',
     'httpHeaderName': '標頭名稱',
     'httpHeaderValue': '標頭值',
+    'insertPlaceholder': '插入佔位符',
+    'placeholderYear': '當前年份',
+    'placeholderMonth': '當前月份 (01-12)',
+    'placeholderDay': '當前日期 (01-31)',
+    'placeholderDate': '完整日期 (年-月-日)',
+    'example': '例如 @value',
+    'downloadCategories': '下載目錄分類',
+    'categoryMusic': '音樂',
+    'categoryVideo': '影片',
+    'categoryDocument': '文件',
+    'categoryProgram': '程式',
+    'categoryName': '分類名稱',
+    'categoryPath': '分類路徑',
+    'builtInCategory': '內建分類無法刪除',
+    'selectCategory': '選擇分類',
   }
 };



Test patch:
diff --git a/pkg/base/model_test.go b/pkg/base/model_test.go
index f218162..c1ba691 100644
--- a/pkg/base/model_test.go
+++ b/pkg/base/model_test.go
@@ -18,6 +18,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
 				MaxRunning:     5,
 				ProtocolConfig: map[string]any{},
 				Proxy:          &DownloaderProxyConfig{},
+				Webhook:        &WebhookConfig{},
 			},
 		},
 		{
@@ -29,6 +30,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
 				MaxRunning:     10,
 				ProtocolConfig: map[string]any{},
 				Proxy:          &DownloaderProxyConfig{},
+				Webhook:        &WebhookConfig{},
 			},
 		},
 		{
@@ -43,7 +45,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
 				ProtocolConfig: map[string]any{
 					"key": "value",
 				},
-				Proxy: &DownloaderProxyConfig{},
+				Proxy:   &DownloaderProxyConfig{},
+				Webhook: &WebhookConfig{},
 			},
 		},
 		{
@@ -59,6 +62,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
 				Proxy: &DownloaderProxyConfig{
 					Enable: true,
 				},
+				Webhook: &WebhookConfig{},
 			},
 		},
 	}
@@ -71,6 +75,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
 				ProtocolConfig: tt.fields.ProtocolConfig,
 				Extra:          tt.fields.Extra,
 				Proxy:          tt.fields.Proxy,
+				Webhook:        tt.fields.Webhook,
 			}
 			if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
 				t.Errorf("Init() = %v, want %v", got, tt.want)
@@ -255,6 +260,7 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
 				ProtocolConfig: tt.fields.ProtocolConfig,
 				Extra:          tt.fields.Extra,
 				Proxy:          tt.fields.Proxy,
+				Webhook:        tt.fields.Webhook,
 			}
 			if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
 				t.Errorf("Merge() = %v, want %v", got, tt.want)
diff --git a/pkg/download/webhook_test.go b/pkg/download/webhook_test.go
index 521bb21..dcfcbb7 100644
--- a/pkg/download/webhook_test.go
+++ b/pkg/download/webhook_test.go
@@ -49,10 +49,10 @@ func TestWebhook_TriggerOnDone(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		// Configure webhook URLs
 		cfg, _ := downloader.GetConfig()
-		if cfg.Extra == nil {
-			cfg.Extra = make(map[string]any)
-		}
-		cfg.Extra["webhookUrls"] = []string{server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Create a mock task
@@ -98,10 +98,10 @@ func TestWebhook_TriggerOnError(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		// Configure webhook URLs
 		cfg, _ := downloader.GetConfig()
-		if cfg.Extra == nil {
-			cfg.Extra = make(map[string]any)
-		}
-		cfg.Extra["webhookUrls"] = []string{server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Create a mock task
@@ -143,10 +143,10 @@ func TestWebhook_SendTestWebhook(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		// Configure webhook URLs
 		cfg, _ := downloader.GetConfig()
-		if cfg.Extra == nil {
-			cfg.Extra = make(map[string]any)
-		}
-		cfg.Extra["webhookUrls"] = []string{server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Send test webhook
@@ -206,10 +206,10 @@ func TestWebhook_MultipleUrls(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		// Configure multiple webhook URLs
 		cfg, _ := downloader.GetConfig()
-		if cfg.Extra == nil {
-			cfg.Extra = make(map[string]any)
-		}
-		cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server1.URL, server2.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Create a mock task
@@ -239,10 +239,10 @@ func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		// Configure webhook URLs
 		cfg, _ := downloader.GetConfig()
-		if cfg.Extra == nil {
-			cfg.Extra = make(map[string]any)
-		}
-		cfg.Extra["webhookUrls"] = []string{server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Send test webhook - should fail with non-200 status
@@ -263,10 +263,10 @@ func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		// Configure webhook URLs
 		cfg, _ := downloader.GetConfig()
-		if cfg.Extra == nil {
-			cfg.Extra = make(map[string]any)
-		}
-		cfg.Extra["webhookUrls"] = []string{server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Send test webhook - should fail with 201 status (only 200 is success)
@@ -333,7 +333,7 @@ func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
 func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = nil
+		cfg.Webhook = nil
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
@@ -346,8 +346,7 @@ func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
 func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["otherKey"] = "value"
+		cfg.Webhook = &base.WebhookConfig{Enable: true}  // No URLs set
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
@@ -360,8 +359,10 @@ func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
 func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []string{"http://example.com", "http://example2.com"}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{"http://example.com", "http://example2.com"},
+	}
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
@@ -377,8 +378,10 @@ func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
 func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", "http://example2.com"}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{"http://example.com", "http://example2.com"},
+	}
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
@@ -395,7 +398,7 @@ func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
 		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []string{}
+		cfg.Webhook.URLs = []string{}
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
@@ -408,8 +411,10 @@ func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
 func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []interface{}{"", "", ""}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{"", "", ""},
+	}
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
@@ -422,13 +427,15 @@ func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
 func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", 123, "http://example2.com", nil, ""}
+		cfg.Webhook = &base.WebhookConfig{
+			Enable: true,
+			URLs:   []string{"http://example.com", "", "http://example2.com", ""},
+		}
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
 		if len(urls) != 2 {
-			t.Errorf("Expected 2 valid URLs (ignoring non-strings and empty), got %d: %v", len(urls), urls)
+			t.Errorf("Expected 2 valid URLs (ignoring empty strings), got %d: %v", len(urls), urls)
 		}
 		if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
 			t.Errorf("URLs don't match expected values: %v", urls)
@@ -439,13 +446,28 @@ func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
 func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = "not-a-slice"
+		cfg.Webhook = &base.WebhookConfig{Enable: false}  // Disabled webhook
 		downloader.PutConfig(cfg)
 
 		urls := downloader.getWebhookUrls()
 		if urls != nil {
-			t.Errorf("Expected nil for invalid type, got %v", urls)
+			t.Errorf("Expected nil for disabled webhook, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Webhook = &base.WebhookConfig{
+			Enable: false,
+			URLs:   []string{"http://example.com"},
+		}
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil for disabled webhook even with URLs, got %v", urls)
 		}
 	})
 }
@@ -617,8 +639,10 @@ func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
 
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []string{server.URL, "", server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL, "", server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		task := NewTask()
@@ -650,8 +674,10 @@ func TestWebhook_WebhookDataStructure(t *testing.T) {
 
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []string{server.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		task := NewTask()
@@ -692,7 +718,7 @@ func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
 		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []string{}
+		cfg.Webhook.URLs = []string{}
 		downloader.PutConfig(cfg)
 
 		err := downloader.SendTestWebhook()
@@ -717,8 +743,10 @@ func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
 
 	setupWebhookTest(t, func(downloader *Downloader) {
 		cfg, _ := downloader.GetConfig()
-		cfg.Extra = make(map[string]any)
-		cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL}
+		cfg.Webhook = &base.WebhookConfig{
+		Enable: true,
+		URLs: []string{server1.URL, server2.URL},
+	}
 		downloader.PutConfig(cfg)
 
 		// Should fail because server2 returns 500
diff --git a/pkg/util/path_test.go b/pkg/util/path_test.go
index 3f5cf96..14760fd 100644
--- a/pkg/util/path_test.go
+++ b/pkg/util/path_test.go
@@ -1,8 +1,11 @@
 package util
 
 import (
+	"fmt"
 	"os"
+	"strings"
 	"testing"
+	"time"
 )
 
 func TestDir(t *testing.T) {
@@ -240,3 +243,75 @@ func TestReplaceInvalidFilename(t *testing.T) {
 		})
 	}
 }
+
+func TestReplacePathPlaceholders(t *testing.T) {
+	now := time.Now()
+	year := fmt.Sprintf("%d", now.Year())
+	month := fmt.Sprintf("%02d", now.Month())
+	day := fmt.Sprintf("%02d", now.Day())
+	date := fmt.Sprintf("%s-%s-%s", year, month, day)
+
+	tests := []struct {
+		name string
+		path string
+		want string
+	}{
+		{
+			name: "empty path",
+			path: "",
+			want: "",
+		},
+		{
+			name: "no placeholders",
+			path: "/home/user/Downloads",
+			want: "/home/user/Downloads",
+		},
+		{
+			name: "year placeholder",
+			path: "/Downloads/%year%",
+			want: "/Downloads/" + year,
+		},
+		{
+			name: "month placeholder",
+			path: "/Downloads/%month%",
+			want: "/Downloads/" + month,
+		},
+		{
+			name: "day placeholder",
+			path: "/Downloads/%day%",
+			want: "/Downloads/" + day,
+		},
+		{
+			name: "date placeholder",
+			path: "/Downloads/%date%",
+			want: "/Downloads/" + date,
+		},
+		{
+			name: "multiple placeholders",
+			path: "/Downloads/%year%-%month%",
+			want: "/Downloads/" + year + "-" + month,
+		},
+		{
+			name: "mixed path with placeholders",
+			path: "/home/user/Downloads/%year%/%month%/%day%",
+			want: "/home/user/Downloads/" + year + "/" + month + "/" + day,
+		},
+		{
+			name: "windows style path",
+			path: "D:\\Downloads\\%year%-%month%",
+			want: "D:\\Downloads\\" + year + "-" + month,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := ReplacePathPlaceholders(tt.path)
+			if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") {
+				t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year)
+			}
+			if got != tt.want {
+				t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}