fix: batch tag queries, inline edit, delete response, SPA catch-all, link glyph
- Fix N+1 tag query in List() with batched IN clause
- Add inline body editing in web detail pane (dblclick or e key)
- Delete API returns {result: "soft"|"hard"} with 200 instead of 204
- SPA handler serves index.html for all extensionless paths
- Link glyph changed from emoji 🔗 to unicode ↗ for terminal alignment
- Capture bar contrast and hover glow increased
- Comment on load-bearing "--" in root.go
This commit is contained in:
@@ -235,16 +235,25 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
||||
// Soft delete
|
||||
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
var delResp DeleteResponse
|
||||
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("soft delete: expected 204, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("soft delete: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if delResp.Result != "soft" {
|
||||
t.Fatalf("soft delete: expected result 'soft', got %q", delResp.Result)
|
||||
}
|
||||
|
||||
// Hard delete
|
||||
resp, _ = http.DefaultClient.Do(req)
|
||||
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("hard delete: expected 204, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hard delete: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if delResp.Result != "hard" {
|
||||
t.Fatalf("hard delete: expected result 'hard', got %q", delResp.Result)
|
||||
}
|
||||
|
||||
// Gone
|
||||
|
||||
@@ -214,10 +214,14 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
_, err := store.SoftDelete(id)
|
||||
result, err := store.SoftDelete(id)
|
||||
if err != nil {
|
||||
if err == db.ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
@@ -226,7 +230,11 @@ func deleteEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
label := "soft"
|
||||
if result == db.DeletedHard {
|
||||
label = "hard"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, DeleteResponse{Result: label})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -45,8 +46,8 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
|
||||
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" || path == "/cards" {
|
||||
p := r.URL.Path
|
||||
if p == "/" || path.Ext(p) == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(indexHTML)
|
||||
return
|
||||
|
||||
+40
-6
@@ -269,12 +269,8 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
entities = append(entities, e)
|
||||
}
|
||||
|
||||
for _, e := range entities {
|
||||
tags, err := s.loadTags(e.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Tags = tags
|
||||
if err := s.batchLoadTags(entities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entities, nil
|
||||
@@ -452,6 +448,44 @@ func (s *Store) Resolve(prefix string) (string, error) {
|
||||
|
||||
// helpers
|
||||
|
||||
func (s *Store) batchLoadTags(entities []*Entity) error {
|
||||
if len(entities) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
idMap := make(map[string]*Entity, len(entities))
|
||||
placeholders := make([]string, len(entities))
|
||||
args := make([]any, len(entities))
|
||||
for i, e := range entities {
|
||||
e.Tags = []string{}
|
||||
idMap[e.ID] = e
|
||||
placeholders[i] = "?"
|
||||
args[i] = e.ID
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT entity_id, tag FROM entity_tags WHERE entity_id IN (%s) ORDER BY entity_id, tag",
|
||||
strings.Join(placeholders, ","),
|
||||
)
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var entityID, tag string
|
||||
if err := rows.Scan(&entityID, &tag); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, ok := idMap[entityID]; ok {
|
||||
e.Tags = append(e.Tags, tag)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) loadTags(entityID string) ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,7 +13,7 @@ var cardGlyphMap = map[db.CardType]string{
|
||||
db.CardTemplate: "◈",
|
||||
db.CardChecklist: "☐",
|
||||
db.CardDecision: "⚖",
|
||||
db.CardLink: "🔗",
|
||||
db.CardLink: "↗",
|
||||
}
|
||||
|
||||
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
||||
|
||||
Reference in New Issue
Block a user