package main import ( "encoding/json" "fmt" "slices" "sync" "time" "github.com/mattermost/mattermost/server/public/pluginapi" "git.nakama.town/fmartingr/mattermost-plugin-shelfmark/server/shelfmark" ) const ( // kvTaskPrefix is the KV store key prefix for download tasks. kvTaskPrefix = "dl_task_" // kvTaskIndexKey stores the list of active task IDs. kvTaskIndexKey = "dl_task_index" // taskMaxAge is the maximum time a task can be pending before it's considered stale. taskMaxAge = 30 * time.Minute ) // DownloadTaskStatus represents the state of a download task. type DownloadTaskStatus string const ( TaskStatusPending DownloadTaskStatus = "pending" // Waiting for releases lookup TaskStatusQueued DownloadTaskStatus = "queued" // Download queued on Shelfmark TaskStatusDownloading DownloadTaskStatus = "downloading" // Download in progress TaskStatusComplete DownloadTaskStatus = "complete" // Download complete, file ready TaskStatusUploaded DownloadTaskStatus = "uploaded" // File uploaded to Mattermost TaskStatusFailed DownloadTaskStatus = "failed" // Download or upload failed ) // DownloadTask represents a pending book download task persisted in the KV store. type DownloadTask struct { // ID is a unique identifier for this task (typically the Shelfmark source_id). ID string `json:"id"` // ShelfmarkTaskID is the task/source_id on the Shelfmark server. ShelfmarkTaskID string `json:"shelfmark_task_id"` // PostID is the Mattermost post ID of the book announcement post. // Empty until the post is created when the file is ready. PostID string `json:"post_id"` // ChannelID is the Mattermost channel where the post will be created. ChannelID string `json:"channel_id"` // BookTitle is the title of the book. BookTitle string `json:"book_title"` // BookProvider is the metadata provider name. BookProvider string `json:"book_provider"` // BookProviderID is the book's ID on the metadata provider. BookProviderID string `json:"book_provider_id"` // BookCoverURL is the Shelfmark-relative URL for the cover image. BookCoverURL string `json:"book_cover_url,omitempty"` // BookAuthors is the list of authors from the search result. BookAuthors []string `json:"book_authors,omitempty"` // Language is the ISO language code for filtering releases (e.g., "en", "es"). Language string `json:"language,omitempty"` // LocalizedTitle is the book title in the requested language, set after releases // are fetched. Falls back to BookTitle if unavailable. LocalizedTitle string `json:"localized_title,omitempty"` // Status is the current state of the task. Status DownloadTaskStatus `json:"status"` // ErrorMessage stores any error message if the task failed. ErrorMessage string `json:"error_message,omitempty"` // CreatedAt is when the task was created. CreatedAt time.Time `json:"created_at"` // UpdatedAt is when the task was last updated. UpdatedAt time.Time `json:"updated_at"` // RequestedBy is the Mattermost user ID of the user who requested the book. RequestedBy string `json:"requested_by"` // RequesterLocale is the Mattermost locale of the requesting user, stored at task // creation so background job messages can be localized without extra API calls. RequesterLocale string `json:"requester_locale,omitempty"` // SelectedRelease is the release chosen by the user via the RHS panel. // When set, the background job uses this release instead of auto-selecting. SelectedRelease *shelfmark.Release `json:"selected_release,omitempty"` } // EffectiveTitle returns the localized title if available, otherwise the original book title. func (t *DownloadTask) EffectiveTitle() string { if t.LocalizedTitle != "" { return t.LocalizedTitle } return t.BookTitle } // taskStore provides methods to persist and retrieve download tasks. type taskStore struct { client *pluginapi.Client mu sync.Mutex logFunc func(msg string, keyvals ...string) } // newTaskStore creates a new task store. func newTaskStore(client *pluginapi.Client, logFunc func(msg string, keyvals ...string)) *taskStore { return &taskStore{client: client, logFunc: logFunc} } // SaveTask persists a download task to the KV store and adds it to the index. func (s *taskStore) SaveTask(task *DownloadTask) error { s.mu.Lock() defer s.mu.Unlock() task.UpdatedAt = time.Now() data, err := json.Marshal(task) if err != nil { return fmt.Errorf("failed to marshal task: %w", err) } key := kvTaskPrefix + task.ID if _, setErr := s.client.KV.Set(key, data); setErr != nil { return fmt.Errorf("failed to save task: %w", setErr) } // Add to index if not already present. index, err := s.getIndex() if err != nil { return fmt.Errorf("failed to get task index: %w", err) } if !slices.Contains(index, task.ID) { index = append(index, task.ID) if err := s.saveIndex(index); err != nil { return fmt.Errorf("failed to save task index: %w", err) } } return nil } // GetTask retrieves a download task from the KV store. func (s *taskStore) GetTask(id string) (*DownloadTask, error) { key := kvTaskPrefix + id var data []byte if err := s.client.KV.Get(key, &data); err != nil { return nil, fmt.Errorf("failed to get task: %w", err) } if data == nil { return nil, nil } var task DownloadTask if err := json.Unmarshal(data, &task); err != nil { return nil, fmt.Errorf("failed to unmarshal task: %w", err) } return &task, nil } // DeleteTask removes a download task from the KV store and the index. func (s *taskStore) DeleteTask(id string) error { s.mu.Lock() defer s.mu.Unlock() return s.deleteTaskLocked(id) } // deleteTaskLocked removes a task. Must be called with s.mu held. func (s *taskStore) deleteTaskLocked(id string) error { key := kvTaskPrefix + id if err := s.client.KV.Delete(key); err != nil { return fmt.Errorf("failed to delete task: %w", err) } index, err := s.getIndex() if err != nil { return err } newIndex := make([]string, 0, len(index)) for _, taskID := range index { if taskID != id { newIndex = append(newIndex, taskID) } } return s.saveIndex(newIndex) } // ListActiveTasks returns all tasks that are still being processed // (pending, queued, downloading, or complete). func (s *taskStore) ListActiveTasks() ([]*DownloadTask, error) { s.mu.Lock() defer s.mu.Unlock() index, err := s.getIndex() if err != nil { return nil, err } var tasks []*DownloadTask var toDelete []string for _, id := range index { task, err := s.GetTask(id) if err != nil { if s.logFunc != nil { s.logFunc("Failed to load task from KV store", "task_id", id, "error", err.Error()) } continue } if task == nil { toDelete = append(toDelete, id) continue } switch task.Status { case TaskStatusUploaded, TaskStatusFailed: toDelete = append(toDelete, id) default: tasks = append(tasks, task) } } // Batch-remove terminal/orphaned tasks from index. if len(toDelete) > 0 { s.removeFromIndexLocked(toDelete) } return tasks, nil } // removeFromIndexLocked removes multiple IDs from the index in a single write. // Must be called with s.mu held. func (s *taskStore) removeFromIndexLocked(idsToRemove []string) { index, err := s.getIndex() if err != nil { return } removeSet := make(map[string]struct{}, len(idsToRemove)) for _, id := range idsToRemove { removeSet[id] = struct{}{} // Also delete the individual task data. key := kvTaskPrefix + id _ = s.client.KV.Delete(key) } newIndex := make([]string, 0, len(index)) for _, id := range index { if _, remove := removeSet[id]; !remove { newIndex = append(newIndex, id) } } _ = s.saveIndex(newIndex) } // getIndex retrieves the task index from the KV store. func (s *taskStore) getIndex() ([]string, error) { var index []string if err := s.client.KV.Get(kvTaskIndexKey, &index); err != nil { return nil, fmt.Errorf("failed to get task index: %w", err) } if index == nil { return []string{}, nil } return index, nil } // saveIndex persists the task index to the KV store. func (s *taskStore) saveIndex(index []string) error { if _, err := s.client.KV.Set(kvTaskIndexKey, index); err != nil { return fmt.Errorf("failed to save task index: %w", err) } return nil }