From 2b177eeae9d304ba0ef6430ce5ec864ffa5537a4 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 12:49:43 -0400 Subject: [PATCH] feat(cards): add 'note' card type for readable markdown content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New card type renders body as styled markdown with no copy/fill/run affordance. Glyph: ¶, color: --note. Migration uses transaction to safely rebuild table constraint. Checks both 'note' presence and modified_at column to catch partial migration state. --- internal/db/db.go | 56 ++++++++++++++++++++++++++++++++++++++- internal/db/entities.go | 3 ++- internal/display/glyph.go | 1 + internal/parse/grammar.go | 2 ++ web/app.js | 6 ++--- web/index.html | 5 ++++ web/style.css | 1 + 7 files changed, 69 insertions(+), 5 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index 15530d8..fc389ea 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -3,8 +3,10 @@ package db import ( "database/sql" "errors" + "fmt" "os" "path/filepath" + "strings" _ "modernc.org/sqlite" ) @@ -63,7 +65,7 @@ func (s *Store) migrate() error { pinned INTEGER NOT NULL DEFAULT 0, deleted_at TEXT, card_type TEXT - CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link') + CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note') OR card_type IS NULL), card_data TEXT, use_count INTEGER NOT NULL DEFAULT 0, @@ -91,6 +93,58 @@ func (s *Store) migrate() error { s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + // Migrate CHECK constraint to include 'note' card type + var needsMigrate bool + row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`) + 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 { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { + return fmt.Errorf("migrate rename: %w", err) + } + if _, err := tx.Exec(`CREATE TABLE entities ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL, + body TEXT NOT NULL, + glyph TEXT NOT NULL + CHECK (glyph IN ('todo', 'event', 'note')), + time_anchor TEXT, + completed_at TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, + card_type TEXT + CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note') + OR card_type IS NULL), + card_data TEXT, + use_count INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT, + title TEXT, + description TEXT + )`); err != nil { + return fmt.Errorf("migrate create: %w", err) + } + if _, err := tx.Exec(`INSERT INTO entities SELECT * FROM _entities_migrate`); err != nil { + return fmt.Errorf("migrate copy: %w", err) + } + if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil { + return fmt.Errorf("migrate drop: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("migrate commit: %w", err) + } + } + return nil } diff --git a/internal/db/entities.go b/internal/db/entities.go index 4669416..c257d7a 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -27,6 +27,7 @@ const ( CardChecklist CardType = "checklist" CardDecision CardType = "decision" CardLink CardType = "link" + CardNote CardType = "note" ) func ValidGlyph(s string) bool { @@ -39,7 +40,7 @@ func ValidGlyph(s string) bool { func ValidCardType(s string) bool { switch CardType(s) { - case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink: + case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote: return true } return false diff --git a/internal/display/glyph.go b/internal/display/glyph.go index 9ba2bfc..e3cb21d 100644 --- a/internal/display/glyph.go +++ b/internal/display/glyph.go @@ -15,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{ db.CardChecklist: "☐", db.CardDecision: "⚖", db.CardLink: "↗", + db.CardNote: "¶", } func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string { diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index ee30ea0..0556320 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -27,6 +27,8 @@ var validCardTypes = map[string]string{ "checklist": "checklist", "decision": "decision", "link": "link", + "note": "note", + "n": "note", } func Parse(input string) (*Result, error) { diff --git a/web/app.js b/web/app.js index dfaa3bb..adbe789 100644 --- a/web/app.js +++ b/web/app.js @@ -4,14 +4,14 @@ const GLYPHS = { note: '—', todo: '○', event: '◇', reminder: '△', snippet: '◆', template: '◈', checklist: '☐', - decision: '⚖', link: '↗', + decision: '⚖', link: '↗', note: '¶', }; const GLYPH_CLASSES = { note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder', snippet: 'glyph-snippet', template: 'glyph-template', checklist: 'glyph-checklist', decision: 'glyph-decision', - link: 'glyph-link', + link: 'glyph-link', note: 'glyph-note', }; const PAGE_SIZE = 50; @@ -121,7 +121,7 @@ // ========== Grammar parser (mirrors Go parser) ========== - const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }; + const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link', note: 'note', n: 'note' }; function validateTime(s) { const parts = s.split(':'); diff --git a/web/index.html b/web/index.html index f54638d..9794a4b 100644 --- a/web/index.html +++ b/web/index.html @@ -64,6 +64,11 @@ decision record a choice + rationale +