From 97ad71d66b7d246a868d99bee3e3b0affed71e07 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 11:12:36 -0400 Subject: [PATCH] fix(parser): align Go and JS parsers with capture grammar spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind prefixes now follow the canonical grammar: `-` for todo, `@time` for event, `!time` for reminder. Removed `*`/`◇`/`▸` as capture aliases (display-layer only). Added `\` escape prefix, `?` query mode, `!pin` flag extraction, `##word` hash escape, and tag lowercasing. Both parsers produce identical results. --- cmd/add.go | 1 + internal/api/entities.go | 4 + internal/db/entities.go | 9 +- internal/display/glyph.go | 7 +- internal/parse/grammar.go | 204 ++++++++++++++++++++++++--------- internal/parse/grammar_test.go | 114 ++++++++++++------ web/app.js | 146 ++++++++++++++++++----- web/style.css | 1 + 8 files changed, 358 insertions(+), 128 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 74edfb9..f95bc51 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -37,6 +37,7 @@ func runAdd(_ *cobra.Command, args []string) error { Description: parsed.Description, Glyph: db.Glyph(parsed.Glyph), Tags: parsed.Tags, + Pinned: parsed.Pin, } if parsed.TimeAnchor != nil { e.TimeAnchor = parsed.TimeAnchor diff --git a/internal/api/entities.go b/internal/api/entities.go index d31f9cf..6e78e2e 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -16,6 +16,7 @@ type CreateEntityRequest struct { Glyph *string `json:"glyph"` TimeAnchor *string `json:"time_anchor"` Tags []string `json:"tags"` + Pinned *bool `json:"pinned"` CardType *string `json:"card_type"` CardData *string `json:"card_data"` } @@ -145,6 +146,9 @@ func createEntity(store *db.Store) http.HandlerFunc { TimeAnchor: req.TimeAnchor, Tags: req.Tags, } + if req.Pinned != nil && *req.Pinned { + e.Pinned = true + } if req.CardType != nil { if !db.ValidCardType(*req.CardType) { diff --git a/internal/db/entities.go b/internal/db/entities.go index 074d6e6..4669416 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -13,9 +13,10 @@ import ( type Glyph string const ( - GlyphNote Glyph = "note" - GlyphTodo Glyph = "todo" - GlyphEvent Glyph = "event" + GlyphNote Glyph = "note" + GlyphTodo Glyph = "todo" + GlyphEvent Glyph = "event" + GlyphReminder Glyph = "reminder" ) type CardType string @@ -30,7 +31,7 @@ const ( func ValidGlyph(s string) bool { switch Glyph(s) { - case GlyphNote, GlyphTodo, GlyphEvent: + case GlyphNote, GlyphTodo, GlyphEvent, GlyphReminder: return true } return false diff --git a/internal/display/glyph.go b/internal/display/glyph.go index f562d39..9ba2bfc 100644 --- a/internal/display/glyph.go +++ b/internal/display/glyph.go @@ -3,9 +3,10 @@ package display import "github.com/lerko/nib/internal/db" var glyphMap = map[db.Glyph]string{ - db.GlyphNote: "—", - db.GlyphTodo: "○", - db.GlyphEvent: "◇", + db.GlyphNote: "—", + db.GlyphTodo: "○", + db.GlyphEvent: "◇", + db.GlyphReminder: "△", } var cardGlyphMap = map[db.CardType]string{ diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index a7a8024..eb02518 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -13,7 +13,10 @@ type Result struct { Description *string TimeAnchor *string Tags []string + FilterTags []string CardSuffix *string + Pin bool + Query bool } var validCardTypes = map[string]string{ @@ -39,26 +42,70 @@ func Parse(input string) (*Result, error) { remaining := input - if sp := strings.IndexByte(remaining, ' '); sp >= 0 { - switch remaining[:sp] { - case "-", "▸": - r.Glyph = "todo" - remaining = strings.TrimSpace(remaining[sp+1:]) - case "*", "◇": - r.Glyph = "event" - remaining = strings.TrimSpace(remaining[sp+1:]) + // Step 1: Escape check — `\` prefix → thought, no prefix detection + if strings.HasPrefix(remaining, `\`) { + remaining = remaining[1:] + r.Glyph = "note" + clean, err := extractModifiers(r, remaining, false) + if err != nil { + return nil, err } - } else { - switch remaining { - case "-", "▸": - r.Glyph = "todo" - remaining = "" - case "*", "◇": + r.Body = clean + if r.Body == "" && r.Title == nil { + return nil, fmt.Errorf("empty body after extracting modifiers") + } + return r, nil + } + + // Step 2: Query check — `?` prefix → search mode + if strings.HasPrefix(remaining, "?") { + remaining = strings.TrimSpace(remaining[1:]) + r.Query = true + r.Glyph = "" + tokens := strings.Fields(remaining) + var bodyParts []string + for _, tok := range tokens { + if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") { + tag := strings.ToLower(tok[1:]) + r.FilterTags = append(r.FilterTags, tag) + } else { + bodyParts = append(bodyParts, tok) + } + } + r.Body = strings.Join(bodyParts, " ") + return r, nil + } + + // Step 3: Kind prefix — `-`, `@time`, `!time` + // `@` and `!` are kind prefixes ONLY if followed by a valid time token. + // Otherwise the input is treated as a plain note. + if strings.HasPrefix(remaining, "- ") { + r.Glyph = "todo" + remaining = strings.TrimSpace(remaining[2:]) + } else if remaining == "-" { + r.Glyph = "todo" + remaining = "" + } else if strings.HasPrefix(remaining, "@") { + if rest, ok := tryPrefixTime(r, remaining[1:]); ok { r.Glyph = "event" - remaining = "" + remaining = rest + } + } else if strings.HasPrefix(remaining, "!") { + afterBang := remaining[1:] + // `!pin` is a flag, not a reminder prefix + firstWord := "" + if fields := strings.Fields(afterBang); len(fields) > 0 { + firstWord = fields[0] + } + if !strings.EqualFold(firstWord, "pin") { + if rest, ok := tryPrefixTime(r, afterBang); ok { + r.Glyph = "reminder" + remaining = rest + } } } + // Steps 4-5: Title and description extraction var titleRaw, descRaw string hasTitle := false @@ -106,46 +153,9 @@ func Parse(input string) (*Result, error) { } } - seen := map[string]bool{} - extract := func(text string) (string, error) { - tokens := strings.Fields(text) - var parts []string - for _, tok := range tokens { - switch { - case strings.HasPrefix(tok, "@") && len(tok) > 1: - timeStr := tok[1:] - if err := validateTime(timeStr); err != nil { - return "", fmt.Errorf("invalid time %q: %w", timeStr, err) - } - if r.TimeAnchor != nil { - return "", fmt.Errorf("multiple time anchors") - } - r.TimeAnchor = &timeStr - case strings.HasPrefix(tok, "#") && len(tok) > 1: - tag := tok[1:] - if !seen[tag] { - r.Tags = append(r.Tags, tag) - seen[tag] = true - } - case strings.HasPrefix(tok, "^") && len(tok) > 1: - suffix := tok[1:] - cardType, ok := validCardTypes[suffix] - if !ok { - return "", fmt.Errorf("invalid card type %q", suffix) - } - if r.CardSuffix != nil { - return "", fmt.Errorf("multiple card suffixes") - } - r.CardSuffix = &cardType - default: - parts = append(parts, tok) - } - } - return strings.Join(parts, " "), nil - } - + // Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body if hasTitle { - clean, err := extract(titleRaw) + clean, err := extractModifiers(r, titleRaw, false) if err != nil { return nil, err } @@ -154,7 +164,7 @@ func Parse(input string) (*Result, error) { } } if descRaw != "" { - clean, err := extract(descRaw) + clean, err := extractModifiers(r, descRaw, false) if err != nil { return nil, err } @@ -163,7 +173,7 @@ func Parse(input string) (*Result, error) { } } - clean, err := extract(remaining) + clean, err := extractModifiers(r, remaining, true) if err != nil { return nil, err } @@ -176,6 +186,88 @@ func Parse(input string) (*Result, error) { return r, nil } +// tryPrefixTime attempts to extract a time token from the start of text. +// Returns (remaining text, true) on success, or ("", false) if no valid time found. +func tryPrefixTime(r *Result, text string) (string, bool) { + text = strings.TrimSpace(text) + if text == "" { + return "", false + } + sp := strings.IndexByte(text, ' ') + var timeStr, rest string + if sp >= 0 { + timeStr = text[:sp] + rest = strings.TrimSpace(text[sp+1:]) + } else { + timeStr = text + rest = "" + } + if validateTime(timeStr) != nil { + return "", false + } + r.TimeAnchor = &timeStr + return rest, true +} + +// extractModifiers extracts tags, flags, time anchors, and card suffixes from text. +// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts). +func extractModifiers(r *Result, text string, handleFlags bool) (string, error) { + tokens := strings.Fields(text) + var parts []string + seen := map[string]bool{} + for _, t := range r.Tags { + seen[strings.ToLower(t)] = true + } + + for _, tok := range tokens { + switch { + // !pin flag + case handleFlags && strings.EqualFold(tok, "!pin"): + r.Pin = true + + // ##word escape → literal #word in body + case strings.HasPrefix(tok, "##") && len(tok) > 2: + parts = append(parts, "#"+tok[2:]) + + // @time inline time anchor (for todos: sets due_at) + // If not valid time format, keep as body text + case strings.HasPrefix(tok, "@") && len(tok) > 1: + timeStr := tok[1:] + if validateTime(timeStr) != nil { + parts = append(parts, tok) + } else if r.TimeAnchor != nil { + return "", fmt.Errorf("multiple time anchors") + } else { + r.TimeAnchor = &timeStr + } + + // #tag extraction (lowercased, deduplicated) + case strings.HasPrefix(tok, "#") && len(tok) > 1: + tag := strings.ToLower(tok[1:]) + if !seen[tag] { + r.Tags = append(r.Tags, tag) + seen[tag] = true + } + + // ^card suffix (kept for now, spec says remove later) + case strings.HasPrefix(tok, "^") && len(tok) > 1: + suffix := tok[1:] + cardType, ok := validCardTypes[suffix] + if !ok { + return "", fmt.Errorf("invalid card type %q", suffix) + } + if r.CardSuffix != nil { + return "", fmt.Errorf("multiple card suffixes") + } + r.CardSuffix = &cardType + + default: + parts = append(parts, tok) + } + } + return strings.Join(parts, " "), nil +} + func validateTime(s string) error { parts := strings.SplitN(s, ":", 2) if len(parts) != 2 { diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go index 46ec311..29daa0b 100644 --- a/internal/parse/grammar_test.go +++ b/internal/parse/grammar_test.go @@ -18,56 +18,87 @@ func TestParse(t *testing.T) { wantTime *string wantTags []string wantCard *string + wantPin bool + wantQuery bool + wantFilter []string wantErrSub string }{ - // Glyph detection - {"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, ""}, - {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""}, - {"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""}, - {"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""}, - {"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, nil, ""}, + // Kind prefixes + {"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"dash todo requires space", "-deploy", "-deploy", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"event prefix", "@14:00 dentist", "dentist", "event", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""}, + {"event no body", "@9:30", "", "event", nil, nil, sp("9:30"), nil, nil, false, false, nil, "empty body"}, + {"reminder prefix", "!15:00 call dentist", "call dentist", "reminder", nil, nil, sp("15:00"), nil, nil, false, false, nil, ""}, + {"reminder no body", "!9:30", "", "reminder", nil, nil, sp("9:30"), nil, nil, false, false, nil, "empty body"}, - // Time anchor - {"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, ""}, - {"time at start", "@9:30 standup", "standup", "note", nil, nil, sp("9:30"), nil, nil, ""}, - {"invalid hours", "meeting @25:00", "", "", nil, nil, nil, nil, nil, "invalid time"}, - {"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, nil, nil, "invalid time"}, + // Event/reminder with invalid time — @ stays as body token, ! stays as body token + {"at-sign not time", "@nottime hello", "@nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"bang not time", "!nottime hello", "!nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, - // Tags - {"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""}, - {"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""}, - {"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""}, - {"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""}, + // Escape prefix + {"escape dash", `\- this is not a todo`, "- this is not a todo", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"escape at", `\@14:00 not event`, "not event", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""}, + {"escape plain", `\hello`, "hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, - // Card suffix - {"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""}, - {"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""}, - {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""}, - {"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""}, - {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, "invalid card type"}, + // Query mode + {"query basic", "? proxy config", "proxy config", "", nil, nil, nil, nil, nil, false, true, nil, ""}, + {"query with tags", "? proxy config #ops #infra", "proxy config", "", nil, nil, nil, nil, nil, false, true, []string{"ops", "infra"}, ""}, + {"query tags only", "? #ops", "", "", nil, nil, nil, nil, nil, false, true, []string{"ops"}, ""}, + + // Inline time anchor + {"inline time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""}, + {"todo due time", "- buy milk @9:30", "buy milk", "todo", nil, nil, sp("9:30"), nil, nil, false, false, nil, ""}, + {"invalid hours stays as body", "meeting @25:00", "meeting @25:00", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"invalid minutes stays as body", "meeting @14:60", "meeting @14:60", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + + // Tags (lowercased) + {"single tag", "deploy #Ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"multiple tags", "deploy #ops #Infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, false, false, nil, ""}, + {"duplicate tags", "deploy #ops #Ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, false, false, nil, ""}, + + // Hash escape + {"double hash escape", "use ##channel in slack", "use #channel in slack", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"double hash with tag", "use ##channel #ops", "use #channel", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + + // Pin flag + {"pin flag", "important thing !pin", "important thing", "note", nil, nil, nil, nil, nil, true, false, nil, ""}, + {"pin case insensitive", "important !Pin #work", "important", "note", nil, nil, nil, []string{"work"}, nil, true, false, nil, ""}, + {"pin with todo", "- urgent task !pin", "urgent task", "todo", nil, nil, nil, nil, nil, true, false, nil, ""}, + + // !pin at start — not a reminder, flag is extracted + {"bang pin only", "!pin important", "important", "note", nil, nil, nil, nil, nil, true, false, nil, ""}, + + // Card suffix (kept for now) + {"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), false, false, nil, ""}, + {"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), false, false, nil, ""}, + {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), false, false, nil, ""}, + {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, false, false, nil, "invalid card type"}, // Combined - {"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, ""}, - {"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""}, + {"full todo", "- deploy nginx @15:00 #ops", "deploy nginx", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, false, false, nil, ""}, + {"full event", "@14:00 lunch with alex #personal", "lunch with alex", "event", nil, nil, sp("14:00"), []string{"personal"}, nil, false, false, nil, ""}, + {"full reminder", "!15:00 call dentist #health", "call dentist", "reminder", nil, nil, sp("15:00"), []string{"health"}, nil, false, false, nil, ""}, // Title - {"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, ""}, - {"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, ""}, - {"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, ""}, - {"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, ""}, - {"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, ""}, - {"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, ""}, + {"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, false, false, nil, ""}, + {"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, false, false, nil, ""}, + {"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, false, false, nil, ""}, // Description without title - {"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, ""}, - {"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, ""}, - {"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, ""}, + {"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, false, false, nil, ""}, + {"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, false, false, nil, ""}, + {"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, // Edge cases - {"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"}, - {"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"}, - {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, "empty body"}, - {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, "empty"}, + {"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"}, + {"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"}, + {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"}, + {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"}, } for _, tt := range tests { @@ -109,6 +140,15 @@ func TestParse(t *testing.T) { if !ptrEq(got.CardSuffix, tt.wantCard) { t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(tt.wantCard)) } + if got.Pin != tt.wantPin { + t.Errorf("pin: got %v, want %v", got.Pin, tt.wantPin) + } + if got.Query != tt.wantQuery { + t.Errorf("query: got %v, want %v", got.Query, tt.wantQuery) + } + if !tagsEq(got.FilterTags, tt.wantFilter) { + t.Errorf("filter_tags: got %v, want %v", got.FilterTags, tt.wantFilter) + } }) } } diff --git a/web/app.js b/web/app.js index 9ece617..c65011b 100644 --- a/web/app.js +++ b/web/app.js @@ -2,13 +2,13 @@ 'use strict'; const GLYPHS = { - note: '—', todo: '○', event: '◇', + note: '—', todo: '○', event: '◇', reminder: '△', snippet: '◆', template: '◈', checklist: '☐', decision: '⚖', link: '↗', }; const GLYPH_CLASSES = { - note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', + 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', @@ -120,23 +120,76 @@ const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }; + function validateTime(s) { + const parts = s.split(':'); + if (parts.length !== 2) return false; + const h = parseInt(parts[0], 10), m = parseInt(parts[1], 10); + return !isNaN(h) && !isNaN(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59; + } + function parseInput(input) { input = input.trim(); if (!input) return null; let glyph = 'note'; let remaining = input; + let timeAnchor = null, cardSuffix = null, pin = false, query = false; + const tags = [], seenTags = {}, filterTags = []; - const sp = remaining.indexOf(' '); - if (sp >= 0) { - const first = remaining.slice(0, sp); - if (first === '-' || first === '▸') { glyph = 'todo'; remaining = remaining.slice(sp + 1).trim(); } - else if (first === '*' || first === '◇') { glyph = 'event'; remaining = remaining.slice(sp + 1).trim(); } - } else { - if (remaining === '-' || remaining === '▸') { glyph = 'todo'; remaining = ''; } - else if (remaining === '*' || remaining === '◇') { glyph = 'event'; remaining = ''; } + // Step 1: Escape check — `\` prefix → thought, skip prefix detection + if (remaining.startsWith('\\')) { + remaining = remaining.slice(1); + const result = extractModifiers(remaining, true); + if (!result.body) return null; + return { body: result.body, glyph: 'note', title: null, description: null, timeAnchor: result.timeAnchor, tags: result.tags, cardSuffix: result.cardSuffix, pin: result.pin, query: false, filterTags: [] }; } + // Step 2: Query check — `?` prefix → search mode + if (remaining.startsWith('?')) { + remaining = remaining.slice(1).trim(); + const tokens = remaining.split(/\s+/).filter(Boolean); + const bodyParts = []; + for (const tok of tokens) { + if (tok.startsWith('#') && tok.length > 1 && !tok.startsWith('##')) { + filterTags.push(tok.slice(1).toLowerCase()); + } else { + bodyParts.push(tok); + } + } + return { body: bodyParts.join(' '), glyph: '', title: null, description: null, timeAnchor: null, tags: [], cardSuffix: null, pin: false, query: true, filterTags }; + } + + // Step 3: Kind prefix — `-`, `@time`, `!time` + if (remaining.startsWith('- ')) { + glyph = 'todo'; + remaining = remaining.slice(2).trim(); + } else if (remaining === '-') { + glyph = 'todo'; + remaining = ''; + } else if (remaining.startsWith('@')) { + const afterAt = remaining.slice(1).trim(); + const sp = afterAt.indexOf(' '); + const timeTok = sp >= 0 ? afterAt.slice(0, sp) : afterAt; + if (validateTime(timeTok)) { + glyph = 'event'; + timeAnchor = timeTok; + remaining = sp >= 0 ? afterAt.slice(sp + 1).trim() : ''; + } + } else if (remaining.startsWith('!')) { + const afterBang = remaining.slice(1).trim(); + const firstWord = afterBang.split(/\s+/)[0] || ''; + if (firstWord.toLowerCase() !== 'pin') { + const sp = afterBang.indexOf(' '); + const timeTok = sp >= 0 ? afterBang.slice(0, sp) : afterBang; + if (validateTime(timeTok)) { + glyph = 'reminder'; + timeAnchor = timeTok; + remaining = sp >= 0 ? afterBang.slice(sp + 1).trim() : ''; + } + } + } + + // Steps 4-5: Title and description extraction let titleRaw = null, descRaw = null, hasTitle = false; const lines = remaining.split('\n'); const firstLine = (lines[0] || '').trim(); @@ -174,42 +227,59 @@ } } - let timeAnchor = null, cardSuffix = null; - const tags = [], seenTags = {}; - - function extract(text) { + // Steps 6-8: Extract flags, tags, time, card suffix + function extractModifiers(text, handleFlags) { const tokens = text.split(/\s+/).filter(Boolean); const parts = []; + let localTime = timeAnchor, localPin = pin, localCard = cardSuffix; + const localTags = [...tags]; + const localSeen = { ...seenTags }; + for (const tok of tokens) { - if (tok.startsWith('@') && tok.length > 1) { - timeAnchor = tok.slice(1); + if (handleFlags && tok.toLowerCase() === '!pin') { + localPin = true; + } else if (tok.startsWith('##') && tok.length > 2) { + parts.push('#' + tok.slice(2)); + } else if (tok.startsWith('@') && tok.length > 1) { + const ts = tok.slice(1); + if (validateTime(ts) && localTime === null) { + localTime = ts; + } else { + parts.push(tok); + } } else if (tok.startsWith('#') && tok.length > 1) { - const tag = tok.slice(1); - if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; } + const tag = tok.slice(1).toLowerCase(); + if (!localSeen[tag]) { localTags.push(tag); localSeen[tag] = true; } } else if (tok.startsWith('^') && tok.length > 1) { const suffix = tok.slice(1); - if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix]; + if (VALID_CARDS[suffix] && localCard === null) localCard = VALID_CARDS[suffix]; + else parts.push(tok); } else { parts.push(tok); } } - return parts.join(' '); + return { body: parts.join(' '), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin }; } let title = null, description = null; if (hasTitle) { - const clean = extract(titleRaw || ''); - if (clean) title = clean; + const r = extractModifiers(titleRaw || '', false); + if (r.body) title = r.body; + timeAnchor = r.timeAnchor; Object.assign(seenTags, r.seen); tags.length = 0; tags.push(...r.tags); cardSuffix = r.cardSuffix; pin = r.pin; } if (descRaw) { - const clean = extract(descRaw); - if (clean) description = clean; + const r = extractModifiers(descRaw, false); + if (r.body) description = r.body; + timeAnchor = r.timeAnchor; Object.assign(seenTags, r.seen); tags.length = 0; tags.push(...r.tags); cardSuffix = r.cardSuffix; pin = r.pin; } - const body = extract(remaining); + const bodyResult = extractModifiers(remaining, true); + const body = bodyResult.body; + timeAnchor = bodyResult.timeAnchor; tags.length = 0; tags.push(...bodyResult.tags); cardSuffix = bodyResult.cardSuffix; pin = bodyResult.pin; + if (!body && !title) return null; - return { body, glyph, title, description, timeAnchor, tags, cardSuffix }; + return { body, glyph, title, description, timeAnchor, tags, cardSuffix, pin, query: false, filterTags: [] }; } function detectCardType(body) { @@ -334,6 +404,7 @@ el.addEventListener('click', () => { state.intent = el.dataset.intent; renderTagRail(); + renderEntityList(); }); }); } @@ -371,6 +442,16 @@ const parsed = parseInput(val); if (!parsed) return; + // Query mode → switch to search + if (parsed.query) { + state.searchQuery = parsed.body; + const searchInput = $('#search-input'); + if (searchInput) searchInput.value = parsed.body + (parsed.filterTags.length ? ' ' + parsed.filterTags.map(t => '#' + t).join(' ') : ''); + input.value = ''; + renderEntityList(); + return; + } + const data = { body: parsed.body, glyph: parsed.glyph, @@ -380,6 +461,7 @@ if (parsed.description) data.description = parsed.description; if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; + if (parsed.pin) data.pinned = true; await api.createEntity(data); input.value = ''; @@ -1399,12 +1481,20 @@ if (ev.key === 'Escape') { searchInput.value = ''; state.searchQuery = ''; renderEntityList(); searchInput.blur(); } }); + function filterByIntent(entities) { + if (state.view !== 'cards' || state.intent === 'grab') return entities; + if (state.intent === 'read') return entities.filter(e => e.card_data); + if (state.intent === 'fill') return entities.filter(e => e.body && /\$\{.+\}/.test(e.body)); + return entities; + } + function filterBySearch(entities) { - if (!state.searchQuery) return entities; + const intentFiltered = filterByIntent(entities); + if (!state.searchQuery) return intentFiltered; let query = state.searchQuery; let filterTags = []; query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim(); - return entities.filter(e => { + return intentFiltered.filter(e => { if (filterTags.length) { const eTags = (e.tags || []).map(t => t.toLowerCase()); if (!filterTags.every(ft => eTags.includes(ft))) return false; diff --git a/web/style.css b/web/style.css index 9ca81e6..d6d9ad8 100644 --- a/web/style.css +++ b/web/style.css @@ -356,6 +356,7 @@ main { .glyph-note { color: var(--muted); } .glyph-todo { color: var(--todo); } .glyph-event { color: var(--event); } +.glyph-reminder { color: var(--remind); } .glyph-snippet { color: var(--accent); } .glyph-template { color: var(--lineage); } .glyph-checklist { color: var(--remind); }