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>
290 lines
8.1 KiB
Go
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
|
|
}
|