mattermost-plugin-shelfmark/server/kvstore.go
Felipe M. 3691d9eba2
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
ci / plugin-ci (push) Has been cancelled
Add book request functionality to RHS panel
Server:
- Add HTTP API endpoints (ServeHTTP) for search, releases, request,
  config, and cover image proxy
- Add SearchBooksWithLimit to shelfmark client for multi-result searches
- Add SelectedRelease field to DownloadTask for user-chosen releases
- Honor SelectedRelease in background job instead of auto-selecting

Webapp:
- Add Client4-based API client for plugin endpoints
- Implement 3-view RHS panel: search, results, and book detail
- Search view with language dropdown (English/Español) and query input
- Results view with cover thumbnails, titles, and authors
- Detail view with release list and per-release request buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:52:11 +01:00

290 lines
8.1 KiB
Go

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
}