diff --git a/client/bot/handlers/add_task.go b/client/bot/handlers/add_task.go index 8861e56..8030a02 100644 --- a/client/bot/handlers/add_task.go +++ b/client/bot/handlers/add_task.go @@ -73,14 +73,16 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error { return dispatcher.EndGroups } dirPath = dir.Path + } else if data.SelectedDirPath != "" { + dirPath = data.SelectedDirPath } switch data.TaskType { case tasktype.TaskTypeTgfiles: if data.AsBatch { - return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID) + return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID, data.ConflictStrategy) } - return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID) + return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID, data.ConflictStrategy) case tasktype.TaskTypeTphpics: return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID) case tasktype.TaskTypeParseditem: diff --git a/client/bot/handlers/config.go b/client/bot/handlers/config.go index 6d8e2cf..c8c363d 100644 --- a/client/bot/handlers/config.go +++ b/client/bot/handlers/config.go @@ -8,6 +8,7 @@ import ( "github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/ext" "github.com/gotd/td/tg" + "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/conflictutil" "github.com/krau/SaveAny-Bot/common/i18n" "github.com/krau/SaveAny-Bot/common/i18n/i18nk" "github.com/krau/SaveAny-Bot/config" @@ -26,6 +27,10 @@ func handleConfigCmd(ctx *ext.Context, update *ext.Update) error { Text: i18n.T(i18nk.BotMsgConfigButtonFilenameStrategy), Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"), }, + &tg.KeyboardButtonCallback{ + Text: i18n.T(i18nk.BotMsgConfigButtonConflictStrategy), + Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "conflictst"), + }, }, }, }, @@ -51,6 +56,8 @@ func handleConfigCallback(ctx *ext.Context, update *ext.Update) error { switch args[1] { case "fnamest": return handleConfigFnameSTCallback(ctx, update) + case "conflictst": + return handleConfigConflictSTCallback(ctx, update) default: return invaildDataAnswer() } @@ -110,6 +117,55 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error { return dispatcher.EndGroups } +func handleConfigConflictSTCallback(ctx *ext.Context, update *ext.Update) error { + userID := update.CallbackQuery.GetUserID() + user, err := database.GetUserByChatID(ctx, userID) + if err != nil { + return err + } + args := strings.Fields(string(update.CallbackQuery.Data)) + if len(args) == 3 { + selected := args[2] + if !tcbdata.IsConflictStrategy(selected) { + return fmt.Errorf("invalid conflict strategy: %s", selected) + } + user.ConflictStrategy = selected + if err := database.UpdateUser(ctx, user); err != nil { + return err + } + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: update.CallbackQuery.GetMsgID(), + Message: i18n.T(i18nk.BotMsgConfigInfoConflictStrategySet, map[string]any{ + "Strategy": conflictutil.Display(selected), + }), + }) + return dispatcher.EndGroups + } + + opts := tcbdata.ConflictStrategyValues() + rows := make([]tg.KeyboardButtonRow, 0, len(opts)) + for _, opt := range opts { + rows = append(rows, tg.KeyboardButtonRow{ + Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonCallback{ + Text: conflictutil.Display(opt), + Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "conflictst", opt), + }, + }, + }) + } + markup := &tg.ReplyInlineMarkup{Rows: rows} + currentSt := conflictutil.EffectiveStrategy(user) + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: update.CallbackQuery.GetMsgID(), + Message: i18n.T(i18nk.BotMsgConfigPromptSelectConflictStrategy, map[string]any{ + "Strategy": conflictutil.Display(currentSt), + }), + ReplyMarkup: markup, + }) + return dispatcher.EndGroups +} + func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error { userID := update.GetUserChat().GetID() user, err := database.GetUserByChatID(ctx, userID) diff --git a/client/bot/handlers/utils/conflictutil/conflict.go b/client/bot/handlers/utils/conflictutil/conflict.go new file mode 100644 index 0000000..f43b68b --- /dev/null +++ b/client/bot/handlers/utils/conflictutil/conflict.go @@ -0,0 +1,55 @@ +package conflictutil + +import ( + "fmt" + "strings" + + "github.com/krau/SaveAny-Bot/common/i18n" + "github.com/krau/SaveAny-Bot/common/i18n/i18nk" + "github.com/krau/SaveAny-Bot/database" + "github.com/krau/SaveAny-Bot/pkg/tcbdata" +) + +const maxConflictLines = 10 + +func EffectiveStrategy(user *database.User) string { + if user != nil && tcbdata.IsConflictStrategy(user.ConflictStrategy) { + return user.ConflictStrategy + } + return tcbdata.ConflictStrategyRename +} + +func ResolveStrategy(user *database.User, override string) string { + if tcbdata.IsConflictStrategy(override) { + return override + } + return EffectiveStrategy(user) +} + +func Display(strategy string) string { + switch strategy { + case tcbdata.ConflictStrategyRename: + return i18n.T(i18nk.BotMsgConfigConflictStrategyRename, nil) + case tcbdata.ConflictStrategyAsk: + return i18n.T(i18nk.BotMsgConfigConflictStrategyAsk, nil) + case tcbdata.ConflictStrategyOverwrite: + return i18n.T(i18nk.BotMsgConfigConflictStrategyOverwrite, nil) + case tcbdata.ConflictStrategySkip: + return i18n.T(i18nk.BotMsgConfigConflictStrategySkip, nil) + default: + return strategy + } +} + +func FormatPaths(conflicts []string) string { + if len(conflicts) <= maxConflictLines { + return strings.Join(conflicts, "\n") + } + return strings.Join(conflicts[:maxConflictLines], "\n") + "\n" + i18n.T(i18nk.BotMsgCommonPromptConflictMoreFiles, map[string]any{ + "Count": len(conflicts) - maxConflictLines, + }) +} + +func FormatPath(storageName, storagePath string) string { + return fmt.Sprintf("[%s]:%s", storageName, storagePath) +} diff --git a/client/bot/handlers/utils/msgelem/storage.go b/client/bot/handlers/utils/msgelem/storage.go index f48ade7..7f7eef2 100644 --- a/client/bot/handlers/utils/msgelem/storage.go +++ b/client/bot/handlers/utils/msgelem/storage.go @@ -38,6 +38,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) data := tcbdata.Add{ TaskType: taskType, SelectedStorName: storage.Name(), + SelectedDirPath: adddata.SelectedDirPath, + ConflictStrategy: adddata.ConflictStrategy, Files: adddata.Files, AsBatch: len(adddata.Files) > 1, @@ -109,6 +111,38 @@ func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storag }, nil } +func BuildConflictStrategyMarkup(adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) { + type option struct { + text string + strategy string + } + options := []option{ + {text: i18n.T(i18nk.BotMsgCommonButtonConflictRename, nil), strategy: tcbdata.ConflictStrategyRename}, + {text: i18n.T(i18nk.BotMsgCommonButtonConflictOverwrite, nil), strategy: tcbdata.ConflictStrategyOverwrite}, + {text: i18n.T(i18nk.BotMsgCommonButtonConflictSkip, nil), strategy: tcbdata.ConflictStrategySkip}, + } + buttons := make([]tg.KeyboardButtonClass, 0, len(options)) + for _, opt := range options { + data := adddata + data.ConflictStrategy = opt.strategy + dataid := xid.New().String() + if err := cache.Set(dataid, data); err != nil { + return nil, err + } + buttons = append(buttons, &tg.KeyboardButtonCallback{ + Text: opt.text, + Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid), + }) + } + rows := make([]tg.KeyboardButtonRow, 0, len(buttons)) + for _, button := range buttons { + rows = append(rows, tg.KeyboardButtonRow{ + Buttons: []tg.KeyboardButtonClass{button}, + }) + } + return &tg.ReplyInlineMarkup{Rows: rows}, nil +} + // Builds the inline keyboard for setting default storage func BuildSetDefaultStorageMarkup( ctx context.Context, diff --git a/client/bot/handlers/utils/shortcut/tftask.go b/client/bot/handlers/utils/shortcut/tftask.go index df5efa0..7a6c9e8 100644 --- a/client/bot/handlers/utils/shortcut/tftask.go +++ b/client/bot/handlers/utils/shortcut/tftask.go @@ -8,6 +8,7 @@ import ( "github.com/celestix/gotgproto/ext" "github.com/charmbracelet/log" "github.com/gotd/td/tg" + "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/conflictutil" "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem" "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil" "github.com/krau/SaveAny-Bot/common/i18n" @@ -17,14 +18,17 @@ import ( "github.com/krau/SaveAny-Bot/core/tasks/batchtfile" tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile" "github.com/krau/SaveAny-Bot/database" + "github.com/krau/SaveAny-Bot/pkg/enums/tasktype" + "github.com/krau/SaveAny-Bot/pkg/tcbdata" "github.com/krau/SaveAny-Bot/pkg/tfile" "github.com/krau/SaveAny-Bot/storage" "github.com/rs/xid" ) // 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果 -func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error { +func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int, conflictStrategy ...string) error { logger := log.FromContext(ctx) + strategy := selectedConflictStrategy(conflictStrategy) user, err := database.GetUserByChatID(ctx, userID) if err != nil { logger.Errorf("Failed to get user by chat ID: %s", err) @@ -36,6 +40,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage }) return dispatcher.EndGroups } + strategy = conflictutil.ResolveStrategy(user, strategy) if user.ApplyRule && user.Rules != nil { matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file)) if !matched { @@ -60,7 +65,26 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage } startCreateTask: storagePath := path.Join(dirPath, file.Name()) + if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip { + exists := stor.Exists(ctx, storagePath) + if exists && strategy == tcbdata.ConflictStrategyAsk { + return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, []tfile.TGFileMessage{file}, false, []string{conflictutil.FormatPath(stor.Name(), storagePath)}, trackMsgID) + } + if exists { + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: trackMsgID, + Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{ + "Skipped": file.Name(), + }), + ReplyMarkup: nil, + }) + return dispatcher.EndGroups + } + } injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) + if strategy == tcbdata.ConflictStrategyOverwrite { + injectCtx = storage.WithOverwrite(injectCtx) + } taskid := xid.New().String() task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, tftask.NewProgressTrack( @@ -97,8 +121,9 @@ startCreateTask: } // 创建一个 batchtfile.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果 -func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error { +func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int, conflictStrategy ...string) error { logger := log.FromContext(ctx) + strategy := selectedConflictStrategy(conflictStrategy) user, err := database.GetUserByChatID(ctx, userID) if err != nil { logger.Errorf("Failed to get user by chat ID: %s", err) @@ -110,6 +135,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st }) return dispatcher.EndGroups } + strategy = conflictutil.ResolveStrategy(user, strategy) useRule := user.ApplyRule && user.Rules != nil @@ -128,14 +154,17 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st return storname, dirP } + skipped := make([]string, 0) + conflicts := make([]string, 0) elems := make([]batchtfile.TaskElement, 0, len(files)) type albumFile struct { file tfile.TGFileMessage storage storage.Storage + dirPath string } albumFiles := make(map[int64][]albumFile, 0) for _, file := range files { - storName, dirPath := applyRule(file) + storName, matchedDirPath := applyRule(file) fileStor := stor if storName != stor.Name() && storName != "" { fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName) @@ -150,8 +179,19 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st return dispatcher.EndGroups } } - if !dirPath.NeedNewForAlbum() { - storPath := path.Join(dirPath.String(), file.Name()) + if !matchedDirPath.NeedNewForAlbum() { + storPath := path.Join(matchedDirPath.String(), file.Name()) + if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip { + exists := fileStor.Exists(ctx, storPath) + if exists && strategy == tcbdata.ConflictStrategyAsk { + conflicts = append(conflicts, conflictutil.FormatPath(fileStor.Name(), storPath)) + continue + } + if exists { + skipped = append(skipped, file.Name()) + continue + } + } elem, err := batchtfile.NewTaskElement(fileStor, storPath, file) if err != nil { logger.Errorf("Failed to create task element: %s", err) @@ -170,12 +210,17 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st logger.Warnf("File %s is not in a group, skipping album handling", file.Name()) continue } + fileDirPath := matchedDirPath.String() + if matchedDirPath.NeedNewForAlbum() { + fileDirPath = dirPath + } if _, ok := albumFiles[groupId]; !ok { albumFiles[groupId] = make([]albumFile, 0) } albumFiles[groupId] = append(albumFiles[groupId], albumFile{ file: file, storage: fileStor, + dirPath: fileDirPath, }) } } @@ -188,7 +233,18 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name())) albumStor := afiles[0].storage for _, af := range afiles { - afstorPath := path.Join(dirPath, albumDir, af.file.Name()) + afstorPath := path.Join(af.dirPath, albumDir, af.file.Name()) + if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip { + exists := albumStor.Exists(ctx, afstorPath) + if exists && strategy == tcbdata.ConflictStrategyAsk { + conflicts = append(conflicts, conflictutil.FormatPath(albumStor.Name(), afstorPath)) + continue + } + if exists { + skipped = append(skipped, af.file.Name()) + continue + } + } elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file) if err != nil { logger.Errorf("Failed to create task element for album file: %s", err) @@ -204,9 +260,26 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st } } + if strategy == tcbdata.ConflictStrategyAsk && len(conflicts) > 0 { + return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, files, true, conflicts, trackMsgID) + } + injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) + if strategy == tcbdata.ConflictStrategyOverwrite { + injectCtx = storage.WithOverwrite(injectCtx) + } + if len(elems) == 0 { + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: trackMsgID, + Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{ + "Skipped": strings.Join(skipped, "\n"), + }), + ReplyMarkup: nil, + }) + return dispatcher.EndGroups + } taskid := xid.New().String() - task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTracker(trackMsgID, userID), true) + task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTrackerWithSkipped(trackMsgID, userID, skipped), true) if err := core.AddTask(injectCtx, task); err != nil { logger.Errorf("Failed to add batch task: %s", err) ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ @@ -218,11 +291,48 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st return dispatcher.EndGroups } ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ - ID: trackMsgID, - Message: i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{ - "Count": len(files), - }), + ID: trackMsgID, + Message: buildBatchAddedMessage(len(elems), skipped), ReplyMarkup: nil, }) return dispatcher.EndGroups } + +func promptTGFileConflictStrategy(ctx *ext.Context, userID int64, storageName, dirPath string, files []tfile.TGFileMessage, asBatch bool, conflicts []string, trackMsgID int) error { + markup, err := msgelem.BuildConflictStrategyMarkup(tcbdata.Add{ + TaskType: tasktype.TaskTypeTgfiles, + SelectedStorName: storageName, + SettedDir: true, + SelectedDirPath: dirPath, + Files: files, + AsBatch: asBatch, + }) + if err != nil { + return err + } + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: trackMsgID, + Message: i18n.T(i18nk.BotMsgCommonPromptSelectConflictStrategy, map[string]any{"Files": conflictutil.FormatPaths(conflicts)}), + ReplyMarkup: markup, + }) + return dispatcher.EndGroups +} + +func selectedConflictStrategy(strategies []string) string { + if len(strategies) == 0 { + return "" + } + return strategies[0] +} + +func buildBatchAddedMessage(count int, skipped []string) string { + if len(skipped) == 0 { + return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{ + "Count": count, + }) + } + return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAddedWithSkipped, map[string]any{ + "Count": count, + "Skipped": strings.Join(skipped, "\n"), + }) +} diff --git a/common/i18n/i18nk/keys.go b/common/i18n/i18nk/keys.go index b3443b8..157544c 100644 --- a/common/i18n/i18nk/keys.go +++ b/common/i18n/i18nk/keys.go @@ -36,6 +36,9 @@ const ( BotMsgCmdUpdate Key = "bot.msg.cmd.update" BotMsgCmdWatch Key = "bot.msg.cmd.watch" BotMsgCmdYtdlp Key = "bot.msg.cmd.ytdlp" + BotMsgCommonButtonConflictOverwrite Key = "bot.msg.common.button_conflict_overwrite" + BotMsgCommonButtonConflictRename Key = "bot.msg.common.button_conflict_rename" + BotMsgCommonButtonConflictSkip Key = "bot.msg.common.button_conflict_skip" BotMsgCommonCancelButtonText Key = "bot.msg.common.cancel_button_text" BotMsgCommonErrorBuildDirSelectKeyboardFailed Key = "bot.msg.common.error_build_dir_select_keyboard_failed" BotMsgCommonErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.common.error_build_storage_select_keyboard_failed" @@ -63,7 +66,10 @@ const ( BotMsgCommonErrorTaskAddFailed Key = "bot.msg.common.error_task_add_failed" BotMsgCommonErrorTaskCreateFailed Key = "bot.msg.common.error_task_create_failed" BotMsgCommonErrorUpdateUserInfoFailed Key = "bot.msg.common.error_update_user_info_failed" + BotMsgCommonInfoAllConflictFilesSkipped Key = "bot.msg.common.info_all_conflict_files_skipped" BotMsgCommonInfoBatchTasksAdded Key = "bot.msg.common.info_batch_tasks_added" + BotMsgCommonInfoBatchTasksAddedWithSkipped Key = "bot.msg.common.info_batch_tasks_added_with_skipped" + BotMsgCommonInfoConflictFilesSkipped Key = "bot.msg.common.info_conflict_files_skipped" BotMsgCommonInfoDefaultStorageSet Key = "bot.msg.common.info_default_storage_set" BotMsgCommonInfoDefaultStorageWithDirSet Key = "bot.msg.common.info_default_storage_with_dir_set" BotMsgCommonInfoFetchingFileInfo Key = "bot.msg.common.info_fetching_file_info" @@ -73,16 +79,25 @@ const ( BotMsgCommonInfoSilentModeOff Key = "bot.msg.common.info_silent_mode_off" BotMsgCommonInfoSilentModeOn Key = "bot.msg.common.info_silent_mode_on" BotMsgCommonInfoTaskAdded Key = "bot.msg.common.info_task_added" + BotMsgCommonPromptConflictMoreFiles Key = "bot.msg.common.prompt_conflict_more_files" + BotMsgCommonPromptSelectConflictStrategy Key = "bot.msg.common.prompt_select_conflict_strategy" 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" + BotMsgConfigConflictStrategyAsk Key = "bot.msg.config.conflict_strategy_ask" + BotMsgConfigConflictStrategyOverwrite Key = "bot.msg.config.conflict_strategy_overwrite" + BotMsgConfigConflictStrategyRename Key = "bot.msg.config.conflict_strategy_rename" + BotMsgConfigConflictStrategySkip Key = "bot.msg.config.conflict_strategy_skip" 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" 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" BotMsgConfigPromptSelectFilenameStrategy Key = "bot.msg.config.prompt_select_filename_strategy" BotMsgConfigPromptSelectOption Key = "bot.msg.config.prompt_select_option" BotMsgDirButtonDefault Key = "bot.msg.dir.button_default" diff --git a/common/i18n/locale/en.yaml b/common/i18n/locale/en.yaml index b0218a7..f76e8c4 100644 --- a/common/i18n/locale/en.yaml +++ b/common/i18n/locale/en.yaml @@ -112,9 +112,17 @@ bot: error_task_add_failed: "Failed to add task: {{.Error}}" info_task_added: "Task added" info_batch_tasks_added: "Batch tasks added, total {{.Count}} files" + info_batch_tasks_added_with_skipped: "Batch tasks added, total {{.Count}} files\nSkipped conflicting files:\n{{.Skipped}}" + info_all_conflict_files_skipped: "All conflicting files were skipped:\n{{.Skipped}}" + info_conflict_files_skipped: "Skipped conflicting files:\n{{.Skipped}}" error_task_create_failed: "Failed to create task: {{.Error}}" error_get_dir_failed: "Failed to get directory: {{.Error}}" prompt_select_dir: "Please select a directory to store to" + prompt_select_conflict_strategy: "Files with the same name already exist. Please select a save strategy:\n{{.Files}}" + prompt_conflict_more_files: "...and {{.Count}} more files" + button_conflict_rename: "Rename" + button_conflict_overwrite: "Overwrite" + button_conflict_skip: "Skip" prompt_select_default_dir: "Please select a default directory to save to" info_default_storage_set: "Default storage set to: {{.Name}}" info_default_storage_with_dir_set: "Default storage set to: {{.Name}}:/{{.Dir}}" @@ -266,10 +274,17 @@ bot: config: prompt_select_option: "Please select an option to configure" button_filename_strategy: "Filename strategy" + button_conflict_strategy: "Duplicate file strategy" error_invalid_callback_data: "Invalid callback data" error_invalid_template: "Invalid template, please check syntax\n{{.Error}}" info_filename_strategy_set: "Filename strategy set to: {{.Strategy}}" + info_conflict_strategy_set: "Duplicate file strategy set to: {{.Strategy}}" prompt_select_filename_strategy: "Please select filename strategy, current strategy: {{.Strategy}}" + prompt_select_conflict_strategy: "Please select duplicate file strategy, current strategy: {{.Strategy}}" + conflict_strategy_rename: "Always rename" + conflict_strategy_ask: "Ask every time" + conflict_strategy_overwrite: "Always overwrite" + conflict_strategy_skip: "Always skip" fnametmpl_help: |- Use this command to set filename template, for example: /fnametmpl Image_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg diff --git a/common/i18n/locale/zh-Hans.yaml b/common/i18n/locale/zh-Hans.yaml index e460d3d..9369b52 100644 --- a/common/i18n/locale/zh-Hans.yaml +++ b/common/i18n/locale/zh-Hans.yaml @@ -113,9 +113,17 @@ bot: error_task_add_failed: "任务添加失败: {{.Error}}" info_task_added: "任务已添加" info_batch_tasks_added: "已添加批量任务, 共 {{.Count}} 个文件" + info_batch_tasks_added_with_skipped: "已添加批量任务, 共 {{.Count}} 个文件\n已跳过同名文件:\n{{.Skipped}}" + info_all_conflict_files_skipped: "全部同名文件已跳过:\n{{.Skipped}}" + info_conflict_files_skipped: "已跳过同名文件:\n{{.Skipped}}" error_task_create_failed: "任务创建失败: {{.Error}}" error_get_dir_failed: "获取目录失败: {{.Error}}" prompt_select_dir: "请选择要存储到的目录" + prompt_select_conflict_strategy: "检测到同名文件, 请选择保存策略:\n{{.Files}}" + prompt_conflict_more_files: "...还有 {{.Count}} 个文件" + button_conflict_rename: "重命名" + button_conflict_overwrite: "覆盖" + button_conflict_skip: "跳过" prompt_select_default_dir: "请选择要保存到的默认文件夹" info_default_storage_set: "已将默认存储位置设为: {{.Name}}" info_default_storage_with_dir_set: "已将默认存储位置设为: {{.Name}}:/{{.Dir}}" @@ -267,10 +275,17 @@ bot: config: prompt_select_option: "请选择要配置的选项" button_filename_strategy: "文件名策略" + button_conflict_strategy: "重名文件保存策略" error_invalid_callback_data: "无效的回调数据" error_invalid_template: "无效的模板, 请检查语法\n{{.Error}}" info_filename_strategy_set: "已将文件名策略设置为: {{.Strategy}}" + info_conflict_strategy_set: "已将重名文件保存策略设置为: {{.Strategy}}" prompt_select_filename_strategy: "请选择文件名策略, 当前策略: {{.Strategy}}" + prompt_select_conflict_strategy: "请选择重名文件保存策略, 当前策略: {{.Strategy}}" + conflict_strategy_rename: "始终重命名" + conflict_strategy_ask: "每次询问" + conflict_strategy_overwrite: "始终覆盖" + conflict_strategy_skip: "始终跳过" fnametmpl_help: |- 使用该命令设置文件名模板, 示例: /fnametmpl 图片_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg diff --git a/core/tasks/batchtfile/progress.go b/core/tasks/batchtfile/progress.go index 5267291..feb4767 100644 --- a/core/tasks/batchtfile/progress.go +++ b/core/tasks/batchtfile/progress.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "strings" "sync/atomic" "time" @@ -30,6 +31,7 @@ type Progress struct { ChatID int64 start time.Time lastUpdatePercent atomic.Int32 + skippedFiles []string } func (p *Progress) OnStart(ctx context.Context, info TaskInfo) { @@ -151,6 +153,14 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) { styling.Code(strconv.Itoa(info.Count())), styling.Plain(i18n.T(i18nk.BotMsgProgressTotalSizePrefix, nil)), styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))), + func() styling.StyledTextOption { + if len(p.skippedFiles) == 0 { + return styling.Plain("") + } + return styling.Plain("\n\n" + i18n.T(i18nk.BotMsgCommonInfoConflictFilesSkipped, map[string]any{ + "Skipped": strings.Join(p.skippedFiles, "\n"), + })) + }(), ) } @@ -173,8 +183,13 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) { } func NewProgressTracker(messageID int, chatID int64) ProgressTracker { + return NewProgressTrackerWithSkipped(messageID, chatID, nil) +} + +func NewProgressTrackerWithSkipped(messageID int, chatID int64, skippedFiles []string) ProgressTracker { return &Progress{ - MessageID: messageID, - ChatID: chatID, + MessageID: messageID, + ChatID: chatID, + skippedFiles: skippedFiles, } } diff --git a/database/model.go b/database/model.go index 7c249b3..3a53381 100644 --- a/database/model.go +++ b/database/model.go @@ -16,6 +16,7 @@ type User struct { WatchChats []WatchChat FilenameStrategy string FilenameTemplate string + ConflictStrategy string } type WatchChat struct { diff --git a/pkg/enums/ctxkey/context_key.go b/pkg/enums/ctxkey/context_key.go index 9696efc..e38cdcb 100644 --- a/pkg/enums/ctxkey/context_key.go +++ b/pkg/enums/ctxkey/context_key.go @@ -1,6 +1,6 @@ package ctxkey -// ENUM(content-length) +// ENUM(content-length, overwrite-existing) // //go:generate go-enum --values --names --flag --nocase --noprefix type ContextKey string diff --git a/pkg/enums/ctxkey/context_key_enum.go b/pkg/enums/ctxkey/context_key_enum.go index ad1202c..3f258de 100644 --- a/pkg/enums/ctxkey/context_key_enum.go +++ b/pkg/enums/ctxkey/context_key_enum.go @@ -14,12 +14,15 @@ import ( const ( // ContentLength is a ContextKey of type content-length. ContentLength ContextKey = "content-length" + // OverwriteExisting is a ContextKey of type overwrite-existing. + OverwriteExisting ContextKey = "overwrite-existing" ) var ErrInvalidContextKey = fmt.Errorf("not a valid ContextKey, try [%s]", strings.Join(_ContextKeyNames, ", ")) var _ContextKeyNames = []string{ string(ContentLength), + string(OverwriteExisting), } // ContextKeyNames returns a list of possible string values of ContextKey. @@ -33,6 +36,7 @@ func ContextKeyNames() []string { func ContextKeyValues() []ContextKey { return []ContextKey{ ContentLength, + OverwriteExisting, } } @@ -49,7 +53,8 @@ func (x ContextKey) IsValid() bool { } var _ContextKeyValue = map[string]ContextKey{ - "content-length": ContentLength, + "content-length": ContentLength, + "overwrite-existing": OverwriteExisting, } // ParseContextKey attempts to convert a string to a ContextKey. diff --git a/pkg/tcbdata/data.go b/pkg/tcbdata/data.go index e94ff5f..0f22222 100644 --- a/pkg/tcbdata/data.go +++ b/pkg/tcbdata/data.go @@ -14,6 +14,31 @@ const ( TypeCancel = "cancel" ) +const ( + ConflictStrategyRename = "rename" + ConflictStrategyAsk = "ask" + ConflictStrategyOverwrite = "overwrite" + ConflictStrategySkip = "skip" +) + +func ConflictStrategyValues() []string { + return []string{ + ConflictStrategyRename, + ConflictStrategyAsk, + ConflictStrategyOverwrite, + ConflictStrategySkip, + } +} + +func IsConflictStrategy(strategy string) bool { + for _, value := range ConflictStrategyValues() { + if strategy == value { + return true + } + } + return false +} + // type TaskDataTGFiles struct { // Files []tfile.TGFileMessage // AsBatch bool @@ -34,6 +59,8 @@ type Add struct { SelectedStorName string DirID uint SettedDir bool + SelectedDirPath string + ConflictStrategy string // tfiles Files []tfile.TGFileMessage AsBatch bool diff --git a/storage/alist/alist.go b/storage/alist/alist.go index f33a3e1..cc0a2ee 100644 --- a/storage/alist/alist.go +++ b/storage/alist/alist.go @@ -108,8 +108,10 @@ func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string) ext := path.Ext(storagePath) base := strings.TrimSuffix(storagePath, ext) candidate := storagePath - for i := 1; a.Exists(ctx, candidate); i++ { - candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { + for i := 1; a.existsPath(ctx, candidate); i++ { + candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + } } req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.baseURL+"/api/fs/put", reader) @@ -158,6 +160,10 @@ func (a *Alist) JoinStoragePath(p string) string { } func (a *Alist) Exists(ctx context.Context, storagePath string) bool { + return a.existsPath(ctx, a.JoinStoragePath(storagePath)) +} + +func (a *Alist) existsPath(ctx context.Context, storagePath string) bool { // POST /api/fs/get /* body: diff --git a/storage/context.go b/storage/context.go index 7ee5ec8..9c70274 100644 --- a/storage/context.go +++ b/storage/context.go @@ -1,6 +1,10 @@ package storage -import "context" +import ( + "context" + + "github.com/krau/SaveAny-Bot/pkg/enums/ctxkey" +) type contextKey struct{} @@ -20,3 +24,7 @@ func FromContext(ctx context.Context) Storage { } return storage } + +func WithOverwrite(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxkey.OverwriteExisting, true) +} diff --git a/storage/local/local.go b/storage/local/local.go index c648800..4031d59 100644 --- a/storage/local/local.go +++ b/storage/local/local.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/log" "github.com/duke-git/lancet/v2/fileutil" config "github.com/krau/SaveAny-Bot/config/storage" + "github.com/krau/SaveAny-Bot/pkg/enums/ctxkey" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" "github.com/krau/SaveAny-Bot/pkg/storagetypes" ) @@ -56,8 +57,10 @@ func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error ext := filepath.Ext(storagePath) base := strings.TrimSuffix(storagePath, ext) candidate := storagePath - for i := 1; l.Exists(ctx, candidate); i++ { - candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { + for i := 1; l.existsPath(candidate); i++ { + candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + } } absPath, err := filepath.Abs(candidate) @@ -77,6 +80,10 @@ func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error } func (l *Local) Exists(ctx context.Context, storagePath string) bool { + return l.existsPath(l.JoinStoragePath(storagePath)) +} + +func (l *Local) existsPath(storagePath string) bool { absPath, err := filepath.Abs(storagePath) if err != nil { return false diff --git a/storage/minio/client.go b/storage/minio/client.go index c4ee16b..d62d031 100644 --- a/storage/minio/client.go +++ b/storage/minio/client.go @@ -81,12 +81,14 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error ext := path.Ext(storagePath) base := strings.TrimSuffix(storagePath, ext) candidate := storagePath - for i := 1; m.Exists(ctx, candidate); i++ { - candidate = fmt.Sprintf("%s_%d%s", base, i, ext) - if i > 10 { - m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) - candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) - break + if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { + for i := 1; m.existsObject(ctx, candidate); i++ { + candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + if i > 10 { + m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) + candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) + break + } } } size := int64(-1) @@ -106,6 +108,10 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error func (m *Minio) Exists(ctx context.Context, storagePath string) bool { m.logger.Debugf("Checking if file exists at %s", storagePath) + return m.existsObject(ctx, m.JoinStoragePath(storagePath)) +} + +func (m *Minio) existsObject(ctx context.Context, storagePath string) bool { _, err := m.client.StatObject(ctx, m.config.BucketName, storagePath, minio.StatObjectOptions{}) return err == nil } diff --git a/storage/rclone/rclone.go b/storage/rclone/rclone.go index e705ea7..9b2a0be 100644 --- a/storage/rclone/rclone.go +++ b/storage/rclone/rclone.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/log" config "github.com/krau/SaveAny-Bot/config/storage" + "github.com/krau/SaveAny-Bot/pkg/enums/ctxkey" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" "github.com/krau/SaveAny-Bot/pkg/storagetypes" "github.com/rs/xid" @@ -107,12 +108,14 @@ func (r *Rclone) Save(ctx context.Context, reader io.Reader, storagePath string) ext := path.Ext(storagePath) base := strings.TrimSuffix(storagePath, ext) candidate := storagePath - for i := 1; r.Exists(ctx, candidate); i++ { - candidate = fmt.Sprintf("%s_%d%s", base, i, ext) - if i > 100 { - r.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) - candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) - break + if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { + for i := 1; r.Exists(ctx, candidate); i++ { + candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + if i > 100 { + r.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) + candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) + break + } } } diff --git a/storage/s3/s3.go b/storage/s3/s3.go index 153cd56..d893c77 100644 --- a/storage/s3/s3.go +++ b/storage/s3/s3.go @@ -70,13 +70,15 @@ func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error { base := strings.TrimSuffix(storagePath, ext) candidate := storagePath - // Unique filename - for i := 1; m.Exists(ctx, candidate); i++ { - candidate = fmt.Sprintf("%s_%d%s", base, i, ext) - if i > 10 { - m.logger.Errorf("Too many attempts for unique filename: %s", storagePath) - candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) - break + if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { + // Unique filename + for i := 1; m.existsKey(ctx, candidate); i++ { + candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + if i > 10 { + m.logger.Errorf("Too many attempts for unique filename: %s", storagePath) + candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) + break + } } } @@ -99,5 +101,9 @@ func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error { func (m *S3) Exists(ctx context.Context, storagePath string) bool { m.logger.Debugf("Checking if file exists at %s", storagePath) - return m.client.Exists(ctx, storagePath) + return m.existsKey(ctx, m.JoinStoragePath(storagePath)) +} + +func (m *S3) existsKey(ctx context.Context, key string) bool { + return m.client.Exists(ctx, key) } diff --git a/storage/webdav/webdav.go b/storage/webdav/webdav.go index db4216d..d869a5d 100644 --- a/storage/webdav/webdav.go +++ b/storage/webdav/webdav.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/log" config "github.com/krau/SaveAny-Bot/config/storage" + "github.com/krau/SaveAny-Bot/pkg/enums/ctxkey" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" "github.com/krau/SaveAny-Bot/pkg/storagetypes" "github.com/rs/xid" @@ -57,12 +58,14 @@ func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) erro ext := path.Ext(storagePath) base := strings.TrimSuffix(storagePath, ext) candidate := storagePath - for i := 1; w.Exists(ctx, candidate); i++ { - candidate = fmt.Sprintf("%s_%d%s", base, i, ext) - if i > 1000 { - w.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) - candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) - break + if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { + for i := 1; w.existsPath(ctx, candidate); i++ { + candidate = fmt.Sprintf("%s_%d%s", base, i, ext) + if i > 1000 { + w.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) + candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) + break + } } } @@ -79,6 +82,10 @@ func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) erro func (w *Webdav) Exists(ctx context.Context, storagePath string) bool { w.logger.Debugf("Checking if file exists at %s", storagePath) + return w.existsPath(ctx, w.JoinStoragePath(storagePath)) +} + +func (w *Webdav) existsPath(ctx context.Context, storagePath string) bool { exists, err := w.client.Exists(ctx, storagePath) if err != nil { w.logger.Errorf("Failed to check if file exists at %s: %v", storagePath, err)