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 }