fix: align parsers with capture grammar, restore demote, add parse preview #10
@@ -37,6 +37,7 @@ func runAdd(_ *cobra.Command, args []string) error {
|
|||||||
Description: parsed.Description,
|
Description: parsed.Description,
|
||||||
Glyph: db.Glyph(parsed.Glyph),
|
Glyph: db.Glyph(parsed.Glyph),
|
||||||
Tags: parsed.Tags,
|
Tags: parsed.Tags,
|
||||||
|
Pinned: parsed.Pin,
|
||||||
}
|
}
|
||||||
if parsed.TimeAnchor != nil {
|
if parsed.TimeAnchor != nil {
|
||||||
e.TimeAnchor = parsed.TimeAnchor
|
e.TimeAnchor = parsed.TimeAnchor
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CreateEntityRequest struct {
|
|||||||
Glyph *string `json:"glyph"`
|
Glyph *string `json:"glyph"`
|
||||||
TimeAnchor *string `json:"time_anchor"`
|
TimeAnchor *string `json:"time_anchor"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
Pinned *bool `json:"pinned"`
|
||||||
CardType *string `json:"card_type"`
|
CardType *string `json:"card_type"`
|
||||||
CardData *string `json:"card_data"`
|
CardData *string `json:"card_data"`
|
||||||
}
|
}
|
||||||
@@ -145,6 +146,9 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
|||||||
TimeAnchor: req.TimeAnchor,
|
TimeAnchor: req.TimeAnchor,
|
||||||
Tags: req.Tags,
|
Tags: req.Tags,
|
||||||
}
|
}
|
||||||
|
if req.Pinned != nil && *req.Pinned {
|
||||||
|
e.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
if req.CardType != nil {
|
if req.CardType != nil {
|
||||||
if !db.ValidCardType(*req.CardType) {
|
if !db.ValidCardType(*req.CardType) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
GlyphNote Glyph = "note"
|
GlyphNote Glyph = "note"
|
||||||
GlyphTodo Glyph = "todo"
|
GlyphTodo Glyph = "todo"
|
||||||
GlyphEvent Glyph = "event"
|
GlyphEvent Glyph = "event"
|
||||||
|
GlyphReminder Glyph = "reminder"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CardType string
|
type CardType string
|
||||||
@@ -30,7 +31,7 @@ const (
|
|||||||
|
|
||||||
func ValidGlyph(s string) bool {
|
func ValidGlyph(s string) bool {
|
||||||
switch Glyph(s) {
|
switch Glyph(s) {
|
||||||
case GlyphNote, GlyphTodo, GlyphEvent:
|
case GlyphNote, GlyphTodo, GlyphEvent, GlyphReminder:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ var glyphMap = map[db.Glyph]string{
|
|||||||
db.GlyphNote: "—",
|
db.GlyphNote: "—",
|
||||||
db.GlyphTodo: "○",
|
db.GlyphTodo: "○",
|
||||||
db.GlyphEvent: "◇",
|
db.GlyphEvent: "◇",
|
||||||
|
db.GlyphReminder: "△",
|
||||||
}
|
}
|
||||||
|
|
||||||
var cardGlyphMap = map[db.CardType]string{
|
var cardGlyphMap = map[db.CardType]string{
|
||||||
|
|||||||
+145
-53
@@ -13,7 +13,10 @@ type Result struct {
|
|||||||
Description *string
|
Description *string
|
||||||
TimeAnchor *string
|
TimeAnchor *string
|
||||||
Tags []string
|
Tags []string
|
||||||
|
FilterTags []string
|
||||||
CardSuffix *string
|
CardSuffix *string
|
||||||
|
Pin bool
|
||||||
|
Query bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var validCardTypes = map[string]string{
|
var validCardTypes = map[string]string{
|
||||||
@@ -39,26 +42,70 @@ func Parse(input string) (*Result, error) {
|
|||||||
|
|
||||||
remaining := input
|
remaining := input
|
||||||
|
|
||||||
if sp := strings.IndexByte(remaining, ' '); sp >= 0 {
|
// Step 1: Escape check — `\` prefix → thought, no prefix detection
|
||||||
switch remaining[:sp] {
|
if strings.HasPrefix(remaining, `\`) {
|
||||||
case "-", "▸":
|
remaining = remaining[1:]
|
||||||
r.Glyph = "todo"
|
r.Glyph = "note"
|
||||||
remaining = strings.TrimSpace(remaining[sp+1:])
|
clean, err := extractModifiers(r, remaining, false)
|
||||||
case "*", "◇":
|
if err != nil {
|
||||||
r.Glyph = "event"
|
return nil, err
|
||||||
remaining = strings.TrimSpace(remaining[sp+1:])
|
|
||||||
}
|
}
|
||||||
|
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 {
|
} else {
|
||||||
switch remaining {
|
bodyParts = append(bodyParts, tok)
|
||||||
case "-", "▸":
|
}
|
||||||
|
}
|
||||||
|
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"
|
r.Glyph = "todo"
|
||||||
remaining = ""
|
remaining = ""
|
||||||
case "*", "◇":
|
} else if strings.HasPrefix(remaining, "@") {
|
||||||
|
if rest, ok := tryPrefixTime(r, remaining[1:]); ok {
|
||||||
r.Glyph = "event"
|
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
|
var titleRaw, descRaw string
|
||||||
hasTitle := false
|
hasTitle := false
|
||||||
|
|
||||||
@@ -106,46 +153,9 @@ func Parse(input string) (*Result, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := map[string]bool{}
|
// Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasTitle {
|
if hasTitle {
|
||||||
clean, err := extract(titleRaw)
|
clean, err := extractModifiers(r, titleRaw, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -154,7 +164,7 @@ func Parse(input string) (*Result, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if descRaw != "" {
|
if descRaw != "" {
|
||||||
clean, err := extract(descRaw)
|
clean, err := extractModifiers(r, descRaw, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -176,6 +186,88 @@ func Parse(input string) (*Result, error) {
|
|||||||
return r, nil
|
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 {
|
func validateTime(s string) error {
|
||||||
parts := strings.SplitN(s, ":", 2)
|
parts := strings.SplitN(s, ":", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
|
|||||||
@@ -18,56 +18,87 @@ func TestParse(t *testing.T) {
|
|||||||
wantTime *string
|
wantTime *string
|
||||||
wantTags []string
|
wantTags []string
|
||||||
wantCard *string
|
wantCard *string
|
||||||
|
wantPin bool
|
||||||
|
wantQuery bool
|
||||||
|
wantFilter []string
|
||||||
wantErrSub string
|
wantErrSub string
|
||||||
}{
|
}{
|
||||||
// Glyph detection
|
// Kind prefixes
|
||||||
{"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, ""},
|
{"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, ""},
|
{"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, false, false, nil, ""},
|
||||||
{"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""},
|
{"dash todo requires space", "-deploy", "-deploy", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
|
||||||
{"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
|
{"event prefix", "@14:00 dentist", "dentist", "event", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
|
||||||
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, 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
|
// Event/reminder with invalid time — @ stays as body token, ! stays as body token
|
||||||
{"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, ""},
|
{"at-sign not time", "@nottime hello", "@nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
|
||||||
{"time at start", "@9:30 standup", "standup", "note", nil, nil, sp("9:30"), nil, nil, ""},
|
{"bang not time", "!nottime hello", "!nottime hello", "note", nil, nil, nil, nil, nil, false, false, 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"},
|
|
||||||
|
|
||||||
// Tags
|
// Escape prefix
|
||||||
{"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
|
{"escape dash", `\- this is not a todo`, "- this is not a todo", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
|
||||||
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""},
|
{"escape at", `\@14:00 not event`, "not event", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
|
||||||
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
|
{"escape plain", `\hello`, "hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
|
||||||
{"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""},
|
|
||||||
|
|
||||||
// Card suffix
|
// Query mode
|
||||||
{"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""},
|
{"query basic", "? proxy config", "proxy config", "", nil, nil, nil, nil, nil, false, true, nil, ""},
|
||||||
{"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
|
{"query with tags", "? proxy config #ops #infra", "proxy config", "", nil, nil, nil, nil, nil, false, true, []string{"ops", "infra"}, ""},
|
||||||
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""},
|
{"query tags only", "? #ops", "", "", nil, nil, nil, nil, nil, false, true, []string{"ops"}, ""},
|
||||||
{"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"},
|
// 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
|
// Combined
|
||||||
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, ""},
|
{"full todo", "- deploy nginx @15:00 #ops", "deploy nginx", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, false, false, 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 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
|
||||||
{"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, 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, ""},
|
{"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, ""},
|
{"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, ""},
|
{"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, ""},
|
{"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, ""},
|
{"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
|
// Description without title
|
||||||
{"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), 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, ""},
|
{"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, ""},
|
{"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
|
// Edge cases
|
||||||
{"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"},
|
{"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
|
||||||
{"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"},
|
{"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
|
||||||
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, 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, "empty"},
|
{"whitespace only", " ", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -109,6 +140,15 @@ func TestParse(t *testing.T) {
|
|||||||
if !ptrEq(got.CardSuffix, tt.wantCard) {
|
if !ptrEq(got.CardSuffix, tt.wantCard) {
|
||||||
t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-28
@@ -2,13 +2,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const GLYPHS = {
|
const GLYPHS = {
|
||||||
note: '—', todo: '○', event: '◇',
|
note: '—', todo: '○', event: '◇', reminder: '△',
|
||||||
snippet: '◆', template: '◈', checklist: '☐',
|
snippet: '◆', template: '◈', checklist: '☐',
|
||||||
decision: '⚖', link: '↗',
|
decision: '⚖', link: '↗',
|
||||||
};
|
};
|
||||||
|
|
||||||
const GLYPH_CLASSES = {
|
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',
|
snippet: 'glyph-snippet', template: 'glyph-template',
|
||||||
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
||||||
link: 'glyph-link',
|
link: 'glyph-link',
|
||||||
@@ -120,23 +120,76 @@
|
|||||||
|
|
||||||
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' };
|
||||||
|
|
||||||
|
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) {
|
function parseInput(input) {
|
||||||
input = input.trim();
|
input = input.trim();
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
|
|
||||||
let glyph = 'note';
|
let glyph = 'note';
|
||||||
let remaining = input;
|
let remaining = input;
|
||||||
|
let timeAnchor = null, cardSuffix = null, pin = false, query = false;
|
||||||
|
const tags = [], seenTags = {}, filterTags = [];
|
||||||
|
|
||||||
const sp = remaining.indexOf(' ');
|
// Step 1: Escape check — `\` prefix → thought, skip prefix detection
|
||||||
if (sp >= 0) {
|
if (remaining.startsWith('\\')) {
|
||||||
const first = remaining.slice(0, sp);
|
remaining = remaining.slice(1);
|
||||||
if (first === '-' || first === '▸') { glyph = 'todo'; remaining = remaining.slice(sp + 1).trim(); }
|
const result = extractModifiers(remaining, true);
|
||||||
else if (first === '*' || first === '◇') { glyph = 'event'; remaining = remaining.slice(sp + 1).trim(); }
|
if (!result.body) return null;
|
||||||
} else {
|
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: [] };
|
||||||
if (remaining === '-' || remaining === '▸') { glyph = 'todo'; remaining = ''; }
|
|
||||||
else if (remaining === '*' || remaining === '◇') { glyph = 'event'; remaining = ''; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
let titleRaw = null, descRaw = null, hasTitle = false;
|
||||||
const lines = remaining.split('\n');
|
const lines = remaining.split('\n');
|
||||||
const firstLine = (lines[0] || '').trim();
|
const firstLine = (lines[0] || '').trim();
|
||||||
@@ -174,42 +227,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeAnchor = null, cardSuffix = null;
|
// Steps 6-8: Extract flags, tags, time, card suffix
|
||||||
const tags = [], seenTags = {};
|
function extractModifiers(text, handleFlags) {
|
||||||
|
|
||||||
function extract(text) {
|
|
||||||
const tokens = text.split(/\s+/).filter(Boolean);
|
const tokens = text.split(/\s+/).filter(Boolean);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
let localTime = timeAnchor, localPin = pin, localCard = cardSuffix;
|
||||||
|
const localTags = [...tags];
|
||||||
|
const localSeen = { ...seenTags };
|
||||||
|
|
||||||
for (const tok of tokens) {
|
for (const tok of tokens) {
|
||||||
if (tok.startsWith('@') && tok.length > 1) {
|
if (handleFlags && tok.toLowerCase() === '!pin') {
|
||||||
timeAnchor = tok.slice(1);
|
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) {
|
} else if (tok.startsWith('#') && tok.length > 1) {
|
||||||
const tag = tok.slice(1);
|
const tag = tok.slice(1).toLowerCase();
|
||||||
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
|
if (!localSeen[tag]) { localTags.push(tag); localSeen[tag] = true; }
|
||||||
} else if (tok.startsWith('^') && tok.length > 1) {
|
} else if (tok.startsWith('^') && tok.length > 1) {
|
||||||
const suffix = tok.slice(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 {
|
} else {
|
||||||
parts.push(tok);
|
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;
|
let title = null, description = null;
|
||||||
if (hasTitle) {
|
if (hasTitle) {
|
||||||
const clean = extract(titleRaw || '');
|
const r = extractModifiers(titleRaw || '', false);
|
||||||
if (clean) title = clean;
|
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) {
|
if (descRaw) {
|
||||||
const clean = extract(descRaw);
|
const r = extractModifiers(descRaw, false);
|
||||||
if (clean) description = clean;
|
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;
|
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) {
|
function detectCardType(body) {
|
||||||
@@ -334,6 +404,7 @@
|
|||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
state.intent = el.dataset.intent;
|
state.intent = el.dataset.intent;
|
||||||
renderTagRail();
|
renderTagRail();
|
||||||
|
renderEntityList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -371,6 +442,16 @@
|
|||||||
const parsed = parseInput(val);
|
const parsed = parseInput(val);
|
||||||
if (!parsed) return;
|
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 = {
|
const data = {
|
||||||
body: parsed.body,
|
body: parsed.body,
|
||||||
glyph: parsed.glyph,
|
glyph: parsed.glyph,
|
||||||
@@ -380,6 +461,7 @@
|
|||||||
if (parsed.description) data.description = parsed.description;
|
if (parsed.description) data.description = parsed.description;
|
||||||
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
|
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
|
||||||
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
|
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
|
||||||
|
if (parsed.pin) data.pinned = true;
|
||||||
|
|
||||||
await api.createEntity(data);
|
await api.createEntity(data);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -1399,12 +1481,20 @@
|
|||||||
if (ev.key === 'Escape') { searchInput.value = ''; state.searchQuery = ''; renderEntityList(); searchInput.blur(); }
|
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) {
|
function filterBySearch(entities) {
|
||||||
if (!state.searchQuery) return entities;
|
const intentFiltered = filterByIntent(entities);
|
||||||
|
if (!state.searchQuery) return intentFiltered;
|
||||||
let query = state.searchQuery;
|
let query = state.searchQuery;
|
||||||
let filterTags = [];
|
let filterTags = [];
|
||||||
query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim();
|
query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim();
|
||||||
return entities.filter(e => {
|
return intentFiltered.filter(e => {
|
||||||
if (filterTags.length) {
|
if (filterTags.length) {
|
||||||
const eTags = (e.tags || []).map(t => t.toLowerCase());
|
const eTags = (e.tags || []).map(t => t.toLowerCase());
|
||||||
if (!filterTags.every(ft => eTags.includes(ft))) return false;
|
if (!filterTags.every(ft => eTags.includes(ft))) return false;
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ main {
|
|||||||
.glyph-note { color: var(--muted); }
|
.glyph-note { color: var(--muted); }
|
||||||
.glyph-todo { color: var(--todo); }
|
.glyph-todo { color: var(--todo); }
|
||||||
.glyph-event { color: var(--event); }
|
.glyph-event { color: var(--event); }
|
||||||
|
.glyph-reminder { color: var(--remind); }
|
||||||
.glyph-snippet { color: var(--accent); }
|
.glyph-snippet { color: var(--accent); }
|
||||||
.glyph-template { color: var(--lineage); }
|
.glyph-template { color: var(--lineage); }
|
||||||
.glyph-checklist { color: var(--remind); }
|
.glyph-checklist { color: var(--remind); }
|
||||||
|
|||||||
Reference in New Issue
Block a user