From b7dd58bf3e8a9c46b27b6e11fd0f4bba3b4d39ab Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:00:22 -0400 Subject: [PATCH] feat(ui): absorb button in peek, preserve newlines, demote promoted items Stream peek now shows absorb button for unpromoted entries. Promoted items in stream show demote instead of delete. d double-tap demotes any card_type entity regardless of view. Parsers preserve newlines from Shift+Enter. Absorb popup truncates to first non-empty line. --- internal/parse/grammar.go | 86 +++++++++++++++++----------------- internal/parse/grammar_test.go | 5 ++ web/app.js | 60 ++++++++++++++---------- 3 files changed, 82 insertions(+), 69 deletions(-) diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index eb02518..ee30ea0 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -212,60 +212,58 @@ func tryPrefixTime(r *Result, text string) (string, bool) { // 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 + var outLines []string + for _, line := range strings.Split(text, "\n") { + tokens := strings.Fields(line) + var lineParts []string + for _, tok := range tokens { + switch { + 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:]) + case strings.HasPrefix(tok, "##") && len(tok) > 2: + lineParts = append(lineParts, "#"+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 + case strings.HasPrefix(tok, "@") && len(tok) > 1: + timeStr := tok[1:] + if validateTime(timeStr) != nil { + lineParts = append(lineParts, tok) + } else if r.TimeAnchor != nil { + return "", fmt.Errorf("multiple time anchors") + } else { + r.TimeAnchor = &timeStr + } + + case strings.HasPrefix(tok, "#") && len(tok) > 1: + tag := strings.ToLower(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: + lineParts = append(lineParts, tok) } - - // #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) } + outLines = append(outLines, strings.Join(lineParts, " ")) } - return strings.Join(parts, " "), nil + return strings.Join(outLines, "\n"), nil } func validateTime(s string) error { diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go index 29daa0b..0734dc5 100644 --- a/internal/parse/grammar_test.go +++ b/internal/parse/grammar_test.go @@ -94,6 +94,11 @@ func TestParse(t *testing.T) { {"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, ""}, + // Multiline body preserves newlines + {"multiline body", "hello\nworld", "hello\nworld", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"multiline with tags", "line one #ops\nline two", "line one\nline two", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"title multiline body", "|my title\nfirst line\nsecond line", "first line\nsecond line", "note", sp("my title"), nil, nil, nil, nil, false, false, nil, ""}, + // Edge cases {"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"}, {"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"}, diff --git a/web/app.js b/web/app.js index ce1d52e..e850a03 100644 --- a/web/app.js +++ b/web/app.js @@ -229,36 +229,40 @@ // 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 (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; + const outLines = []; + for (const line of text.split('\n')) { + const tokens = line.split(/[ \t]+/).filter(Boolean); + const lineParts = []; + for (const tok of tokens) { + if (handleFlags && tok.toLowerCase() === '!pin') { + localPin = true; + } else if (tok.startsWith('##') && tok.length > 2) { + lineParts.push('#' + tok.slice(2)); + } else if (tok.startsWith('@') && tok.length > 1) { + const ts = tok.slice(1); + if (validateTime(ts) && localTime === null) { + localTime = ts; + } else { + lineParts.push(tok); + } + } else if (tok.startsWith('#') && tok.length > 1) { + 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] && localCard === null) localCard = VALID_CARDS[suffix]; + else lineParts.push(tok); } else { - parts.push(tok); + lineParts.push(tok); } - } else if (tok.startsWith('#') && tok.length > 1) { - 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] && localCard === null) localCard = VALID_CARDS[suffix]; - else parts.push(tok); - } else { - parts.push(tok); } + outLines.push(lineParts.join(' ')); } - return { body: parts.join(' '), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin }; + return { body: outLines.join('\n'), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin }; } let title = null, description = null; @@ -737,8 +741,13 @@ let actions = ''; if (!e.card_type) { actions += ``; + actions += ``; + } + if (e.card_type) { + actions += ``; + } else { + actions += ``; } - actions += ``; return `
@@ -1271,7 +1280,8 @@ list.innerHTML = sources.map(e => { const g = displayGlyph(e); const gc = glyphClass(e); - const label = e.title ? escHtml(e.title) : escHtml(e.body); + const rawLabel = e.title || (e.body || '').split('\n').find(l => l.trim()) || ''; + const label = escHtml(rawLabel.length > 80 ? rawLabel.slice(0, 80) + '…' : rawLabel); return `
${g} ${label} @@ -1460,7 +1470,7 @@ const now = Date.now(); if (now - lastDTime < 400) { if (sel) { - if (sel.card_type && state.view === 'cards') nibApp.demoteEntity(sel.id); + if (sel.card_type) nibApp.demoteEntity(sel.id); else nibApp.deleteEntity(sel.id); } lastDTime = 0; -- 2.52.0