feat(cards): add 'note' card type for readable markdown content

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.
This commit is contained in:
2026-05-17 12:49:43 -04:00
parent 840084fbb0
commit 2b177eeae9
7 changed files with 69 additions and 5 deletions
+55 -1
View File
@@ -3,8 +3,10 @@ package db
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -63,7 +65,7 @@ func (s *Store) migrate() error {
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT, deleted_at TEXT,
card_type 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), OR card_type IS NULL),
card_data TEXT, card_data TEXT,
use_count INTEGER NOT NULL DEFAULT 0, 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 title TEXT`)
s.db.Exec(`ALTER TABLE entities ADD COLUMN description 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 return nil
} }
+2 -1
View File
@@ -27,6 +27,7 @@ const (
CardChecklist CardType = "checklist" CardChecklist CardType = "checklist"
CardDecision CardType = "decision" CardDecision CardType = "decision"
CardLink CardType = "link" CardLink CardType = "link"
CardNote CardType = "note"
) )
func ValidGlyph(s string) bool { func ValidGlyph(s string) bool {
@@ -39,7 +40,7 @@ func ValidGlyph(s string) bool {
func ValidCardType(s string) bool { func ValidCardType(s string) bool {
switch CardType(s) { switch CardType(s) {
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink: case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote:
return true return true
} }
return false return false
+1
View File
@@ -15,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{
db.CardChecklist: "☐", db.CardChecklist: "☐",
db.CardDecision: "⚖", db.CardDecision: "⚖",
db.CardLink: "↗", db.CardLink: "↗",
db.CardNote: "¶",
} }
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string { func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
+2
View File
@@ -27,6 +27,8 @@ var validCardTypes = map[string]string{
"checklist": "checklist", "checklist": "checklist",
"decision": "decision", "decision": "decision",
"link": "link", "link": "link",
"note": "note",
"n": "note",
} }
func Parse(input string) (*Result, error) { func Parse(input string) (*Result, error) {
+3 -3
View File
@@ -4,14 +4,14 @@
const GLYPHS = { const GLYPHS = {
note: '—', todo: '○', event: '◇', reminder: '△', note: '—', todo: '○', event: '◇', reminder: '△',
snippet: '◆', template: '◈', checklist: '☐', snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '↗', decision: '⚖', link: '↗', note: '¶',
}; };
const GLYPH_CLASSES = { const GLYPH_CLASSES = {
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder', note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder',
snippet: 'glyph-snippet', template: 'glyph-template', snippet: 'glyph-snippet', template: 'glyph-template',
checklist: 'glyph-checklist', decision: 'glyph-decision', checklist: 'glyph-checklist', decision: 'glyph-decision',
link: 'glyph-link', link: 'glyph-link', note: 'glyph-note',
}; };
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -121,7 +121,7 @@
// ========== Grammar parser (mirrors Go parser) ========== // ========== 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) { function validateTime(s) {
const parts = s.split(':'); const parts = s.split(':');
+5
View File
@@ -64,6 +64,11 @@
<span class="type-name">decision</span> <span class="type-name">decision</span>
<span class="type-hint">record a choice + rationale</span> <span class="type-hint">record a choice + rationale</span>
</button> </button>
<button data-type="note" class="type-btn">
<span class="type-glyph glyph-note"></span>
<span class="type-name">note</span>
<span class="type-hint">readable markdown content</span>
</button>
<button data-type="link" class="type-btn"> <button data-type="link" class="type-btn">
<span class="type-glyph glyph-link"></span> <span class="type-glyph glyph-link"></span>
<span class="type-name">link</span> <span class="type-name">link</span>
+1
View File
@@ -462,6 +462,7 @@ main.focus-peek .resize-handle { visibility: hidden; }
.glyph-checklist { color: var(--remind); } .glyph-checklist { color: var(--remind); }
.glyph-decision { color: var(--note); } .glyph-decision { color: var(--note); }
.glyph-link { color: var(--event); } .glyph-link { color: var(--event); }
.glyph-note { color: var(--note); }
.entity-content { .entity-content {
flex: 1; flex: 1;