From 2bc460c609c112a3994f5d807c9ab76655e6b952 Mon Sep 17 00:00:00 2001 From: krau <71133316+krau@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:39:14 +0800 Subject: [PATCH] feat: add preset rule import functionality and update related messages --- client/bot/handlers/rule.go | 41 +++++++++++++++++ client/bot/handlers/utils/msgelem/rule.go | 2 + common/i18n/i18nk/keys.go | 8 +++- common/i18n/locale/en.yaml | 4 ++ common/i18n/locale/zh-Hans.yaml | 4 ++ pkg/rule/preset.go | 55 +++++++++++++++++++++++ pkg/rule/preset_test.go | 55 +++++++++++++++++++++++ 7 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 pkg/rule/preset.go create mode 100644 pkg/rule/preset_test.go diff --git a/client/bot/handlers/rule.go b/client/bot/handlers/rule.go index bf3f10a..7d7bf11 100644 --- a/client/bot/handlers/rule.go +++ b/client/bot/handlers/rule.go @@ -13,6 +13,7 @@ import ( "github.com/krau/SaveAny-Bot/common/i18n" "github.com/krau/SaveAny-Bot/common/i18n/i18nk" "github.com/krau/SaveAny-Bot/common/utils/strutil" + "github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/database" "github.com/krau/SaveAny-Bot/pkg/rule" ) @@ -84,6 +85,46 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error { return dispatcher.EndGroups } ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoCreateRuleSuccess, nil)), nil) + case "preset": + // /rule preset [base_path] + if len(args) < 3 { + ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil) + return dispatcher.EndGroups + } + storageName := args[2] + if !config.C().HasStorage(user.ChatID, storageName) { + ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorStorageNotFound, map[string]any{ + "Storage": storageName, + })), nil) + return dispatcher.EndGroups + } + basePath := "" + if len(args) >= 4 { + basePath = args[3] + } + presets := rule.PresetCategories(basePath) + imported := 0 + for _, p := range presets { + rd := &database.Rule{ + Type: rule.FileNameRegex.String(), + Data: p.Regex, + StorageName: storageName, + DirPath: p.Dir, + UserID: user.ID, + } + if err := database.CreateRule(ctx, rd); err != nil { + logger.Errorf("failed to create preset rule %s: %s", p.Name, err) + continue + } + imported++ + } + if imported == 0 { + ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorCreateRuleFailed, nil)), nil) + return dispatcher.EndGroups + } + ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoPresetImported, map[string]any{ + "Count": imported, + })), nil) case "del": // /rule del if len(args) < 3 { diff --git a/client/bot/handlers/utils/msgelem/rule.go b/client/bot/handlers/utils/msgelem/rule.go index 6fa9b63..6c1f6ee 100644 --- a/client/bot/handlers/utils/msgelem/rule.go +++ b/client/bot/handlers/utils/msgelem/rule.go @@ -24,6 +24,8 @@ func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledT styling.Plain(i18n.T(i18nk.BotMsgRuleHelpSwitchSuffix, nil)), styling.Code("add"), styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAddSuffix, nil)), + styling.Code("preset"), + styling.Plain(i18n.T(i18nk.BotMsgRuleHelpPresetSuffix, nil)), styling.Code("del"), styling.Plain(i18n.T(i18nk.BotMsgRuleHelpDelSuffix, nil)), styling.Plain(i18n.T(i18nk.BotMsgRuleHelpExistingRulesPrefix, nil)), diff --git a/common/i18n/i18nk/keys.go b/common/i18n/i18nk/keys.go index 157544c..cdf0e5f 100644 --- a/common/i18n/i18nk/keys.go +++ b/common/i18n/i18nk/keys.go @@ -84,8 +84,8 @@ const ( BotMsgCommonPromptSelectDefaultDir Key = "bot.msg.common.prompt_select_default_dir" BotMsgCommonPromptSelectDefaultStorage Key = "bot.msg.common.prompt_select_default_storage" BotMsgCommonPromptSelectDir Key = "bot.msg.common.prompt_select_dir" - BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy" BotMsgConfigButtonConflictStrategy Key = "bot.msg.config.button_conflict_strategy" + BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy" BotMsgConfigConflictStrategyAsk Key = "bot.msg.config.conflict_strategy_ask" BotMsgConfigConflictStrategyOverwrite Key = "bot.msg.config.conflict_strategy_overwrite" BotMsgConfigConflictStrategyRename Key = "bot.msg.config.conflict_strategy_rename" @@ -93,8 +93,8 @@ const ( BotMsgConfigErrorInvalidCallbackData Key = "bot.msg.config.error_invalid_callback_data" BotMsgConfigErrorInvalidTemplate Key = "bot.msg.config.error_invalid_template" BotMsgConfigFnametmplHelp Key = "bot.msg.config.fnametmpl_help" - BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix" BotMsgConfigInfoConflictStrategySet Key = "bot.msg.config.info_conflict_strategy_set" + BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix" BotMsgConfigInfoFilenameStrategySet Key = "bot.msg.config.info_filename_strategy_set" BotMsgConfigInfoTemplateUpdated Key = "bot.msg.config.info_template_updated" BotMsgConfigPromptSelectConflictStrategy Key = "bot.msg.config.prompt_select_conflict_strategy" @@ -200,6 +200,7 @@ const ( BotMsgRuleErrorGetUserRulesFailed Key = "bot.msg.rule.error_get_user_rules_failed" BotMsgRuleErrorInvalidRuleId Key = "bot.msg.rule.error_invalid_rule_id" BotMsgRuleErrorInvalidRuleType Key = "bot.msg.rule.error_invalid_rule_type" + BotMsgRuleErrorStorageNotFound Key = "bot.msg.rule.error_storage_not_found" BotMsgRuleErrorUpdateUserFailed Key = "bot.msg.rule.error_update_user_failed" BotMsgRuleHelpAddSuffix Key = "bot.msg.rule.help_add_suffix" BotMsgRuleHelpAvailableOps Key = "bot.msg.rule.help_available_ops" @@ -207,13 +208,16 @@ const ( BotMsgRuleHelpCurrentModeEnabled Key = "bot.msg.rule.help_current_mode_enabled" BotMsgRuleHelpDelSuffix Key = "bot.msg.rule.help_del_suffix" BotMsgRuleHelpExistingRulesPrefix Key = "bot.msg.rule.help_existing_rules_prefix" + BotMsgRuleHelpPresetSuffix Key = "bot.msg.rule.help_preset_suffix" BotMsgRuleHelpSwitchSuffix Key = "bot.msg.rule.help_switch_suffix" BotMsgRuleHelpUsage Key = "bot.msg.rule.help_usage" BotMsgRuleInfoCreateRuleSuccess Key = "bot.msg.rule.info_create_rule_success" BotMsgRuleInfoDeleteRuleSuccess Key = "bot.msg.rule.info_delete_rule_success" + BotMsgRuleInfoPresetImported Key = "bot.msg.rule.info_preset_imported" BotMsgRuleInfoRuleModeDisabled Key = "bot.msg.rule.info_rule_mode_disabled" BotMsgRuleInfoRuleModeEnabled Key = "bot.msg.rule.info_rule_mode_enabled" BotMsgRulePromptProvideRuleId Key = "bot.msg.rule.prompt_provide_rule_id" + BotMsgRulePromptProvideStorageName Key = "bot.msg.rule.prompt_provide_storage_name" BotMsgSaveErrorInvalidIdOrUsername Key = "bot.msg.save.error_invalid_id_or_username" BotMsgSaveHelpText Key = "bot.msg.save_help_text" BotMsgStorageInfoFilenamePrefix Key = "bot.msg.storage.info_filename_prefix" diff --git a/common/i18n/locale/en.yaml b/common/i18n/locale/en.yaml index f76e8c4..6c7f203 100644 --- a/common/i18n/locale/en.yaml +++ b/common/i18n/locale/en.yaml @@ -196,7 +196,11 @@ bot: help_switch_suffix: " - Toggle rule mode\n" help_add_suffix: " - Add rule\n" help_del_suffix: " - Delete rule\n" + help_preset_suffix: " [base_path] - Import built-in filetype rules (video/image/audio/document/archive)\n" help_existing_rules_prefix: "\nCurrent rules:\n" + prompt_provide_storage_name: "Please provide a storage name" + error_storage_not_found: "Storage not found: {{.Storage}}" + info_preset_imported: "Imported {{.Count}} built-in classification rules into storage {{.Storage}}" dir: error_get_user_dirs_failed: "Failed to get user directories" error_get_user_failed: "Failed to get user" diff --git a/common/i18n/locale/zh-Hans.yaml b/common/i18n/locale/zh-Hans.yaml index 9369b52..6438cb1 100644 --- a/common/i18n/locale/zh-Hans.yaml +++ b/common/i18n/locale/zh-Hans.yaml @@ -197,7 +197,11 @@ bot: help_switch_suffix: " - 开关规则模式\n" help_add_suffix: " <类型> <数据> <存储名> <路径> - 添加规则\n" help_del_suffix: " <规则ID> - 删除规则\n" + help_preset_suffix: " <存储名> [基础路径] - 导入内置文件类型分类规则(视频/图片/音频/文档/压缩包)\n" help_existing_rules_prefix: "\n当前已添加的规则:\n" + prompt_provide_storage_name: "请提供存储名称" + error_storage_not_found: "未找到存储: {{.Storage}}" + info_preset_imported: "已导入 {{.Count}} 条内置分类规则到存储 {{.Storage}}" dir: error_get_user_dirs_failed: "获取用户文件夹失败" error_get_user_failed: "获取用户失败" diff --git a/pkg/rule/preset.go b/pkg/rule/preset.go new file mode 100644 index 0000000..f5e80a2 --- /dev/null +++ b/pkg/rule/preset.go @@ -0,0 +1,55 @@ +package rule + +import "path" + +// PresetCategory describes a built-in filetype classification: files whose name +// matches Regex are routed into the Dir subdirectory (joined with a user base path). +type PresetCategory struct { + // Name is a stable identifier for the category (used in logs/messages). + Name string + // Regex is a FILENAME-REGEX rule data string matching this category's extensions. + Regex string + // Dir is the default subdirectory name for this category. + Dir string +} + +// presetCategories holds the default filetype classification rules. +// Regexes are case-insensitive and match common file extensions. +var presetCategories = []PresetCategory{ + { + Name: "video", + Regex: `(?i)\.(mp4|mkv|ts|avi|flv|mov|webm|wmv|rmvb|m2ts)$`, + Dir: "视频", + }, + { + Name: "image", + Regex: `(?i)\.(jpg|jpeg|png|gif|webp|bmp)$`, + Dir: "图片", + }, + { + Name: "audio", + Regex: `(?i)\.(mp3|flac|wav|aac|m4a|ogg)$`, + Dir: "音频", + }, + { + Name: "document", + Regex: `(?i)\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md|csv|epub|mobi|azw3|chm)$`, + Dir: "文档", + }, + { + Name: "archive", + Regex: `(?i)\.(zip|rar|7z|tar|gz|bz2|xz|r\d{1,3}|z\d{1,3}|\d{3}|part\d+\.rar|7z\.\d{3})$`, + Dir: "压缩包", + }, +} + +// PresetCategories returns the built-in filetype classification rules with each +// category's directory joined under basePath. basePath may be empty. +func PresetCategories(basePath string) []PresetCategory { + out := make([]PresetCategory, len(presetCategories)) + for i, c := range presetCategories { + c.Dir = path.Join(basePath, c.Dir) + out[i] = c + } + return out +} diff --git a/pkg/rule/preset_test.go b/pkg/rule/preset_test.go new file mode 100644 index 0000000..64caf81 --- /dev/null +++ b/pkg/rule/preset_test.go @@ -0,0 +1,55 @@ +package rule + +import ( + "regexp" + "testing" +) + +func TestPresetCategoriesCompile(t *testing.T) { + for _, c := range PresetCategories("") { + if _, err := regexp.Compile(c.Regex); err != nil { + t.Errorf("preset %q has invalid regex %q: %v", c.Name, c.Regex, err) + } + } +} + +func TestPresetCategoriesMatch(t *testing.T) { + cases := map[string]string{ + "video": "movie.MP4", + "image": "photo.jpg", + "audio": "song.flac", + "document": "report.pdf", + "archive": "backup.zip", + } + + byName := make(map[string]*regexp.Regexp) + for _, c := range PresetCategories("") { + byName[c.Name] = regexp.MustCompile(c.Regex) + } + + for name, filename := range cases { + re, ok := byName[name] + if !ok { + t.Errorf("missing preset category %q", name) + continue + } + if !re.MatchString(filename) { + t.Errorf("preset %q did not match %q", name, filename) + } + } +} + +func TestPresetCategoriesBasePath(t *testing.T) { + presets := PresetCategories("/media") + for _, c := range presets { + if c.Dir == "" || c.Dir[0] != '/' { + t.Errorf("preset %q dir %q not joined under base path", c.Name, c.Dir) + } + } + // Empty base path must not prefix a separator. + for _, c := range PresetCategories("") { + if c.Dir == "" || c.Dir[0] == '/' { + t.Errorf("preset %q dir %q should be relative when base path empty", c.Name, c.Dir) + } + } +}