c8e18f0bc1
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
97 lines
2.5 KiB
Go
97 lines
2.5 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
const maxBodySize = 1 << 20 // 1 MB
|
|
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type EntityResponse struct {
|
|
ID string `json:"id"`
|
|
CreatedAt string `json:"created_at"`
|
|
ModifiedAt string `json:"modified_at"`
|
|
Body string `json:"body"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Glyph string `json:"glyph"`
|
|
TimeAnchor *string `json:"time_anchor"`
|
|
CompletedAt *string `json:"completed_at"`
|
|
Pinned bool `json:"pinned"`
|
|
DeletedAt *string `json:"deleted_at"`
|
|
Tags []string `json:"tags"`
|
|
CardType *string `json:"card_type"`
|
|
CardData *string `json:"card_data"`
|
|
UseCount int `json:"use_count"`
|
|
LastUsedAt *string `json:"last_used_at"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, ErrorResponse{Error: code, Message: message})
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
|
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeInternalError(w http.ResponseWriter, err error) {
|
|
log.Printf("internal error: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
|
}
|
|
|
|
func entityToResponse(e *db.Entity) EntityResponse {
|
|
resp := EntityResponse{
|
|
ID: e.ID,
|
|
CreatedAt: e.CreatedAt.Format(time.RFC3339),
|
|
ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
|
|
Body: e.Body,
|
|
Title: e.Title,
|
|
Description: e.Description,
|
|
Glyph: string(e.Glyph),
|
|
Pinned: e.Pinned,
|
|
Tags: e.Tags,
|
|
UseCount: e.UseCount,
|
|
}
|
|
if resp.Tags == nil {
|
|
resp.Tags = []string{}
|
|
}
|
|
resp.TimeAnchor = e.TimeAnchor
|
|
resp.CompletedAt = formatTimeRespPtr(e.CompletedAt)
|
|
resp.DeletedAt = formatTimeRespPtr(e.DeletedAt)
|
|
resp.LastUsedAt = formatTimeRespPtr(e.LastUsedAt)
|
|
if e.CardType != nil {
|
|
s := string(*e.CardType)
|
|
resp.CardType = &s
|
|
}
|
|
resp.CardData = e.CardData
|
|
return resp
|
|
}
|
|
|
|
func formatTimeRespPtr(t *time.Time) *string {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
s := t.Format(time.RFC3339)
|
|
return &s
|
|
}
|