Files
nib-v1/internal/api/entities.go
T
lerko f5b46585c3 feat: add title and description fields to capture grammar
Implement | prefix for titles and // separator for descriptions
across the full stack: parser, schema, API, CLI, and web frontend.

- Parser: line-aware extraction for |title, |title // desc,
  // leading desc, body // inline desc. URL-safe (skips :// lines).
  Modifiers (#tag, @time, ^card) extracted from all segments.
- Schema: ALTER TABLE migration adds title, description columns
- DB: Entity/EntityUpdate structs, all CRUD queries updated
- API: title/description on create/update/response, body validation
  relaxed (title-only entries valid)
- CLI: shows title as scan label when present
- Web: parseInput mirrors Go parser, list shows title, detail pane
  renders title + description with double-click inline editing
- Tests: 10 new cases (grammar, entity, API) — 71 total, all pass
2026-05-15 20:52:58 -04:00

394 lines
10 KiB
Go

package api
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/lerko/nib/internal/db"
)
type CreateEntityRequest struct {
Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph *string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"`
Tags []string `json:"tags"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
}
type UpdateEntityRequest struct {
Body *string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph *string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"`
Tags *[]string `json:"tags"`
Pinned *bool `json:"pinned"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
}
type PromoteRequest struct {
CardType string `json:"card_type"`
CardData *string `json:"card_data"`
}
func listEntities(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := db.DefaultListParams()
if tag := r.URL.Query().Get("tag"); tag != "" {
p.Tag = &tag
}
if date := r.URL.Query().Get("date"); date != "" {
if _, err := time.Parse("2006-01-02", date); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "bad date format, use YYYY-MM-DD")
return
}
p.Date = &date
}
if from := r.URL.Query().Get("from"); from != "" {
if _, err := time.Parse("2006-01-02", from); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "bad from format, use YYYY-MM-DD")
return
}
p.From = &from
}
if to := r.URL.Query().Get("to"); to != "" {
if _, err := time.Parse("2006-01-02", to); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "bad to format, use YYYY-MM-DD")
return
}
p.To = &to
}
if r.URL.Query().Get("cards_only") == "true" {
p.CardsOnly = true
}
if r.URL.Query().Get("include_deleted") == "true" {
p.IncludeDeleted = true
}
if sort := r.URL.Query().Get("sort"); sort != "" {
if sort != "created" && sort != "use_count" {
writeError(w, http.StatusBadRequest, "invalid_input", "sort must be 'created' or 'use_count'")
return
}
p.Sort = sort
}
if order := r.URL.Query().Get("order"); order != "" {
if order != "asc" && order != "desc" {
writeError(w, http.StatusBadRequest, "invalid_input", "order must be 'asc' or 'desc'")
return
}
p.Order = order
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 {
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
return
}
p.Limit = limit
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
writeError(w, http.StatusBadRequest, "invalid_input", "offset must be a non-negative integer")
return
}
p.Offset = offset
}
entities, err := store.List(p)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
resp := make([]EntityResponse, len(entities))
for i, e := range entities {
resp[i] = entityToResponse(e)
}
writeJSON(w, http.StatusOK, resp)
}
}
func createEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req CreateEntityRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Body == "" && req.Title == nil {
writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required")
return
}
glyph := db.GlyphNote
if req.Glyph != nil {
if !db.ValidGlyph(*req.Glyph) {
writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value")
return
}
glyph = db.Glyph(*req.Glyph)
}
e := &db.Entity{
Body: req.Body,
Title: req.Title,
Description: req.Description,
Glyph: glyph,
TimeAnchor: req.TimeAnchor,
Tags: req.Tags,
}
if req.CardType != nil {
if !db.ValidCardType(*req.CardType) {
writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value")
return
}
ct := db.CardType(*req.CardType)
e.CardType = &ct
e.CardData = req.CardData
}
if err := store.Create(e); err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusCreated, entityToResponse(e))
}
}
func getEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
e, err := store.Get(id)
if err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}
func updateEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req UpdateEntityRequest
if !decodeJSON(w, r, &req) {
return
}
u := &db.EntityUpdate{}
u.Body = req.Body
u.Title = req.Title
u.Description = req.Description
u.Tags = req.Tags
u.Pinned = req.Pinned
u.CardData = req.CardData
if req.Glyph != nil {
if !db.ValidGlyph(*req.Glyph) {
writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value")
return
}
g := db.Glyph(*req.Glyph)
u.Glyph = &g
}
if req.TimeAnchor != nil {
u.TimeAnchor = req.TimeAnchor
}
if req.CardType != nil {
if !db.ValidCardType(*req.CardType) {
writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value")
return
}
ct := db.CardType(*req.CardType)
u.CardType = &ct
}
if err := store.Update(id, u); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}
type DeleteResponse struct {
Result string `json:"result"`
}
func deleteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
result, err := store.SoftDelete(id)
if err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
label := "soft"
if result == db.DeletedHard {
label = "hard"
}
writeJSON(w, http.StatusOK, DeleteResponse{Result: label})
}
}
func promoteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req PromoteRequest
if !decodeJSON(w, r, &req) {
return
}
if req.CardType == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "card_type is required")
return
}
if !db.ValidCardType(req.CardType) {
writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value")
return
}
if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
if err == db.ErrAlreadyPromoted {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}
func demoteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := store.Demote(id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
if err == db.ErrAlreadyFluid {
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}
type AbsorbRequest struct {
SourceID string `json:"source_id"`
}
func absorbEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req AbsorbRequest
if !decodeJSON(w, r, &req) {
return
}
if req.SourceID == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "source_id is required")
return
}
if req.SourceID == id {
writeError(w, http.StatusBadRequest, "invalid_input", "target and source must be different entities")
return
}
if err := store.Absorb(id, req.SourceID); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
return
}
if err == db.ErrTargetCrystallized {
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}
func useEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := store.IncrementUse(id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}