From 03094706c374c3a81ee061af49bea1b0a82dce9d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 12:37:13 -0400 Subject: [PATCH] fix: batch tag queries, inline edit, delete response, SPA catch-all, link glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/root.go | 1 + internal/api/api_test.go | 17 +++++++++---- internal/api/entities.go | 12 ++++++++-- internal/api/router.go | 5 ++-- internal/db/entities.go | 46 ++++++++++++++++++++++++++++++----- internal/display/glyph.go | 2 +- web/app.js | 50 +++++++++++++++++++++++++++++++++++---- web/index.html | 2 +- web/style.css | 40 ++++++++++++++++++++++++++++--- 9 files changed, 152 insertions(+), 23 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 95f1471..7424fac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ func Execute() error { isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ") if first != "help" && first != "completion" && !isFlag && !isSubcommand(first) { + // "--" stops cobra from parsing glyph prefixes like "-" as flags rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...)) } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 6b720e0..bcb9b8e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 diff --git a/internal/api/entities.go b/internal/api/entities.go index 19d0d41..0a93c62 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -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}) } } diff --git a/internal/api/router.go b/internal/api/router.go index f06b503..035d861 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 diff --git a/internal/db/entities.go b/internal/db/entities.go index 152d30f..34e453f 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -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 { diff --git a/internal/display/glyph.go b/internal/display/glyph.go index 422046a..02604d4 100644 --- a/internal/display/glyph.go +++ b/internal/display/glyph.go @@ -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 { diff --git a/web/app.js b/web/app.js index 80ee598..cc08fbb 100644 --- a/web/app.js +++ b/web/app.js @@ -4,7 +4,7 @@ const GLYPHS = { note: '◦', todo: '▸', event: '◇', snippet: '◆', template: '◈', checklist: '☐', - decision: '⚖', link: '🔗', + decision: '⚖', link: '↗', }; const GLYPH_CLASSES = { @@ -278,11 +278,14 @@ ${shortId} ${e.time_anchor ? `@${e.time_anchor}` : ''} -
${escHtml(e.body)}
+
${escHtml(e.body)}
${tags ? `
${tags}
` : ''} ${cardContent}
${actions}
`; + + const bodyEl = pane.querySelector('.detail-body'); + if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); } function renderCardContent(e) { @@ -334,6 +337,40 @@ } } + // ========== Inline edit ========== + + function startEditBody() { + const e = state.entities[state.selectedIndex]; + if (!e) return; + const el = $(`.detail-body[data-id="${e.id}"]`); + if (!el || el.tagName === 'TEXTAREA') return; + + const ta = document.createElement('textarea'); + ta.className = 'detail-body-edit'; + ta.value = e.body; + el.replaceWith(ta); + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + + async function save() { + const newBody = ta.value.trim(); + if (newBody && newBody !== e.body) { + await api.updateEntity(e.id, { body: newBody }); + await loadEntities(); + const idx = state.entities.findIndex(x => x.id === e.id); + if (idx >= 0) selectEntity(idx); + } else { + renderDetailPane(); + } + } + + ta.addEventListener('blur', save); + ta.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); ta.removeEventListener('blur', save); save(); } + if (ev.key === 'Escape') { ev.preventDefault(); ta.removeEventListener('blur', save); renderDetailPane(); } + }); + } + // ========== Actions ========== function selectEntity(idx) { @@ -498,8 +535,9 @@ const captureInput = $('#capture-input'); document.addEventListener('keydown', (ev) => { - if (document.activeElement === captureInput) { - if (ev.key === 'Escape') captureInput.blur(); + if (document.activeElement === captureInput || + document.activeElement.classList.contains('detail-body-edit')) { + if (ev.key === 'Escape') document.activeElement.blur(); return; } @@ -544,6 +582,10 @@ } break; } + case 'e': { + startEditBody(); + break; + } case '1': switchView('stream'); break; case '2': switchView('cards'); break; } diff --git a/web/index.html b/web/index.html index df2ae4e..e92bcc2 100644 --- a/web/index.html +++ b/web/index.html @@ -51,7 +51,7 @@ decision diff --git a/web/style.css b/web/style.css index 719fe04..5223578 100644 --- a/web/style.css +++ b/web/style.css @@ -92,22 +92,28 @@ nav { #capture-input { width: 100%; background: var(--bg); - border: 1px solid var(--border); + border: 1px solid var(--text-muted); color: var(--text); padding: 8px 12px; border-radius: var(--radius); font-family: var(--font-mono); font-size: 13px; outline: none; - transition: border-color 0.15s; + transition: border-color 0.15s, box-shadow 0.15s; +} + +#capture-input:hover { + border-color: var(--accent-dim); + box-shadow: 0 0 0 1px var(--accent-dim); } #capture-input:focus { border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); } #capture-input::placeholder { - color: var(--text-muted); + color: var(--text-dim); } /* Main layout */ @@ -273,6 +279,34 @@ main { margin-bottom: 16px; white-space: pre-wrap; word-break: break-word; + cursor: text; + border-radius: var(--radius); + padding: 4px 6px; + margin-left: -6px; + transition: background 0.1s; +} + +.detail-body:hover { + background: var(--bg-hover); +} + +.detail-body-edit { + display: block; + width: 100%; + min-height: 80px; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.7; + margin-bottom: 16px; + padding: 6px 8px; + background: var(--bg); + color: var(--text); + border: 1px solid var(--accent); + border-radius: var(--radius); + outline: none; + resize: vertical; + white-space: pre-wrap; + word-break: break-word; } .detail-tags { -- 2.52.0