Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
7.7 KiB
Go
228 lines
7.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
)
|
|
|
|
type Item struct {
|
|
ID int64 `json:"id"`
|
|
ListID int64 `json:"listId"`
|
|
Rank int `json:"rank"`
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle"`
|
|
Year string `json:"year"`
|
|
Image string `json:"image"` // cached file name under /images/
|
|
ImageSource string `json:"imageSource"` // where the image came from
|
|
CustomArt bool `json:"customArt"` // chosen by the owner, not the source
|
|
Provider string `json:"provider"`
|
|
ProviderID string `json:"providerId"`
|
|
ExternalURL string `json:"externalUrl"`
|
|
Description string `json:"description"` // from the source, editable
|
|
Note string `json:"note"` // the owner's "why it's here"
|
|
Extra map[string]string `json:"extra"`
|
|
}
|
|
|
|
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra`
|
|
|
|
func scanItem(row interface{ Scan(...any) error }) (Item, error) {
|
|
var it Item
|
|
var extra string
|
|
err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource, &it.CustomArt,
|
|
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &it.Note, &extra)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return it, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return it, err
|
|
}
|
|
it.Extra = map[string]string{}
|
|
_ = json.Unmarshal([]byte(extra), &it.Extra)
|
|
return it, nil
|
|
}
|
|
|
|
// Items returns a page of a list's items in rank order. limit <= 0 means all.
|
|
func (s *Store) Items(ctx context.Context, listID int64, offset, limit int) ([]Item, error) {
|
|
if limit <= 0 {
|
|
limit = -1
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? ORDER BY rank LIMIT ? OFFSET ?`, listID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Item{}
|
|
for rows.Next() {
|
|
it, err := scanItem(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ItemByID(ctx context.Context, id int64) (Item, error) {
|
|
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
|
|
}
|
|
|
|
// FindItemByProvider returns an item in the list that came from the same
|
|
// provider entry, if any.
|
|
func (s *Store) FindItemByProvider(ctx context.Context, listID int64, provider, providerID string) (Item, error) {
|
|
if provider == "" || providerID == "" {
|
|
return Item{}, ErrNotFound
|
|
}
|
|
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? AND provider = ? AND provider_id = ? LIMIT 1`,
|
|
listID, provider, providerID))
|
|
}
|
|
|
|
func (it *Item) normalize() error {
|
|
it.Title = strings.TrimSpace(it.Title)
|
|
it.Subtitle = strings.TrimSpace(it.Subtitle)
|
|
it.Year = strings.TrimSpace(it.Year)
|
|
it.Note = strings.TrimSpace(it.Note)
|
|
it.Description = strings.TrimSpace(it.Description)
|
|
it.ExternalURL = strings.TrimSpace(it.ExternalURL)
|
|
if it.Title == "" {
|
|
return invalid("Title is required")
|
|
}
|
|
if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.Description) > 10000 || len(it.ExternalURL) > 2000 {
|
|
return invalid("One of the fields is too long")
|
|
}
|
|
if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") {
|
|
return invalid("Links must start with http:// or https://")
|
|
}
|
|
clean := map[string]string{}
|
|
for k, v := range it.Extra {
|
|
if v = strings.TrimSpace(v); v != "" && len(k) <= 50 && len(v) <= 500 {
|
|
clean[k] = v
|
|
}
|
|
}
|
|
it.Extra = clean
|
|
return nil
|
|
}
|
|
|
|
func extraJSON(m map[string]string) string {
|
|
b, _ := json.Marshal(m)
|
|
return string(b)
|
|
}
|
|
|
|
// AddItem puts it at place rank: an empty place takes it as-is, a taken one
|
|
// pushes entries down to the next empty place. rank <= 0 adds it after the
|
|
// last entry.
|
|
func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
|
|
if err := it.normalize(); err != nil {
|
|
return it, err
|
|
}
|
|
pt, err := s.beginPlaces(ctx, it.ListID)
|
|
if err != nil {
|
|
return it, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
if count := len(pt.p); count >= pt.limit {
|
|
return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, pt.limit)
|
|
}
|
|
if rank <= 0 {
|
|
rank = pt.p.appendPlace(pt.limit)
|
|
} else if err := pt.checkPlace(rank); err != nil {
|
|
return it, err
|
|
} else if err := pt.p.makeRoom(rank, pt.limit); err != nil {
|
|
return it, err
|
|
}
|
|
if err := pt.write(ctx); err != nil {
|
|
return it, err
|
|
}
|
|
res, err := pt.tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra))
|
|
if err != nil {
|
|
return it, err
|
|
}
|
|
if err := pt.tx.Commit(); err != nil {
|
|
return it, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.ItemByID(ctx, id)
|
|
}
|
|
|
|
// UpdateItem saves everything except rank and list.
|
|
func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
|
|
if err := it.normalize(); err != nil {
|
|
return it, err
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, image_custom=?, external_url=?, description=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
|
|
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra), it.ID)
|
|
if err != nil {
|
|
return it, err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return it, ErrNotFound
|
|
}
|
|
cur, err := s.ItemByID(ctx, it.ID)
|
|
if err == nil {
|
|
_, err = s.db.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, cur.ListID)
|
|
}
|
|
return cur, err
|
|
}
|
|
|
|
// DeleteItem removes an entry. With closeUp, the entries right below it
|
|
// move up one place, as far as the next empty place; otherwise its place is
|
|
// left empty.
|
|
func (s *Store) DeleteItem(ctx context.Context, id int64, closeUp bool) (Item, error) {
|
|
it, err := s.ItemByID(ctx, id)
|
|
if err != nil {
|
|
return it, err
|
|
}
|
|
pt, err := s.beginPlaces(ctx, it.ListID)
|
|
if err != nil {
|
|
return it, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
if _, err := pt.tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
|
|
return it, err
|
|
}
|
|
delete(pt.before, id)
|
|
delete(pt.p, it.Rank)
|
|
if closeUp {
|
|
pt.p.closeUp(it.Rank)
|
|
}
|
|
if err := pt.write(ctx); err != nil {
|
|
return it, err
|
|
}
|
|
return it, pt.tx.Commit()
|
|
}
|
|
|
|
// ImageInUse reports whether any item or setting still references a cached
|
|
// image file.
|
|
func (s *Store) ImageInUse(ctx context.Context, name string) (bool, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM items WHERE image = ?) + (SELECT COUNT(*) FROM settings WHERE value = ?)`, name, name).Scan(&n)
|
|
return n > 0, err
|
|
}
|
|
|
|
// ListImages returns the image file names used by a list's items.
|
|
func (s *Store) ListImages(ctx context.Context, listID int64) ([]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT image FROM items WHERE list_id = ? AND image != ''`, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var n string
|
|
if err := rows.Scan(&n); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func touchList(ctx context.Context, tx *sql.Tx, listID int64) error {
|
|
_, err := tx.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, listID)
|
|
return err
|
|
}
|