Allow empty places in rankings; rebuild the list editor
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>
This commit is contained in:
1 parent
bf915c04db
commit
88373176f2
15 files changed
+1090
-233
No files matched your search
+29
-73
@@ -110,45 +110,38 @@ func extraJSON(m map[string]string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the
|
||||
// end appends. Items at or below the rank shift down by one.
|
||||
// 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
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
pt, err := s.beginPlaces(ctx, it.ListID)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var maxItems, count int
|
||||
err = tx.QueryRowContext(ctx, `SELECT max_items, (SELECT COUNT(*) FROM items WHERE list_id = lists.id) FROM lists WHERE id = ?`, it.ListID).Scan(&maxItems, &count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return it, ErrNotFound
|
||||
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 err != nil {
|
||||
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 count >= maxItems {
|
||||
return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, maxItems)
|
||||
}
|
||||
if rank <= 0 || rank > count+1 {
|
||||
rank = count + 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
|
||||
if err := pt.write(ctx); err != nil {
|
||||
return it, err
|
||||
}
|
||||
res, err := 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)
|
||||
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 := touchList(ctx, tx, it.ListID); err != nil {
|
||||
return it, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if err := pt.tx.Commit(); err != nil {
|
||||
return it, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
@@ -175,68 +168,31 @@ func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
|
||||
return cur, err
|
||||
}
|
||||
|
||||
// MoveItem moves an item to a new 1-based rank, shifting the items between.
|
||||
func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var listID int64
|
||||
var oldRank, count int
|
||||
err = tx.QueryRowContext(ctx, `SELECT list_id, rank, (SELECT COUNT(*) FROM items x WHERE x.list_id = items.list_id) FROM items WHERE id = ?`, id).Scan(&listID, &oldRank, &count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newRank < 1 {
|
||||
newRank = 1
|
||||
}
|
||||
if newRank > count {
|
||||
newRank = count
|
||||
}
|
||||
if newRank == oldRank {
|
||||
return nil
|
||||
}
|
||||
if newRank < oldRank {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ? AND rank < ?`, listID, newRank, oldRank)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ? AND rank <= ?`, listID, oldRank, newRank)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`, newRank, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := touchList(ctx, tx, listID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteItem(ctx context.Context, id int64) (Item, error) {
|
||||
// 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
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
pt, err := s.beginPlaces(ctx, it.ListID)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
|
||||
defer pt.tx.Rollback()
|
||||
if _, err := pt.tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
|
||||
return it, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ?`, it.ListID, it.Rank); err != nil {
|
||||
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
|
||||
}
|
||||
if err := touchList(ctx, tx, it.ListID); err != nil {
|
||||
return it, err
|
||||
}
|
||||
return it, tx.Commit()
|
||||
return it, pt.tx.Commit()
|
||||
}
|
||||
|
||||
// ImageInUse reports whether any item or setting still references a cached
|
||||
|
||||
Reference in new issue
Block a user