fix: harden API, DB schema, and CLI safety

- Add 'reminder' to glyph CHECK constraint (was accepted by parser but
  rejected by DB)
- Default serve bind to 127.0.0.1, add --host flag for LAN access
- Validate card_data as JSON in Store.Create/Update/Promote
- Return pagination envelope {data,total,limit,offset} from list endpoint
- Append absorb breadcrumb to source entity before soft-delete
- Add Levenshtein fuzzy match to catch command typos before routing to add
- Replace DDL string-matching migrations with versioned schema_version table
- Update web UI and API tests for envelope response format
This commit is contained in:
2026-05-19 18:30:17 -04:00
parent babf1d6620
commit e09919b679
9 changed files with 243 additions and 89 deletions
+55
View File
@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"fmt"
"os" "os"
"strings" "strings"
@@ -26,6 +27,10 @@ func Execute() error {
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ") isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
if first != "help" && first != "completion" && if first != "help" && first != "completion" &&
!isFlag && !isSubcommand(first) { !isFlag && !isSubcommand(first) {
if near := nearSubcommand(first); near != "" {
fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near)
os.Exit(1)
}
// "--" stops cobra from parsing glyph prefixes like "-" as flags // "--" stops cobra from parsing glyph prefixes like "-" as flags
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...)) rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
} }
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
return false return false
} }
func nearSubcommand(name string) string {
for _, c := range rootCmd.Commands() {
if d := editDist(name, c.Name()); d > 0 && d <= 2 {
return c.Name()
}
for _, alias := range c.Aliases {
if d := editDist(name, alias); d > 0 && d <= 2 {
return alias
}
}
}
return ""
}
func editDist(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr := make([]int, lb+1)
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
ins := curr[j-1] + 1
del := prev[j] + 1
sub := prev[j-1] + cost
curr[j] = ins
if del < curr[j] {
curr[j] = del
}
if sub < curr[j] {
curr[j] = sub
}
}
prev = curr
}
return prev[lb]
}
func init() { func init() {
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(lsCmd) rootCmd.AddCommand(lsCmd)
+5 -3
View File
@@ -19,6 +19,7 @@ var WebFS fs.FS
var ( var (
servePort int servePort int
serveHost string
serveDev bool serveDev bool
tlsCert string tlsCert string
tlsKey string tlsKey string
@@ -32,6 +33,7 @@ var serveCmd = &cobra.Command{
func init() { func init() {
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)") serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)")
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development") serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file") serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file") serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
@@ -70,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
router = api.NewRouter(store, serveDev, WebFS) router = api.NewRouter(store, serveDev, WebFS)
} }
addr := fmt.Sprintf(":%d", port) addr := fmt.Sprintf("%s:%d", serveHost, port)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: router, Handler: router,
@@ -81,9 +83,9 @@ func runServe(_ *cobra.Command, _ []string) error {
go func() { go func() {
if useTLS { if useTLS {
fmt.Printf("nib serving on https://localhost%s\n", addr) fmt.Printf("nib serving on https://%s\n", addr)
} else { } else {
fmt.Printf("nib serving on http://localhost%s\n", addr) fmt.Printf("nib serving on http://%s\n", addr)
} }
if serveDev { if serveDev {
fmt.Println(" CORS enabled (dev mode)") fmt.Println(" CORS enabled (dev mode)")
+3 -3
View File
@@ -4,6 +4,9 @@ go 1.24.4
require ( require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@@ -12,10 +15,7 @@ require (
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
-2
View File
@@ -74,8 +74,6 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+21 -14
View File
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store return srv, store
} }
type listEnvelope struct {
Data []EntityResponse `json:"data"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
t.Helper()
var env listEnvelope
json.NewDecoder(resp.Body).Decode(&env)
return env.Data
}
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper() t.Helper()
b, err := json.Marshal(body) b, err := json.Marshal(body)
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 2 { if len(entities) != 2 {
t.Fatalf("expected 2, got %d", len(entities)) t.Fatalf("expected 2, got %d", len(entities))
} }
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 { if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities)) t.Fatalf("expected 1, got %d", len(entities))
} }
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 { if len(entities) != 1 {
t.Fatalf("expected 1 card, got %d", len(entities)) t.Fatalf("expected 1 card, got %d", len(entities))
} }
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var page1 []EntityResponse page1 := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&page1)
resp.Body.Close() resp.Body.Close()
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2") resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var page2 []EntityResponse page2 := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&page2)
resp.Body.Close() resp.Body.Close()
if len(page1) != 2 || len(page2) != 2 { if len(page1) != 2 || len(page2) != 2 {
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var entities []EntityResponse entities := decodeList(t, listResp)
json.NewDecoder(listResp.Body).Decode(&entities)
listResp.Body.Close() listResp.Body.Close()
for _, ent := range entities { for _, ent := range entities {
if ent.ID == source.ID { if ent.ID == source.ID {
@@ -686,8 +694,7 @@ func TestListEntities_TitleInResponse(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 { if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities)) t.Fatalf("expected 1, got %d", len(entities))
} }
+29 -3
View File
@@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc {
} }
p.Offset = offset p.Offset = offset
} }
if p.Limit <= 0 {
p.Limit = 50
}
total, err := store.Count(p)
if err != nil {
writeInternalError(w, err)
return
}
entities, err := store.List(p) entities, err := store.List(p)
if err != nil { if err != nil {
@@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc {
return return
} }
resp := make([]EntityResponse, len(entities)) items := make([]EntityResponse, len(entities))
for i, e := range entities { for i, e := range entities {
resp[i] = entityToResponse(e) items[i] = entityToResponse(e)
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, map[string]any{
"data": items,
"total": total,
"limit": p.Limit,
"offset": p.Offset,
})
} }
} }
@@ -161,6 +175,10 @@ func createEntity(store *db.Store) http.HandlerFunc {
} }
if err := store.Create(e); err != nil { if err := store.Create(e); err != nil {
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
@@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
} }
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
@@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return return
} }
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
+70 -27
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -16,6 +15,7 @@ var (
ErrAlreadyPromoted = errors.New("invalid_promote") ErrAlreadyPromoted = errors.New("invalid_promote")
ErrAlreadyFluid = errors.New("invalid_demote") ErrAlreadyFluid = errors.New("invalid_demote")
ErrTargetCrystallized = errors.New("invalid_absorb") ErrTargetCrystallized = errors.New("invalid_absorb")
ErrInvalidCardData = errors.New("invalid_card_data")
) )
type Store struct { type Store struct {
@@ -51,22 +51,23 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *Store) migrate() error { const currentSchema = 3
_, err := s.db.Exec(`
var migrations = []func(db *sql.DB) error{
// v1: initial schema
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS entities ( CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
modified_at TEXT NOT NULL, modified_at TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
glyph TEXT NOT NULL glyph TEXT NOT NULL,
CHECK (glyph IN ('todo', 'event', 'note')),
time_anchor TEXT, time_anchor TEXT,
completed_at TEXT, completed_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT, deleted_at TEXT,
card_type TEXT card_type TEXT,
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note')
OR card_type IS NULL),
card_data TEXT, card_data TEXT,
use_count INTEGER NOT NULL DEFAULT 0, use_count INTEGER NOT NULL DEFAULT 0,
last_used_at TEXT last_used_at TEXT
@@ -86,29 +87,29 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
ON entity_tags(tag); ON entity_tags(tag);
`) `)
if err != nil {
return err return err
} },
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) // v2: add title and description columns
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) func(db *sql.DB) error {
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
return nil
},
// Migrate CHECK constraint to include 'note' card type // v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
var needsMigrate bool func(db *sql.DB) error {
row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`) tx, err := db.Begin()
var ddl string
if row.Scan(&ddl) == nil {
hasNote := strings.Contains(ddl, "'link', 'note'")
hasModified := strings.Contains(ddl, "modified_at")
needsMigrate = !hasNote || !hasModified
}
if needsMigrate {
tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
// Disable FK checks during rebuild to avoid dangling references
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
return fmt.Errorf("migrate rename: %w", err) return fmt.Errorf("migrate rename: %w", err)
} }
@@ -118,7 +119,7 @@ func (s *Store) migrate() error {
modified_at TEXT NOT NULL, modified_at TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
glyph TEXT NOT NULL glyph TEXT NOT NULL
CHECK (glyph IN ('todo', 'event', 'note')), CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
time_anchor TEXT, time_anchor TEXT,
completed_at TEXT, completed_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
@@ -140,12 +141,54 @@ func (s *Store) migrate() error {
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil { if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
return fmt.Errorf("migrate drop: %w", err) return fmt.Errorf("migrate drop: %w", err)
} }
if err := tx.Commit(); err != nil {
return fmt.Errorf("migrate commit: %w", err) // Rebuild entity_tags to point FK at new entities table
if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags rename: %w", err)
}
if _, err := tx.Exec(`CREATE TABLE entity_tags (
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (entity_id, tag)
)`); err != nil {
return fmt.Errorf("migrate tags create: %w", err)
}
if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags copy: %w", err)
}
if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags drop: %w", err)
}
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("migrate fk on: %w", err)
}
return tx.Commit()
},
}
func (s *Store) migrate() error {
s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`)
var version int
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version)
if err != nil {
version = 0
}
for i := version; i < len(migrations); i++ {
if err := migrations[i](s.db); err != nil {
return fmt.Errorf("migration %d: %w", i+1, err)
} }
} }
return nil if version == 0 {
_, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations))
} else if len(migrations) > version {
_, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations))
}
return err
} }
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
+28 -5
View File
@@ -104,6 +104,9 @@ type EntityUpdate struct {
} }
func (s *Store) Create(e *Entity) error { func (s *Store) Create(e *Entity) error {
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
return ErrInvalidCardData
}
now := time.Now().UTC() now := time.Now().UTC()
e.ID = nibulid.New() e.ID = nibulid.New()
e.CreatedAt = now e.CreatedAt = now
@@ -179,7 +182,7 @@ func (s *Store) Get(id string) (*Entity, error) {
return e, nil return e, nil
} }
func (s *Store) List(params ListParams) ([]*Entity, error) { func listWhere(params ListParams) (string, []any) {
var where []string var where []string
var args []any var args []any
@@ -214,10 +217,23 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
args = append(args, string(*params.CardTypeFilter)) args = append(args, string(*params.CardTypeFilter))
} }
whereClause := "" clause := ""
if len(where) > 0 { if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ") clause = "WHERE " + strings.Join(where, " AND ")
} }
return clause, args
}
func (s *Store) Count(params ListParams) (int, error) {
whereClause, args := listWhere(params)
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
var count int
err := s.db.QueryRow(query, args...).Scan(&count)
return count, err
}
func (s *Store) List(params ListParams) ([]*Entity, error) {
whereClause, args := listWhere(params)
orderCol := "e.created_at" orderCol := "e.created_at"
switch params.Sort { switch params.Sort {
@@ -336,6 +352,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
args = append(args, string(*u.CardType)) args = append(args, string(*u.CardType))
} }
if u.CardData != nil { if u.CardData != nil {
if !json.Valid([]byte(*u.CardData)) {
return ErrInvalidCardData
}
sets = append(sets, "card_data = ?") sets = append(sets, "card_data = ?")
args = append(args, *u.CardData) args = append(args, *u.CardData)
} }
@@ -370,6 +389,9 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
dataVal := "{}" dataVal := "{}"
if cardData != nil { if cardData != nil {
if !json.Valid([]byte(*cardData)) {
return ErrInvalidCardData
}
dataVal = *cardData dataVal = *cardData
} }
@@ -473,8 +495,9 @@ func (s *Store) Absorb(targetID, sourceID string) error {
} }
} }
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?", absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
now, sourceID); err != nil { if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
absorbNote, now, now, sourceID); err != nil {
return err return err
} }
+6 -6
View File
@@ -1247,9 +1247,9 @@
async function loadEntities() { async function loadEntities() {
const params = buildListParams(0); const params = buildListParams(0);
const results = await api.listEntities(params); const resp = await api.listEntities(params);
state.entities = results; state.entities = resp.data;
state.hasMore = results.length === PAGE_SIZE; state.hasMore = (resp.offset + resp.data.length) < resp.total;
state.selectedIndex = -1; state.selectedIndex = -1;
renderEntityList(); renderEntityList();
renderDetailPane(); renderDetailPane();
@@ -1258,9 +1258,9 @@
async function loadMore() { async function loadMore() {
const params = buildListParams(state.entities.length); const params = buildListParams(state.entities.length);
const results = await api.listEntities(params); const resp = await api.listEntities(params);
state.entities = state.entities.concat(results); state.entities = state.entities.concat(resp.data);
state.hasMore = results.length === PAGE_SIZE; state.hasMore = (resp.offset + resp.data.length) < resp.total;
renderEntityList(); renderEntityList();
} }