diff --git a/client/bot/handlers/add_task.go b/client/bot/handlers/add_task.go index 8861e562..a7deae89 100644 --- a/client/bot/handlers/add_task.go +++ b/client/bot/handlers/add_task.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/log" "github.com/gotd/td/tg" "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/client/bot/handlers/utils/shortcut" "github.com/krau/SaveAny-Bot/common/i18n" "github.com/krau/SaveAny-Bot/common/i18n/i18nk" @@ -18,6 +19,7 @@ import ( "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" "gorm.io/gorm" ) @@ -73,14 +75,41 @@ 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: + strategy, err := getEffectiveConflictStrategy(ctx, userID, data.ConflictStrategy) + if err != nil { + ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, err.Error())) + return dispatcher.EndGroups + } + conflicts, err := findTGFileConflicts(ctx, userID, selectedStorage, dirPath, data.Files) + if err != nil { + ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, err.Error())) + return dispatcher.EndGroups + } + if len(conflicts) > 0 && strategy == tcbdata.ConflictStrategyAsk { + markup, err := msgelem.BuildConflictStrategyMarkup(data) + if err != nil { + ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{ + "Error": err.Error(), + }))) + return dispatcher.EndGroups + } + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: update.CallbackQuery.GetMsgID(), + Message: i18n.T(i18nk.BotMsgCommonPromptSelectConflictStrategy, map[string]any{"Files": formatConflictFiles(conflicts)}), + ReplyMarkup: markup, + }) + return dispatcher.EndGroups + } if data.AsBatch { - return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID) + return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID, strategy) } - return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID) + return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID, strategy) case tasktype.TaskTypeTphpics: return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID) case tasktype.TaskTypeParseditem: @@ -108,3 +137,102 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error { } return dispatcher.EndGroups } + +func getEffectiveConflictStrategy(ctx *ext.Context, userID int64, selected string) (string, error) { + if tcbdata.IsConflictStrategy(selected) { + return selected, nil + } + user, err := database.GetUserByChatID(ctx, userID) + if err != nil { + return "", errors.New(i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{"Error": err.Error()})) + } + return effectiveUserConflictStrategy(user), nil +} + +type tgFileConflict struct { + Name string + StorageName string + Path string +} + +func findTGFileConflicts(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage) ([]tgFileConflict, error) { + user, err := database.GetUserByChatID(ctx, userID) + if err != nil { + return nil, errors.New(i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{"Error": err.Error()})) + } + useRule := user.ApplyRule && user.Rules != nil + conflicts := make([]tgFileConflict, 0) + + resolve := func(file tfile.TGFileMessage) (storage.Storage, string, error) { + fileStor := stor + fileDirPath := dirPath + if useRule { + matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file)) + if matched { + if matchedDirPath != "" { + fileDirPath = matchedDirPath.String() + } + if matchedStorageName.Usable() { + var err error + fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String()) + if err != nil { + return nil, "", errors.New(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{"Error": err.Error()})) + } + } + } + } + return fileStor, fileDirPath, nil + } + + albumFiles := make(map[int64][]tfile.TGFileMessage) + for _, file := range files { + fileStor, fileDirPath, err := resolve(file) + if err != nil { + return nil, err + } + if ruleutil.MatchedDirPath(fileDirPath).NeedNewForAlbum() { + groupID, isGroup := file.Message().GetGroupedID() + if isGroup && groupID != 0 { + albumFiles[groupID] = append(albumFiles[groupID], file) + } + continue + } + storagePath := path.Join(fileDirPath, file.Name()) + if fileStor.Exists(ctx, storagePath) { + conflicts = append(conflicts, tgFileConflict{Name: file.Name(), StorageName: fileStor.Name(), Path: storagePath}) + } + } + + for _, afiles := range albumFiles { + if len(afiles) <= 1 { + continue + } + albumDir := strings.TrimSuffix(path.Base(afiles[0].Name()), path.Ext(afiles[0].Name())) + albumStor, _, err := resolve(afiles[0]) + if err != nil { + return nil, err + } + for _, file := range afiles { + storagePath := path.Join(dirPath, albumDir, file.Name()) + if albumStor.Exists(ctx, storagePath) { + conflicts = append(conflicts, tgFileConflict{Name: file.Name(), StorageName: albumStor.Name(), Path: storagePath}) + } + } + } + return conflicts, nil +} + +func formatConflictFiles(conflicts []tgFileConflict) string { + const maxConflictLines = 10 + var b strings.Builder + for i, conflict := range conflicts { + if i >= maxConflictLines { + fmt.Fprint(&b, i18n.T(i18nk.BotMsgCommonPromptConflictMoreFiles, map[string]any{ + "Count": len(conflicts) - maxConflictLines, + })) + break + } + fmt.Fprintf(&b, "- [%s]:%s\n", conflict.StorageName, conflict.Path) + } + return strings.TrimSpace(b.String()) +} diff --git a/client/bot/handlers/config.go b/client/bot/handlers/config.go index 6d8e2cfb..ecd0218e 100644 --- a/client/bot/handlers/config.go +++ b/client/bot/handlers/config.go @@ -26,6 +26,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 +55,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 +116,77 @@ 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": conflictStrategyDisplay(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: conflictStrategyDisplay(opt), + Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "conflictst", opt), + }, + }, + }) + } + markup := &tg.ReplyInlineMarkup{Rows: rows} + currentSt := effectiveUserConflictStrategy(user) + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: update.CallbackQuery.GetMsgID(), + Message: i18n.T(i18nk.BotMsgConfigPromptSelectConflictStrategy, map[string]any{ + "Strategy": conflictStrategyDisplay(currentSt), + }), + ReplyMarkup: markup, + }) + return dispatcher.EndGroups +} + +func effectiveUserConflictStrategy(user *database.User) string { + if user != nil && tcbdata.IsConflictStrategy(user.ConflictStrategy) { + return user.ConflictStrategy + } + return tcbdata.ConflictStrategyRename +} + +func conflictStrategyDisplay(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 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/msgelem/storage.go b/client/bot/handlers/utils/msgelem/storage.go index f48ade73..7f7eef26 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 df5efa00..3d8d0e5d 100644 --- a/client/bot/handlers/utils/shortcut/tftask.go +++ b/client/bot/handlers/utils/shortcut/tftask.go @@ -1,6 +1,7 @@ package shortcut import ( + "fmt" "path" "strings" @@ -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 := firstConflictStrategy(conflictStrategy) user, err := database.GetUserByChatID(ctx, userID) if err != nil { logger.Errorf("Failed to get user by chat ID: %s", err) @@ -36,6 +40,9 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage }) return dispatcher.EndGroups } + if strategy == "" { + strategy = userConflictStrategy(user) + } if user.ApplyRule && user.Rules != nil { matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file)) if !matched { @@ -60,7 +67,23 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage } startCreateTask: storagePath := path.Join(dirPath, file.Name()) + if strategy == tcbdata.ConflictStrategyAsk && stor.Exists(ctx, storagePath) { + return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, []tfile.TGFileMessage{file}, false, []string{fmt.Sprintf("[%s]:%s", stor.Name(), storagePath)}, trackMsgID) + } injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) + if strategy == tcbdata.ConflictStrategyOverwrite { + injectCtx = storage.WithOverwrite(injectCtx) + } + if strategy == tcbdata.ConflictStrategySkip && stor.Exists(ctx, storagePath) { + ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ + ID: trackMsgID, + Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{ + "Skipped": file.Name(), + }), + ReplyMarkup: nil, + }) + return dispatcher.EndGroups + } taskid := xid.New().String() task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, tftask.NewProgressTrack( @@ -97,8 +120,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 := firstConflictStrategy(conflictStrategy) user, err := database.GetUserByChatID(ctx, userID) if err != nil { logger.Errorf("Failed to get user by chat ID: %s", err) @@ -110,6 +134,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st }) return dispatcher.EndGroups } + if strategy == "" { + strategy = userConflictStrategy(user) + } useRule := user.ApplyRule && user.Rules != nil @@ -128,6 +155,8 @@ 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 @@ -152,6 +181,15 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st } if !dirPath.NeedNewForAlbum() { storPath := path.Join(dirPath.String(), file.Name()) + if fileStor.Exists(ctx, storPath) { + if strategy == tcbdata.ConflictStrategyAsk { + conflicts = append(conflicts, fmt.Sprintf("[%s]:%s", fileStor.Name(), storPath)) + } + } + if strategy == tcbdata.ConflictStrategySkip && fileStor.Exists(ctx, storPath) { + 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) @@ -189,6 +227,15 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st albumStor := afiles[0].storage for _, af := range afiles { afstorPath := path.Join(dirPath, albumDir, af.file.Name()) + if albumStor.Exists(ctx, afstorPath) { + if strategy == tcbdata.ConflictStrategyAsk { + conflicts = append(conflicts, fmt.Sprintf("[%s]:%s", albumStor.Name(), afstorPath)) + } + } + if strategy == tcbdata.ConflictStrategySkip && albumStor.Exists(ctx, afstorPath) { + 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 +251,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 +282,65 @@ 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": formatConflictPaths(conflicts)}), + ReplyMarkup: markup, + }) + return dispatcher.EndGroups +} + +func formatConflictPaths(conflicts []string) string { + const maxConflictLines = 10 + 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 firstConflictStrategy(strategies []string) string { + if len(strategies) == 0 { + return "" + } + return strategies[0] +} + +func userConflictStrategy(user *database.User) string { + if user != nil && tcbdata.IsConflictStrategy(user.ConflictStrategy) { + return user.ConflictStrategy + } + return tcbdata.ConflictStrategyRename +} + +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 b3443b8b..157544c2 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 b0218a7a..f76e8c49 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 e460d3d9..9369b52e 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 52672917..feb47671 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 7c249b32..3a533814 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 9696efca..e38cdcbc 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 ad1202c5..3f258def 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 e94ff5f9..0f222227 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 f33a3e13..cc0a2eeb 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 7ee5ec8b..0d31122f 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,12 @@ func FromContext(ctx context.Context) Storage { } return storage } + +func WithOverwrite(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxkey.OverwriteExisting, true) +} + +func ShouldOverwrite(ctx context.Context) bool { + overwrite, ok := ctx.Value(ctxkey.OverwriteExisting).(bool) + return ok && overwrite +} diff --git a/storage/local/local.go b/storage/local/local.go index c6488008..4031d597 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 c4ee16bb..d62d031e 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 e705ea7d..9b2a0be4 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 153cd569..d893c77a 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 db4216d9..d869a5dd 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)