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.
This commit is contained in:
+42
-44
@@ -212,60 +212,58 @@ func tryPrefixTime(r *Result, text string) (string, bool) {
|
|||||||
// extractModifiers extracts tags, flags, time anchors, and card suffixes from text.
|
// 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).
|
// 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) {
|
func extractModifiers(r *Result, text string, handleFlags bool) (string, error) {
|
||||||
tokens := strings.Fields(text)
|
|
||||||
var parts []string
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, t := range r.Tags {
|
for _, t := range r.Tags {
|
||||||
seen[strings.ToLower(t)] = true
|
seen[strings.ToLower(t)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tok := range tokens {
|
var outLines []string
|
||||||
switch {
|
for _, line := range strings.Split(text, "\n") {
|
||||||
// !pin flag
|
tokens := strings.Fields(line)
|
||||||
case handleFlags && strings.EqualFold(tok, "!pin"):
|
var lineParts []string
|
||||||
r.Pin = true
|
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:
|
||||||
case strings.HasPrefix(tok, "##") && len(tok) > 2:
|
lineParts = append(lineParts, "#"+tok[2:])
|
||||||
parts = append(parts, "#"+tok[2:])
|
|
||||||
|
|
||||||
// @time inline time anchor (for todos: sets due_at)
|
case strings.HasPrefix(tok, "@") && len(tok) > 1:
|
||||||
// If not valid time format, keep as body text
|
timeStr := tok[1:]
|
||||||
case strings.HasPrefix(tok, "@") && len(tok) > 1:
|
if validateTime(timeStr) != nil {
|
||||||
timeStr := tok[1:]
|
lineParts = append(lineParts, tok)
|
||||||
if validateTime(timeStr) != nil {
|
} else if r.TimeAnchor != nil {
|
||||||
parts = append(parts, tok)
|
return "", fmt.Errorf("multiple time anchors")
|
||||||
} else if r.TimeAnchor != nil {
|
} else {
|
||||||
return "", fmt.Errorf("multiple time anchors")
|
r.TimeAnchor = &timeStr
|
||||||
} 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 {
|
func validateTime(s string) error {
|
||||||
|
|||||||
@@ -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, ""},
|
{"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, ""},
|
{"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
|
// Edge cases
|
||||||
{"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, 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 glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
|
||||||
|
|||||||
+35
-25
@@ -229,36 +229,40 @@
|
|||||||
|
|
||||||
// Steps 6-8: Extract flags, tags, time, card suffix
|
// Steps 6-8: Extract flags, tags, time, card suffix
|
||||||
function extractModifiers(text, handleFlags) {
|
function extractModifiers(text, handleFlags) {
|
||||||
const tokens = text.split(/\s+/).filter(Boolean);
|
|
||||||
const parts = [];
|
|
||||||
let localTime = timeAnchor, localPin = pin, localCard = cardSuffix;
|
let localTime = timeAnchor, localPin = pin, localCard = cardSuffix;
|
||||||
const localTags = [...tags];
|
const localTags = [...tags];
|
||||||
const localSeen = { ...seenTags };
|
const localSeen = { ...seenTags };
|
||||||
|
|
||||||
for (const tok of tokens) {
|
const outLines = [];
|
||||||
if (handleFlags && tok.toLowerCase() === '!pin') {
|
for (const line of text.split('\n')) {
|
||||||
localPin = true;
|
const tokens = line.split(/[ \t]+/).filter(Boolean);
|
||||||
} else if (tok.startsWith('##') && tok.length > 2) {
|
const lineParts = [];
|
||||||
parts.push('#' + tok.slice(2));
|
for (const tok of tokens) {
|
||||||
} else if (tok.startsWith('@') && tok.length > 1) {
|
if (handleFlags && tok.toLowerCase() === '!pin') {
|
||||||
const ts = tok.slice(1);
|
localPin = true;
|
||||||
if (validateTime(ts) && localTime === null) {
|
} else if (tok.startsWith('##') && tok.length > 2) {
|
||||||
localTime = ts;
|
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 {
|
} 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;
|
let title = null, description = null;
|
||||||
@@ -737,8 +741,13 @@
|
|||||||
let actions = '';
|
let actions = '';
|
||||||
if (!e.card_type) {
|
if (!e.card_type) {
|
||||||
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
|
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||||
|
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
||||||
|
}
|
||||||
|
if (e.card_type) {
|
||||||
|
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||||
|
} else {
|
||||||
|
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||||
}
|
}
|
||||||
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
|
||||||
|
|
||||||
return `<div class="peek-scroll">
|
return `<div class="peek-scroll">
|
||||||
<div class="peek-brow">
|
<div class="peek-brow">
|
||||||
@@ -1271,7 +1280,8 @@
|
|||||||
list.innerHTML = sources.map(e => {
|
list.innerHTML = sources.map(e => {
|
||||||
const g = displayGlyph(e);
|
const g = displayGlyph(e);
|
||||||
const gc = glyphClass(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 `<div class="absorb-source-item" data-id="${e.id}">
|
return `<div class="absorb-source-item" data-id="${e.id}">
|
||||||
<span class="entity-glyph ${gc}">${g}</span>
|
<span class="entity-glyph ${gc}">${g}</span>
|
||||||
<span class="entity-body">${label}</span>
|
<span class="entity-body">${label}</span>
|
||||||
@@ -1460,7 +1470,7 @@
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastDTime < 400) {
|
if (now - lastDTime < 400) {
|
||||||
if (sel) {
|
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);
|
else nibApp.deleteEntity(sel.id);
|
||||||
}
|
}
|
||||||
lastDTime = 0;
|
lastDTime = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user