118 Commits

Author SHA1 Message Date
lerko 4517b2e37c feat(tui): include backlinks in link picker
CI / test (pull_request) Successful in 2m25s
Link picker now shows both outgoing [[links]] and backlinks in
a unified list with section headers. Backlinks follow by entity
ID directly, outgoing links resolve by text. Navigating into a
backlink works the same as following an outgoing link — pushes
to nav stack, esc pops back.
2026-05-21 14:12:28 -04:00
lerko 2684eb1d24 feat(tui): add link picker and navigation history
CI / test (pull_request) Successful in 2m18s
Press [ in detail view to open link picker showing all [[links]]
in the current entry. Enter follows a link, resolving by title
then body substring. Navigation history stack enables esc to pop
back through followed links before returning to list.

Adds Store.ResolveLink() for non-transactional link resolution
from the TUI layer.
2026-05-21 14:03:09 -04:00
lerko 8426c2fbc1 Merge pull request 'feat(db): add wiki-link extraction, resolution, and backlinks' (#44) from feat/entry-linking into main
CI / test (push) Successful in 2m25s
Reviewed-on: #44
2026-05-21 17:49:04 +00:00
lerko 1e58433936 feat(db): add wiki-link extraction, resolution, and backlinks
CI / test (pull_request) Successful in 2m27s
[[wiki-links]] in entry bodies are extracted at save time, resolved
to entity IDs (title match first, body substring fallback), and
stored in entity_links junction table. Backlinks surface in TUI
detail view showing entries that link to the current entry.

Schema migration v5 adds entity_links with CASCADE/SET NULL
semantics. Links sync on Create, Update, and Absorb.
2026-05-21 13:34:56 -04:00
lerko d24df8432f Merge pull request 'feat(tui): add tag autocomplete and query composition' (#43) from feat/tag-autocomplete-query-compose into main
CI / test (push) Successful in 2m17s
Reviewed-on: #43
2026-05-21 16:22:00 +00:00
lerko e22e040688 feat(tui): add tag autocomplete and query composition
CI / test (pull_request) Successful in 2m31s
Tag autocomplete shows suggestions when typing #partial in capture bar.
Tab/enter accepts, up/down navigates, esc dismisses.

Query composition extends ? search with date filters (@today, @week,
@month, <7d, >30d), card type filters (^snippet), all composable
with existing text and tag filters.
2026-05-21 12:12:07 -04:00
lerko 29bd7d3dc6 Merge pull request 'fix(ci): act runner compatibility' (#42) from fix/ci-act-runner-compat into main
CI / test (push) Successful in 2m41s
Reviewed-on: #42
2026-05-21 15:11:04 +00:00
lerko a9da5c1765 fix(ci): act runner compatibility
CI / test (pull_request) Successful in 2m28s
- Default shell to sh (act runner image lacks bash)
- Drop -race flag (requires cgo, unavailable in act runner)
2026-05-21 11:07:37 -04:00
lerko b9b3f99be9 Revert "fix(ci): use sh instead of bash for act runner compatibility"
CI / test (push) Failing after 13s
This reverts commit cae651302a.
2026-05-21 11:07:10 -04:00
lerko cae651302a fix(ci): use sh instead of bash for act runner compatibility
CI / test (push) Failing after 1m25s
Gitea act runner image lacks bash, causing all run steps to fail with
exit 127. Default shell to sh which is available in all images.
2026-05-21 10:54:34 -04:00
lerko 8fc686ec6d Merge pull request 'feat(export): add HTML card deck export' (#41) from feat/html-card-export into main
CI / test (push) Failing after 17s
2026-05-21 02:00:38 +00:00
lerko 564039112a feat(export): add HTML card deck export
CI / test (pull_request) Failing after 10s
Self-contained single-file HTML export for cards. Mobile-first,
dark theme, zero dependencies. Each card type gets its own
interactive treatment: snippet tap-to-copy, template slot filling,
checklist with progress bar, decision structured layout, link
tap targets. Filter chips by type, search across all cards.

Usage: nib export -f html -o deck.html
       nib export -f html -t triage -o triage.html
2026-05-20 21:49:19 -04:00
lerko eea59b3f3c Merge pull request 'fix: code hardening from senior dev audit' (#40) from fix/audit-phase1-hardening into main
CI / test (push) Failing after 16s
2026-05-21 01:04:30 +00:00
lerko ceb29fdd7b chore: mark all audit phases complete in TODO
CI / test (pull_request) Failing after 56s
2026-05-20 20:54:57 -04:00
lerko 2152baeb4f feat: add export and backup commands
- nib export: dump all entities to JSON (stdout or --output file)
- nib backup: atomic SQLite backup via VACUUM INTO (WAL-safe)
- Store.Backup() method on db layer
- Tests for both commands
2026-05-20 20:54:44 -04:00
lerko 33f6d99ba7 test(cmd): add tests for add, delete, promote, demote, absorb, ls
Covers happy paths, error cases (not found, already promoted,
already fluid, crystallized target, same-entity absorb), and
empty result sets. Uses NIB_DB env var for test isolation.
2026-05-20 20:52:57 -04:00
lerko d715b053e7 refactor(db): thread context.Context through all Store methods
Enables request-scoped cancellation, timeouts, and graceful shutdown
for all database operations across API handlers, CLI commands, and TUI.
2026-05-20 20:51:51 -04:00
lerko 50b80f4407 ci: add Gitea Actions workflow for test and lint on PR 2026-05-20 20:42:16 -04:00
lerko 8663beeb96 fix: harden API, DB, and web layer from audit findings
- Cap list API limit at 200 to prevent unbounded queries
- Sanitize markdown output with DOMPurify to prevent XSS
- Add v4 migration with indexes on deleted_at and modified_at
- Fix v2 migration swallowed ALTER TABLE errors
- Tighten ~/.nib directory permissions to 0o700
2026-05-20 20:41:53 -04:00
lerko 1ac4196547 Merge pull request 'feat(tui): add 13 preloaded themes matching web design system' (#39) from feat/tui-theme into main
Reviewed-on: #39
2026-05-21 00:27:49 +00:00
lerko a96c1a52f4 feat(tui): add 13 preloaded themes matching web design system
Port all web CSS token themes to TUI via shared vocabulary (accent, dim,
muted, ok, todo, event, remind, danger). Styles rebuild from active
theme on switch. Press T to cycle, persists to ~/.nib/theme. Glamour
markdown renderer respects light/dark per theme.
2026-05-20 20:13:21 -04:00
lerko db1dc135d2 Merge pull request 'fix(web): mobile edit via inline fullscreen' (#38) from fix/mobile-edit into main
Reviewed-on: #38
2026-05-20 23:17:45 +00:00
lerko 7d1e0f895c fix(web): mobile edit via inline fullscreen instead of hidden detail pane
Detail pane is display:none on mobile, so edit mode was unreachable.
Render edit fields directly in the inline expansion with exp-full
takeover. ESC and Cmd+Enter work from within inputs.

Closes #32
2026-05-20 19:14:02 -04:00
lerko 82bc6e7ba1 Merge pull request 'fix(tui): stream layout density and alignment' (#37) from fix/stream-layout-density into main
Reviewed-on: #37
2026-05-20 22:57:58 +00:00
lerko 533e086ffb fix(tui): esc closes detail split when list is focused
Previously esc in split view with list focus fell through to capture
focus. Now closes the detail pane and stays in stream browse mode.
2026-05-20 18:57:15 -04:00
lerko 989aa86679 fix(tui): compute truncation budget from actual overhead, not magic numbers
Tags wrapped past pane edge when detail split narrowed the list.
Truncation used fixed constants that didn't account for real tag width.
Now measures everything-except-body and gives body exactly what remains.
2026-05-20 18:49:38 -04:00
lerko 3eb778f31b fix(tui): clean up stream row density — drop ID, fix newline leak, align margins
ID cluttered rows and caused wrapping on long entries. Body newlines
leaked into stream rendering extra unindented lines. Cursor glyph
shifted selected rows 1 col right of unselected.

Remove ID from all row renderers (detail pane already shows it),
collapse multiline body to first line, cap tags to 2 in stream,
and reserve cursor column on unselected rows for consistent alignment.
2026-05-20 18:12:18 -04:00
lerko 98fdae1e3a Merge pull request 'feat(tui): stumble mode — resurface stale entries card by card' (#36) from feat/stumble into main
Reviewed-on: #36
2026-05-20 20:57:25 +00:00
lerko a567b2ce73 feat(tui): stumble mode — resurface stale entries card by card
Card-by-card walkthrough of entries untouched for 30+ days.
Prevents write-mostly decay by bringing old entries back to attention.

- S from list triggers stumble, loads entries where modified_at < 30d
- Single-card view with markdown body, glyph, tags, age indicator
- Actions: n skip, d dismiss, ! pin, p promote, m absorb, esc exit
- Progress indicator: stumble [3/12]
- After promote/absorb from stumble, returns to deck (not list)
- "All caught up" screen when deck exhausted
- DB: add ModifiedBefore to ListParams, modified_at sort column
2026-05-20 16:40:40 -04:00
lerko 388ae88d4a Merge pull request 'feat(tui): collapsible tag rail with ambient tag awareness' (#35) from feat/tag-rail into main
Reviewed-on: #35
2026-05-20 19:24:42 +00:00
lerko 60705463c1 fix(tui): simplify focus model — tab toggles capture ↔ list only
Tag rail removed from tab cycle to reduce focus confusion.
Rail is now ambient-by-default, focusable via h from list (spatial).

- Tab: capture ↔ list (no rail, no detail in cycle)
- h from list: focus tag rail (when visible)
- l from rail: back to list
- Split detail reachable via l/enter, not tab
- Remove nextFocusFromCapture helper
2026-05-20 15:08:11 -04:00
lerko b5b7f6b6ee feat(tui): collapsible tag rail with ambient tag awareness
Persistent left panel showing tags with counts. Provides ambient
awareness of tag landscape without requiring a modal.

- New tagRailModel in tagrail.go: tag list with cursor, scroll, counts
- Rail visible at >=100 cols width, 18% width (min 16 chars)
- ctrl+b toggles rail visibility
- focusTagRail added to focus cycle: capture → tags → list → detail
- j/k navigates, enter filters/unfilters by tag
- Active filter tag highlighted bold in rail
- Tags refresh after entity create/delete/absorb
- Rail auto-hides on narrow terminals, # modal still works as fallback
- Width allocation accounts for rail in split and non-split layouts
2026-05-20 14:32:32 -04:00
lerko 3f57531995 Merge pull request 'feat(tui): always-visible capture bar with focus cycling' (#34) from feat/capture-first into main
Reviewed-on: #34
2026-05-20 18:25:55 +00:00
lerko a2dac64d1f feat(tui): always-visible capture bar with focus cycling
Replace drawer-based input with permanent capture bar at bottom.
Focus defaults to capture on startup — open nib, start typing.

- Remove stateInput; route via focusCapture/focusList/focusDetail
- Tab cycles: capture → list → detail (split) → capture
- Esc cascades: clear search → clear filter → focus capture
- Capture bar shows blinking cursor when focused, dims when not
- Intent cycling moved from tab to i (tab now cycles focus)
- Parse preview shown inline in status bar while typing
- Content area constant height (no layout thrash from drawer)
2026-05-20 14:11:46 -04:00
lerko 3daa5a2e11 Merge pull request 'feat(tui): layout and interaction polish' (#33) from fix/tui-polish into main
Reviewed-on: #33
2026-05-20 16:33:58 +00:00
lerko c26e2d2022 feat(tui): status debounce, scroll indicator, drawer label, card grouping
Status messages now use a sequence counter so rapid actions don't
cause premature clearing. Detail pane shows scroll position and
supports pgup/pgdown/g/G. Capture drawer border includes inline
label. Cards view groups by intent (pinned/grab/read/fill) with
gutter labels matching the stream view's date grouping pattern.
2026-05-20 11:49:11 -04:00
lerko cb10d1e93d feat(tui): render entity body as markdown via glamour
Detail pane now pipes entity body through charmbracelet/glamour for
styled markdown output — headers, bold, code blocks, lists. Uses
hardcoded dark style to avoid terminal query freeze in alt screen.
2026-05-20 11:27:40 -04:00
lerko e20fae3543 feat(tui): add broot-style tab affordances to header and footer
Header now renders stream/cards as tabs with keybindings inline —
active tab highlighted, inactive shows the key to switch. Footer
shows a capture tab affordance when in list state. Redundant mode
and capture hints removed from the context hint bar.
2026-05-20 11:16:58 -04:00
lerko 4e0ac8402f fix(tui): pin footer to bottom, style hint bar, auto-clear status
Content area now enforces full height so the context help bar stays
pinned to the terminal bottom. Hint keys rendered with bold highlight
color for scannability. Status messages (created, deleted, etc.)
auto-clear after 2 seconds, reverting to the entity count.
2026-05-20 11:01:13 -04:00
lerko e2d0f3e997 fix(tui): add $VISUAL fallback for editor resolution
Check $EDITOR, then $VISUAL, then fall back to vi.
2026-05-20 10:34:09 -04:00
lerko 618335513b Merge pull request 'feat(tui): add bubbletea terminal UI' (#30) from feat/tui into main
Reviewed-on: #30
2026-05-20 01:16:57 +00:00
lerko 476abbed00 test(tui): add tier 1 unit tests for pure logic functions
Cover search filtering, intent matching, card affordances, checklist
parsing, template slot discovery/resolve, date grouping, and truncation.
2026-05-19 21:10:51 -04:00
lerko 39975a6787 chore(tui): remove dead formatPreviewEntity function 2026-05-19 21:01:26 -04:00
lerko 778fab3edd docs: add terminal UI to README and development guide 2026-05-19 20:56:29 -04:00
lerko a141b2fd4f Merge pull request 'feat(tui): split-pane detail, compact headers, input drawer' (#31) from feat/tui-layout into feat/tui
Reviewed-on: #31
2026-05-19 23:59:44 +00:00
lerko f89ca8acb9 feat(tui): add split-pane detail, compact date headers, and input drawer
Three layout improvements for better space utilization:

- Compact date headers: date labels render as left gutter column instead
  of standalone lines, saving one line per date group in stream view
- Input drawer: capture bar expands to 4-line drawer with border, hints,
  and live preview of parsed entity/search query
- Split-pane detail: wide terminals (>=100 cols) show list and detail
  side-by-side with h/l focus switching, falling back to full-screen
  detail on narrow terminals
2026-05-19 19:55:37 -04:00
lerko e09919b679 fix: harden API, DB schema, and CLI safety
- Add 'reminder' to glyph CHECK constraint (was accepted by parser but
  rejected by DB)
- Default serve bind to 127.0.0.1, add --host flag for LAN access
- Validate card_data as JSON in Store.Create/Update/Promote
- Return pagination envelope {data,total,limit,offset} from list endpoint
- Append absorb breadcrumb to source entity before soft-delete
- Add Levenshtein fuzzy match to catch command typos before routing to add
- Replace DDL string-matching migrations with versioned schema_version table
- Update web UI and API tests for envelope response format
2026-05-19 18:30:17 -04:00
lerko babf1d6620 fix(tui): harden EDITOR handling and SQL sort/order validation
Split EDITOR env var on whitespace so multi-word values like
"code --wait" work correctly. Add allow-list switch for sort column
and order direction at the query boundary to prevent future callers
from passing unsanitized values into SQL.
2026-05-17 23:24:58 -04:00
lerko 77222ff1b8 feat(tui): add interactive run mode for checklists and fill mode for templates
Run mode (r key on checklist cards): cursor navigates steps, space
toggles done/undone, r resets all, esc saves changes to DB and exits.
Persists step state — improvement over web which discards on exit.

Fill mode (f key on template cards): tab/shift-tab navigates slots,
type to fill values, enter resolves template and copies to clipboard
with use count increment. Esc cancels without copying.

Both modes are sub-states of detail view, keeping architecture simple.
2026-05-17 21:53:55 -04:00
lerko 1066c0bc7d feat(tui): add search via capture bar and absorb flow
Search uses existing parse grammar ?prefix — type `?query #tag` in
capture bar to filter entities client-side. Substring match on
body+title+description with AND tag filtering. Esc clears search.

Absorb via m key on fluid entities — opens source picker showing all
other entities, enter merges source into target. Uses existing
store.Absorb() backend.
2026-05-17 21:35:44 -04:00
lerko ce335cabd6 feat(tui): add cards view, mode switching, promote picker, and card detail
Stream/cards toggle with 1/2 keys. Cards view with intent filtering
(tab cycles grab/read/fill/all), sort cycling (s key), pinned-first
ordering, and affordance badges. Promote picker (p key) with card type
selection and auto-detection from body content. Detail view renders
card_data per type: checklist steps, template slots, decision fields,
link URLs.

Extracts generateCardData to internal/carddata for reuse across cmd
and tui packages.
2026-05-17 21:14:14 -04:00
lerko c2ea63dd16 feat(tui): add status bar, help overlay, tag filter, and entity actions
Status bar with entity count and context-sensitive key hints. Help
overlay via ? key. Tag filter via # with cursor-navigable tag list.
Todo toggle (x), pin (!), promote (p), demote (D), copy (c), edit (e)
via $EDITOR. Delete confirmation with 3s timeout. Date-grouped list
with completed todo and pinned indicators. Esc clears active tag filter.

Adds CompletedAt/ClearCompleted to EntityUpdate for todo toggling.
2026-05-17 20:33:34 -04:00
lerko 36999cd825 feat(tui): add bubbletea terminal UI with entity list, detail, and capture
Adds `nib tui` command and `make tui` target. Scrollable entity list
with j/k navigation, enter for detail view, `a` to capture new entries
using the existing parse grammar, and `d` to delete.
2026-05-17 20:07:45 -04:00
lerko d995d1e708 Merge pull request 'feat(serve): add TLS support' (#29) from fix/copy-clipboard into main
Reviewed-on: #29
2026-05-17 21:51:22 +00:00
lerko dd8878ebcf feat(serve): add TLS support with --tls-cert and --tls-key flags
Adds make cert target for self-signed dev certs and development guide.
2026-05-17 14:53:14 -04:00
lerko 805467486b feat(dev): add air live-reload with make watch 2026-05-17 14:18:08 -04:00
lerko 4980714583 fix(api): check http errors before using response in tests 2026-05-17 14:10:40 -04:00
lerko 6d8170d219 build: add Makefile for dev, test, and build orchestration 2026-05-17 14:08:22 -04:00
lerko 73c6a315c1 Merge pull request 'feat/community-themes' (#28) from feat/community-themes into main
Reviewed-on: #28
2026-05-17 17:50:39 +00:00
lerko d5fa6cc56b feat(themes): replace cycle button with popover theme picker
Click theme button opens panel grouped by dark/light. Hover previews
theme live, click confirms. Dismiss on outside-click or Escape.
2026-05-17 13:41:40 -04:00
lerko 8555d0da19 feat(themes): add catppuccin latte, rosé pine dawn, and solarized light
Light variant community themes. Total theme count: 13.
2026-05-17 13:30:06 -04:00
lerko ec907d0e0d feat(themes): add gruvbox, rosé pine, tokyo night, and solarized dark
Tier 2 community themes mapped to nib's token system.
Total theme count: 10 (3 original + 3 tier 1 + 4 tier 2).
2026-05-17 13:28:35 -04:00
lerko a854f02854 feat(themes): add catppuccin mocha, nord, and dracula themes
Community-standard palettes mapped to nib's 18-token design system.
Theme cycle extended: dark → paper → tinycard → catppuccin → nord → dracula.
2026-05-17 13:28:10 -04:00
lerko 824192f581 Merge pull request 'fix: UI issues #23-25 + note card type + promote modal' (#26) from fix/ui-issues-23-24-25 into main
Reviewed-on: #26
2026-05-17 17:04:16 +00:00
lerko c2506ef7fd feat(ui): restructure promote modal into read/grab/fill columns
Card types grouped by intent: read (note, link, decision),
grab (snippet), fill (template, checklist). 3-column grid layout
with stacked buttons and intent headers.
2026-05-17 13:01:04 -04:00
lerko 2b177eeae9 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.
2026-05-17 12:49:43 -04:00
lerko 840084fbb0 fix(ui): render full card content in mobile inline expansion
Promoted cards now show decision/steps/link/body sections in inline
detail instead of just a body preview. Fullscreen removes line clamp.
2026-05-17 11:05:10 -04:00
lerko 4ec876b2d2 fix(ui): mobile capture sticky, post-delete focus, inline markdown
- Capture bar stays visible on mobile via sticky positioning (#25)
- Cursor moves to adjacent entry after delete instead of resetting (#24)
- Inline expansion renders styled markdown via .exp-body.md selectors (#23)
2026-05-17 10:40:04 -04:00
lerko e66b7d19f6 chore: tidy before tag
Update .gitignore (add .local/, remove stale spec entry).
Remove TODO.md (moved to .local/done/).
Remove docs/ISSUE_TEMPLATE.md (moved to .local/).
2026-05-16 23:20:58 -04:00
lerko 38db465cc2 chore: add issue templates (bug + feature)
Gitea-native yaml templates for web UI, local markdown copy for reference.
2026-05-16 23:06:50 -04:00
lerko 7023806e1a Merge pull request 'fix/mobile-view' (#22) from fix/stream-zoom-ui into main
Reviewed-on: #22
2026-05-17 02:50:40 +00:00
lerko fa960ec204 feat(ui): inline expansion for cards view at mobile
Same accordion pattern as stream: card-row gets entity-exp markup,
selectEntity/expandInline/dismissPeek/Escape all handle .card-row.
Fullscreen expand works for both views.
2026-05-16 22:46:01 -04:00
lerko ad44d35d9b fix(ui): render markdown in mobile inline expansion
Use renderMd instead of escHtml for exp-body content.
Add .md class for consistent markdown styling.
2026-05-16 22:38:38 -04:00
lerko 35df7dcb69 fix(ui): card fullscreen transparency in mobile stream
Explicit .is-card.exp-full selector overrides card background/border/margin
so fullscreen overlay is fully opaque.
2026-05-16 22:35:43 -04:00
lerko 694dfe1c89 feat(ui): inline expansion for mobile stream entries
Replace bottom-sheet peek with inline accordion at ≤900px.
Entries expand in-place with grid-template-rows animation (0.2s).
Body clamped to 3 lines; fullscreen uncaps it.
Selection toggles DOM classes instead of re-rendering for fluid j/k nav.
2026-05-16 22:32:41 -04:00
lerko 180757827b fix(ui): mobile breakpoint layout and peek interactions
Grid forced to single-column at ≤900px for all panel states.
Resize handles hidden, transitions killed to prevent slivers.
Peek pane gets mobile toolbar (expand/dismiss buttons).
Escape dismisses peek at any viewport. Z toggles full-screen
peek at mobile instead of no-op zen toggle.
2026-05-16 21:53:12 -04:00
lerko 3084152695 Merge pull request 'feat(ui): add favicon (diamond split-tip nib)' (#21) from feat/favicon into main
Reviewed-on: #21
2026-05-17 01:26:35 +00:00
lerko f449562b27 feat(ui): add favicon (diamond split-tip nib)
SVG favicon — gold nib shape on transparent background, no container.
Fills the full viewBox for maximum visibility at 16px.
2026-05-16 21:24:44 -04:00
lerko 1c5f6836f5 Merge pull request 'feat/resizable-panels' (#20) from feat/resizable-panels into main
Reviewed-on: #20
2026-05-17 00:48:39 +00:00
lerko ff190e395b feat(ui): context-sensitive z key (focus mode)
- Nothing selected: z toggles zen (hide both panels)
- Item selected: z expands peek to full width (focus mode)
- z again or Esc exits focus mode and deselects
- j/k still cycle items while in focus mode
2026-05-16 20:46:23 -04:00
lerko 0316076bf8 feat(ui): resizable rail and peek pane
- Drag handles between rail/center and center/peek
- Rail: 120–360px range, peek: 250–700px range
- Widths persisted in localStorage
- Handles hidden when panel is collapsed (zen mode)
- Transition disabled during drag for smooth resize
2026-05-16 20:42:20 -04:00
lerko a399c4fb15 Merge pull request 'feat(ui): self-host fonts, remove Google Fonts CDN' (#19) from feat/solidify-fonts into main
Reviewed-on: #19
2026-05-17 00:01:23 +00:00
lerko 03e982281c feat(ui): self-host fonts, remove Google Fonts CDN
- Bundle Satoshi (sans) and JetBrains Mono in web/fonts/
- New fonts.css with @font-face declarations
- Remove Google Fonts preconnect and stylesheet link
- Update --sans token: Satoshi replaces Space Grotesk/Inter
- Zero external font requests, works fully offline
- Keep extra fonts (Geo, Mooli, StackSansNotch) for future use
2026-05-16 19:58:11 -04:00
lerko 5fd324e4bb Merge pull request 'feat(ui): render markdown in peek pane' (#18) from feat/peek-markdown into main
Reviewed-on: #18
2026-05-16 23:52:57 +00:00
lerko b456dca4b3 feat(ui): render markdown in peek pane
- Add marked.js for full markdown rendering
- Stream peek body renders as markdown
- Card peek non-code content renders as markdown
- Code/snippet cards keep escaped pre/code display
- Styled: headers, lists, blockquotes, inline code, code blocks, links, hr
- Graceful fallback to escHtml if marked fails to load
2026-05-16 19:47:53 -04:00
lerko 4c3cdc55c6 Merge pull request 'feat(ui): zen mode and panel toggles' (#17) from feat/zen-mode into main
Reviewed-on: #17
2026-05-16 23:43:40 +00:00
lerko 9ea00c235b feat(ui): zen mode and panel toggles
- z: toggle zen mode (hide both sidebars)
- [: toggle tag rail
- ]: toggle peek pane
- Panel state persisted in localStorage
- CSS grid transition for smooth collapse
2026-05-16 19:40:44 -04:00
lerko b580ed46b0 Merge pull request 'feat/theme-and-sort' (#16) from feat/theme-and-sort into main
Reviewed-on: #16
2026-05-16 23:35:30 +00:00
lerko ef647aea7a feat(ui): sort dropdown for cards, capture bar prominence
- Cards sort dropdown: newest, oldest, most used — wired to reload
- Capture bar: larger font, more padding, accent glow on focus
- Prompt glyph scales up for visibility
2026-05-16 19:01:44 -04:00
lerko 35fe97a166 feat(ui): add tinycard theme
- New [data-theme="tinycard"] token block with purple accent palette
- Theme toggle cycles dark → paper → tinycard
- Load Inter font for tinycard sans stack
2026-05-16 19:00:25 -04:00
lerko 1f2daf4d0e Merge pull request 'fix(ui): tag counts, j/k nav, stream layout, search alignment' (#15) from fix/ui-bugs-phase1 into main
Reviewed-on: #15
2026-05-16 22:49:27 +00:00
lerko 8bfa9b15ed fix(ui): tag counts, j/k nav, stream layout, search alignment
- Tag rail counts now reflect cards-only when in cards view
  (ListTags accepts cardsOnly filter, JS passes it per view)
- j/k navigation scoped to visible (intent/search filtered) list
- scrollSelectedIntoView works in both stream and cards view
- Entity items wrap title/desc/preview in .entity-content flex
  container so tags/pills align right consistently
- Title no longer eaten by description/body (flex-shrink + min-width)
- Search bar centered in header with margin auto
- switchView awaits loadEntities+loadTags to fix stale intent counts
2026-05-16 18:42:05 -04:00
lerko ab07f631a7 Merge pull request 'feat: UI redesign, capture grammar, demo command' (#14) from develop into main
Reviewed-on: #14
2026-05-16 20:07:28 +00:00
lerko db3f88508e Merge pull request 'fix(ui): edit pill in stream peek, unified edit mode' (#13) from fix/ui-polish into develop 2026-05-16 19:22:52 +00:00
lerko 1c6ba2b34c fix(ui): add edit pill to stream peek, unify edit mode across views 2026-05-16 13:42:44 -04:00
lerko 13cb7b420e Merge pull request 'feat: add demo subcommand with seeded test data' (#12) from feat/demo-command into develop 2026-05-16 17:15:16 +00:00
lerko 5bb6e89523 feat: add demo subcommand with seeded test data
nib demo starts server with temp DB populated from testdata/demo.json.
Covers all glyphs, card types, tags, pins, completions, deletes, and
template fill placeholders.
2026-05-16 13:13:05 -04:00
lerko f6602e3595 Merge pull request 'feat: absorb button, preserve newlines, demote promoted items' (#11) from fix/absorb-button-and-newlines into develop 2026-05-16 17:02:00 +00:00
lerko b7dd58bf3e 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.
2026-05-16 13:00:22 -04:00
lerko 6654907c41 Merge pull request 'fix: align parsers with capture grammar, restore demote, add parse preview' (#10) from fix/capture-grammar-alignment into develop
Reviewed-on: #10
2026-05-16 16:15:40 +00:00
lerko 464ff5a8be fix(ui): replace delete with demote on card peek, scope d-key by view
Card peek action bar now shows demote instead of delete. Double-tap
d demotes in cards view, deletes in stream view.
2026-05-16 12:10:55 -04:00
lerko a8ea8f099f feat(ui): live parse preview pills + description in list rows
Capture bar shows inline pills as you type — glyph, title, desc,
tags, time, pin, card type. Textarea auto-grows on Shift+Enter.
Preview clears on save. Entity list rows now show description.
2026-05-16 12:01:05 -04:00
lerko 97ad71d66b fix(parser): align Go and JS parsers with capture grammar spec
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.
2026-05-16 11:12:36 -04:00
lerko 957265f7b4 Merge pull request 'feat(ui): redesign to match design handoff prototype' (#9) from feat/ui-redesign into develop
Reviewed-on: #9
2026-05-16 14:26:28 +00:00
lerko 6802474595 fix(ui): broken peek editing + wire up search
- Fix startEditBody/startEditField selectors (.detail-body → .peek-body,
  .detail-title → .peek-title) — double-click editing works again
- Wire up search input: client-side filter by body/title/description text
- Search supports #tag inline filters (e.g. "proxy #ops")
- Debounced input (150ms), Escape clears and blurs
- Shows "no matches" when search has no results
2026-05-16 09:47:35 -04:00
lerko f26716a9ee feat(ui): phase 4 — promote modal polish, TODO complete
- Promote modal: colored glyphs, type names, hint descriptions per type
- Show truncated entry body in promote modal subtitle
- Mark all redesign phases complete in TODO.md
2026-05-16 09:37:32 -04:00
lerko 1c95902e2b feat(ui): phase 3 — peek pane redesign with modes
- Full peek pane rewrite: idle state, stream peek, card peek
- Idle state shows keyboard shortcuts per view
- Stream peek: eyebrow (glyph + kind + id + timestamp), body, tags, context
- Card peek: card container with eyebrow, title, desc, meta, content sections
- Decision section with choice/rationale/rejected display
- Steps section with run button
- Code section with content display
- Run mode: interactive checklist with progress bar + step toggling
- Fill mode: inline slot editor with tab navigation + copy resolved
- Edit mode: form fields for title/desc/body/tags
- Mode pills (running/filling/editing) with colored badges
- Pin/unpin action via keyboard (p) and button
- Escape exits any active mode
- Keyboard shortcuts: r=run, f=fill, e=edit, p=pin in cards view
2026-05-16 09:35:06 -04:00
lerko 156ea6ea1c feat(ui): phase 2 — card rows, affordance badges, cards sub-header
- Rich card row rendering: title — preview — affordance badges — tags — pin — use count
- Affordance detection (code, fill, steps, decide, link) from entity shape
- Cards sub-header with scope label, count, sort dropdown
- Section labels (★ pinned / recent) in cards view
- Flash animation on copy (--a-str pulse)
- Tag pill styling for card rows
- Progress bar mini-display for checklists in card preview
2026-05-16 09:29:51 -04:00
lerko dda8426113 feat(ui): phase 1 — layout, tokens, header, rail redesign
- Switch mono font from Monaspace Neon to JetBrains Mono
- Grid layout 192px | 1fr | 400px (was 180/320)
- Move capture bar from header to bottom of center panel
- Add search input to header center
- Redesign tag rail: grid items with arrow/dot/name/count
- Add intent section (grab/read/fill) in cards view rail
- Add --a-str token, toast component
- Logo 16px 700 weight
2026-05-16 09:25:35 -04:00
lerko f4e178e3ee fix(cards): show title instead of body when present 2026-05-15 22:31:28 -04:00
lerko fadc6d9a2a fix(ls): show title instead of body when present 2026-05-15 22:30:59 -04:00
lerko b2d6603dcf docs: add README and MIT license 2026-05-15 22:01:47 -04:00
lerko 80b8a950a3 Merge pull request 'feat: add title and description to capture grammar' (#8) from feat/title-description into develop
Reviewed-on: #8
2026-05-16 01:37:10 +00:00
lerko c8e18f0bc1 feat: add title and description fields to capture grammar
Implement | prefix for titles and // separator for descriptions
across the full stack: parser, schema, API, CLI, and web frontend.

- Parser: line-aware extraction for |title, |title // desc,
  // leading desc, body // inline desc. URL-safe (skips :// lines).
  Modifiers (#tag, @time, ^card) extracted from all segments.
- Schema: ALTER TABLE migration adds title, description columns
- DB: Entity/EntityUpdate structs, all CRUD queries updated
- API: title/description on create/update/response, body validation
  relaxed (title-only entries valid)
- CLI: shows title as scan label when present
- Web: parseInput mirrors Go parser, list shows title, detail pane
  renders title + description with double-click inline editing
- Tests: 10 new cases (grammar, entity, API) — 71 total, all pass
2026-05-15 21:19:33 -04:00
lerko e477e8d512 Merge pull request 'fix: code principles audit — correctness, security, test-ability' (#5) from fix/code-principles-audit into develop
Reviewed-on: #5
2026-05-14 21:55:27 +00:00
lerko 6278cb1022 fix: code principles audit — correctness, security, testability
- Add rows.Err() checks after all scan loops (entities, tags, resolve)
- Surface time.Parse errors instead of silently discarding
- Extract entityRow scan helper to eliminate Get/List duplication
- Cap request body at 1MB via MaxBytesReader
- Stop leaking internal errors to API clients (log server-side only)
- Block javascript: URIs in link card open button (XSS)
- Fix all go vet failures in api_test.go (unchecked http errors)
- Add tests for display package, generateCardData, absorb-source-card
- Run go mod tidy to fix direct/indirect dep markers
2026-05-14 17:41:30 -04:00
lerko e708ea5c13 Merge pull request 'feat: implement nib design system — warm amber palette, dual themes, new typography' (#4) from feat/design-system into main
Reviewed-on: #4
2026-05-14 21:04:38 +00:00
lerko aa7c9aef7d feat: implement nib design system — warm amber palette, dual themes, new typography
Replace Tokyonight/Catppuccin blue palette with warm amber/ink identity.
Dual theme support (noir + paper) via data-theme attribute with
localStorage persistence. Space Grotesk for chrome, Monaspace Neon for
content. Updated glyph set (Strokes: — ○ ◇) across web and CLI.
2026-05-14 17:02:11 -04:00
95 changed files with 12507 additions and 1062 deletions
+18
View File
@@ -0,0 +1,18 @@
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/nib"
cmd = "go build -o ./tmp/nib ."
args_bin = ["serve"]
delay = 500
exclude_dir = ["tmp", "testdata", "docs"]
exclude_regex = ["_test\\.go$"]
include_ext = ["go", "html", "css", "js", "svg"]
kill_delay = 500
[log]
time = false
[misc]
clean_on_exit = true
+29
View File
@@ -0,0 +1,29 @@
name: Bug
about: Something broken
body:
- type: input
id: what
attributes:
label: What happened
placeholder: "stream entries disappear at mobile breakpoint"
validations:
required: true
- type: input
id: expected
attributes:
label: Expected
placeholder: "entries stay visible when viewport narrows"
- type: dropdown
id: area
attributes:
label: Area
options:
- ui
- api
- data
- other
- type: input
id: repro
attributes:
label: Repro steps (if not obvious)
placeholder: "zoom to 67%, press z twice"
+29
View File
@@ -0,0 +1,29 @@
name: Feature
about: New capability or enhancement
body:
- type: input
id: what
attributes:
label: What
placeholder: "inline tag editing in stream view"
validations:
required: true
- type: input
id: why
attributes:
label: Why
placeholder: "avoid opening peek just to add a tag"
- type: dropdown
id: area
attributes:
label: Area
options:
- ui
- api
- data
- other
- type: input
id: notes
attributes:
label: Notes
placeholder: "see mockup in docs/.local/"
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Format check
run: |
diff=$(gofmt -l .)
if [ -n "$diff" ]; then
echo "Files need formatting:"
echo "$diff"
exit 1
fi
- name: Test
run: go test -count=1 ./...
- name: Build
run: go build -trimpath -o nib .
+5 -3
View File
@@ -1,5 +1,7 @@
# Binary
nib
tmp/
certs/
# Database
*.db
@@ -22,9 +24,6 @@ nib
.DS_Store
Thumbs.db
# Spec (not shipped)
nib-unified-spec.md
# Shell/profile dotfiles (host artifacts)
.bash_profile
.bashrc
@@ -35,3 +34,6 @@ nib-unified-spec.md
.gitmodules
.ripgreprc
.mcp.json
# Local Documents
.local/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tyler Koenig
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+71
View File
@@ -0,0 +1,71 @@
BINARY := nib
MODULE := github.com/lerko/nib
GOFLAGS := -trimpath
.PHONY: build dev tui watch test lint fmt vet clean run cert help
## —— Build ——————————————————————————————————
build: ## Build production binary
go build $(GOFLAGS) -o $(BINARY) .
dev: ## Build and run with default serve
go run . serve
tui: ## Launch the terminal UI
go run . tui
watch: ## Live-reload dev server (requires air)
air
## —— Quality ————————————————————————————————
test: ## Run all tests
go test ./...
test-v: ## Run all tests (verbose)
go test -v ./...
test-cover: ## Run tests with coverage report
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
@rm -f coverage.out
lint: vet fmt-check ## Run all linters
vet: ## Run go vet
go vet ./...
fmt: ## Format all Go files
gofmt -w .
fmt-check: ## Check formatting (fails if unformatted)
@test -z "$$(gofmt -l .)" || (echo "unformatted files:" && gofmt -l . && exit 1)
## —— Utility ————————————————————————————————
cert: ## Generate self-signed dev TLS cert (certs/)
@mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-nodes -keyout certs/dev.key -out certs/dev.crt -days 365 \
-subj "/CN=localhost"
@echo " certs/dev.crt and certs/dev.key ready"
@echo " usage: make run ARGS=\"serve --tls-cert certs/dev.crt --tls-key certs/dev.key\""
clean: ## Remove build artifacts
rm -f $(BINARY) coverage.out
run: build ## Build then run with args (usage: make run ARGS="serve")
./$(BINARY) $(ARGS)
tidy: ## Tidy and verify module dependencies
go mod tidy
go mod verify
## —— Help ———————————————————————————————————
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help
+172
View File
@@ -0,0 +1,172 @@
# nib
Capture-first note system. Catch thoughts, todos, and events before they slip — from terminal or browser. Everything lives in a single SQLite file.
nib uses a tiny grammar to extract structure from plain text. You type naturally, and nib figures out what it is: a thought, a todo with a due date, an event, a titled reference. Tags, descriptions, and time anchors are pulled out automatically. The body stays as markdown.
When something proves useful, promote it to a card (snippet, template, checklist, decision, link) for quick reuse. Cards track usage and float to the top.
## Install
Requires Go 1.24+.
```
go build -o nib .
```
Data lives at `~/.nib/nib.db` by default. Override with `NIB_DB=/path/to/file.db`.
## Quick start
```bash
# capture a thought
nib "proxy_pass needs a trailing slash"
# todo
nib "- buy mass gainer @tomorrow #errands"
# titled entry with description
nib "|nginx proxy trick // always forget this #ops"
# todo with a title
nib "- |deploy staging // rebuild docker image #ops"
# list recent entries
nib ls
# list by tag
nib ls --tag ops
# list a specific month
nib ls --month 2026-05
# terminal UI
nib tui
# start the web UI
nib serve
```
Open `http://localhost:4444` for the browser interface.
## Grammar
The full grammar fits on an index card. Here's what matters:
### Kind prefixes
The first character decides what kind of entry you're creating.
| Input | Kind | Example |
|-------|------|---------|
| bare text | thought | `just an idea` |
| `- text` | todo | `- buy milk @tomorrow` |
| `@time text` | event | `@friday 2pm lunch with alex` |
| `!time text` | reminder | `!3pm call dentist` |
The dash needs a space after it. `-deploy` is a thought, `- deploy` is a todo.
### Titles and descriptions
Give an entry a name with `|` at the start. Add a description with `//`.
```
|nginx proxy trick
proxy_pass http://backend/;
|deploy checklist // for staging #ops
1. docker build
2. docker push
3. ssh prod && restart
// quick reference for proxy config
the actual body text goes here
body text // this part becomes the description
```
Title shows as the scan label in list view. Description appears in the detail pane. Both are optional — most captures won't need them.
### Tags and flags
Tags and flags work anywhere in the input. They're extracted and removed from the body.
```
deploy nginx #ops #infra → tags: ops, infra
important thing !pin → pinned to top
use ##channel in slack → literal #channel in body (escaped)
```
### Cards
Promote a fluid entry to a card for reuse:
```bash
nib promote <id> snippet # code trick, copy-to-clipboard
nib promote <id> template # has ${slots} to fill
nib promote <id> checklist # step-through items
nib promote <id> decision # chose/why/rejected
nib promote <id> link # URL with an open button
```
Or use `^type` inline: `nib "proxy trick #nginx ^card"`
## CLI commands
| Command | What it does |
|---------|-------------|
| `nib <input>` | Capture (shorthand for `nib add`) |
| `nib ls` | List entries — filter with `--tag`, `--date`, `--month`, `--from`/`--to` |
| `nib cards` | List cards sorted by usage |
| `nib edit <id>` | Open in `$EDITOR` |
| `nib copy <id>` | Copy body to clipboard |
| `nib promote <id> [type]` | Promote to card |
| `nib demote <id>` | Strip card, back to fluid |
| `nib absorb <target> <source>` | Merge source into target |
| `nib delete <id>` | Soft delete (repeat to hard delete) |
| `nib serve` | Start web UI on `:4444` (or `--port`) |
| `nib tui` | Launch the terminal UI |
IDs are prefix-matchable. If `01KRQ4` is unique, that's enough.
## Terminal UI
`nib tui` launches a keyboard-driven interface in your terminal.
- **Stream view** — entries grouped by date with compact gutter headers
- **Cards view** — promoted cards filtered by intent (grab/read/fill), sorted by usage
- **Split-pane detail** — wide terminals (100+ cols) show list and detail side-by-side
- **Capture drawer** — inline add with live preview of parsed entity
- **Search** — type `?query #tag` in the capture bar to filter
Navigation: `j`/`k` move, `enter` opens detail, `h`/`l` switch panes, `a` to add, `?` for full keybindings.
## Web UI
`nib serve` starts a local web interface with:
- **Capture bar** — same grammar as the CLI
- **Stream view** — entries grouped by date, newest first
- **Cards view** — promoted cards sorted by use count
- **Tag rail** — filter by tag
- **Month navigator** — browse by date range
- **Detail pane** — full entry view, double-click to edit
- **Keyboard shortcuts** — `j`/`k` navigate, `n` to capture, `p` to promote, `e` to edit, `dd` to delete
Dark and light themes. Toggle with the button in the header.
## Data
Everything is one SQLite file. Back it up, sync it, move it between machines — it's just a file. WAL mode is on for concurrent reads.
```bash
# back up
cp ~/.nib/nib.db ~/.nib/nib.db.bak
# use a different database
NIB_DB=/path/to/other.db nib ls
```
## License
MIT
+27
View File
@@ -0,0 +1,27 @@
# Code Hardening — Senior Dev Audit Fixes
## Phase 1: Quick Wins (safety + correctness)
- [x] Cap API list limit at 200
- [x] Fix markdown XSS — add DOMPurify to sanitize marked output
- [x] Add missing DB indexes (deleted_at, modified_at) via v4 migration
- [x] Fix v2 migration error handling (swallowed ALTER TABLE errors)
- [x] Fix ~/.nib directory permissions (0o755 → 0o700)
## Phase 2: CI Pipeline
- [x] Gitea Actions workflow: test + lint on PR
## Phase 3: context.Context in Store
- [x] Thread context.Context through all Store methods
- [x] Use context in API handlers (from r.Context())
- [x] Use context in CLI commands (cobra context)
## Phase 4: cmd/ Tests
- [x] Test add command
- [x] Test ls command
- [x] Test promote/demote commands
- [x] Test delete command
- [x] Test absorb command
## Phase 5: Backup/Export
- [x] nib export — dump entities to JSON
- [x] nib backup — safe SQLite backup (handles WAL)
+4 -4
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(absorbCmd)
}
func runAbsorb(_ *cobra.Command, args []string) error {
func runAbsorb(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
targetID, err := store.Resolve(args[0])
targetID, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
sourceID, err := store.Resolve(args[1])
sourceID, err := store.Resolve(cmd.Context(), args[1])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[1])
}
@@ -40,7 +40,7 @@ func runAbsorb(_ *cobra.Command, args []string) error {
return fmt.Errorf("target and source must be different entities")
}
if err := store.Absorb(targetID, sourceID); err != nil {
if err := store.Absorb(cmd.Context(), targetID, sourceID); err != nil {
if err == db.ErrTargetCrystallized {
return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first",
display.FormatID(targetID))
+9 -2
View File
@@ -17,7 +17,7 @@ var addCmd = &cobra.Command{
RunE: runAdd,
}
func runAdd(_ *cobra.Command, args []string) error {
func runAdd(cmd *cobra.Command, args []string) error {
input := strings.Join(args, " ")
parsed, err := parse.Parse(input)
@@ -33,8 +33,11 @@ func runAdd(_ *cobra.Command, args []string) error {
e := &db.Entity{
Body: parsed.Body,
Title: parsed.Title,
Description: parsed.Description,
Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags,
Pinned: parsed.Pin,
}
if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor
@@ -44,7 +47,7 @@ func runAdd(_ *cobra.Command, args []string) error {
e.CardType = &ct
}
if err := store.Create(e); err != nil {
if err := store.Create(cmd.Context(), e); err != nil {
return err
}
@@ -53,7 +56,11 @@ func runAdd(_ *cobra.Command, args []string) error {
var parts []string
parts = append(parts, glyph)
if e.Title != nil {
parts = append(parts, " "+*e.Title)
} else {
parts = append(parts, " "+e.Body)
}
if e.TimeAnchor != nil {
parts = append(parts, " @"+*e.TimeAnchor)
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"time"
"github.com/lerko/nib/internal/db"
"github.com/spf13/cobra"
)
var backupCmd = &cobra.Command{
Use: "backup [path]",
Short: "create a safe backup of the database",
Long: "Creates an atomic backup using VACUUM INTO. Safe with WAL mode — no need to stop the server.",
Args: cobra.MaximumNArgs(1),
RunE: runBackup,
}
func init() {
rootCmd.AddCommand(backupCmd)
}
func runBackup(cmd *cobra.Command, args []string) error {
srcPath, err := db.DefaultPath()
if err != nil {
return err
}
dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405"))
if len(args) > 0 {
dst = args[0]
}
store, err := db.Open(srcPath)
if err != nil {
return err
}
defer store.Close()
if err := store.Backup(dst); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
fmt.Printf("backed up to %s\n", dst)
return nil
}
+8 -3
View File
@@ -26,7 +26,7 @@ func init() {
rootCmd.AddCommand(cardsCmd)
}
func runCards(_ *cobra.Command, _ []string) error {
func runCards(cmd *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
@@ -49,7 +49,7 @@ func runCards(_ *cobra.Command, _ []string) error {
p.CardTypeFilter = &ct
}
entities, err := store.List(p)
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
@@ -63,8 +63,13 @@ func runCards(_ *cobra.Command, _ []string) error {
tagStr += " #" + tag
}
label := e.Body
if e.Title != nil {
label = *e.Title
}
fmt.Printf("%s %-40s %-16s %3d× %s\n",
glyph, e.Body,
glyph, label,
strings.TrimSpace(tagStr),
e.UseCount, shortID)
}
+286
View File
@@ -0,0 +1,286 @@
package cmd
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lerko/nib/internal/db"
"github.com/spf13/cobra"
)
func testStore(t *testing.T) *db.Store {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
t.Setenv("NIB_DB", dbPath)
store, err := db.Open(dbPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
return store
}
func newCmd() *cobra.Command {
c := &cobra.Command{}
c.SetContext(context.Background())
return c
}
func captureOutput(t *testing.T, fn func()) string {
t.Helper()
var buf bytes.Buffer
rootCmd.SetOut(&buf)
defer rootCmd.SetOut(nil)
fn()
return buf.String()
}
func seedEntity(t *testing.T, store *db.Store, body string, glyph db.Glyph) *db.Entity {
t.Helper()
e := &db.Entity{Body: body, Glyph: glyph, Tags: []string{}}
if err := store.Create(context.Background(), e); err != nil {
t.Fatal(err)
}
return e
}
func TestRunAdd(t *testing.T) {
testStore(t)
err := runAdd(newCmd(), []string{"hello", "world"})
if err != nil {
t.Fatalf("runAdd: %v", err)
}
}
func TestRunAddWithGlyph(t *testing.T) {
testStore(t)
err := runAdd(newCmd(), []string{"-", "buy", "milk", "#errands"})
if err != nil {
t.Fatalf("runAdd todo: %v", err)
}
}
func TestRunAddWithTimeAnchor(t *testing.T) {
testStore(t)
err := runAdd(newCmd(), []string{"@", "dentist", "@14:00"})
if err != nil {
t.Fatalf("runAdd event: %v", err)
}
}
func TestRunDelete(t *testing.T) {
store := testStore(t)
e := seedEntity(t, store, "to delete", db.GlyphNote)
store.Close()
err := runDelete(newCmd(), []string{e.ID})
if err != nil {
t.Fatalf("runDelete soft: %v", err)
}
err = runDelete(newCmd(), []string{e.ID})
if err != nil {
t.Fatalf("runDelete hard: %v", err)
}
}
func TestRunDeleteNotFound(t *testing.T) {
testStore(t)
err := runDelete(newCmd(), []string{"nonexistent"})
if err == nil {
t.Fatal("expected error for nonexistent id")
}
if !strings.Contains(err.Error(), "not_found") {
t.Fatalf("expected not_found error, got: %v", err)
}
}
func TestRunPromote(t *testing.T) {
store := testStore(t)
e := seedEntity(t, store, "reusable snippet", db.GlyphNote)
store.Close()
err := runPromote(newCmd(), []string{e.ID, "snippet"})
if err != nil {
t.Fatalf("runPromote: %v", err)
}
}
func TestRunPromoteAlreadyPromoted(t *testing.T) {
store := testStore(t)
e := seedEntity(t, store, "already a card", db.GlyphNote)
store.Promote(context.Background(), e.ID, db.CardSnippet, nil)
store.Close()
err := runPromote(newCmd(), []string{e.ID, "snippet"})
if err == nil {
t.Fatal("expected error for already promoted")
}
}
func TestRunDemote(t *testing.T) {
store := testStore(t)
e := seedEntity(t, store, "demote me", db.GlyphNote)
store.Promote(context.Background(), e.ID, db.CardSnippet, nil)
store.Close()
err := runDemote(newCmd(), []string{e.ID})
if err != nil {
t.Fatalf("runDemote: %v", err)
}
}
func TestRunDemoteAlreadyFluid(t *testing.T) {
store := testStore(t)
e := seedEntity(t, store, "already fluid", db.GlyphNote)
store.Close()
err := runDemote(newCmd(), []string{e.ID})
if err == nil {
t.Fatal("expected error for already fluid")
}
}
func TestRunAbsorb(t *testing.T) {
store := testStore(t)
target := seedEntity(t, store, "target body", db.GlyphNote)
source := seedEntity(t, store, "source body", db.GlyphNote)
store.Close()
err := runAbsorb(newCmd(), []string{target.ID, source.ID})
if err != nil {
t.Fatalf("runAbsorb: %v", err)
}
}
func TestRunAbsorbSameEntity(t *testing.T) {
store := testStore(t)
e := seedEntity(t, store, "same entity", db.GlyphNote)
store.Close()
err := runAbsorb(newCmd(), []string{e.ID, e.ID})
if err == nil {
t.Fatal("expected error for same entity absorb")
}
}
func TestRunAbsorbCrystallizedTarget(t *testing.T) {
store := testStore(t)
target := seedEntity(t, store, "crystallized", db.GlyphNote)
source := seedEntity(t, store, "source", db.GlyphNote)
store.Promote(context.Background(), target.ID, db.CardSnippet, nil)
store.Close()
err := runAbsorb(newCmd(), []string{target.ID, source.ID})
if err == nil {
t.Fatal("expected error for crystallized target")
}
}
func TestRunLs(t *testing.T) {
store := testStore(t)
seedEntity(t, store, "recent note", db.GlyphNote)
store.Close()
lsTag = ""
lsDate = ""
lsMonth = ""
lsFrom = ""
lsTo = ""
lsLimit = 0
lsAll = false
err := runLs(newCmd(), nil)
if err != nil {
t.Fatalf("runLs: %v", err)
}
}
func TestRunLsEmpty(t *testing.T) {
testStore(t)
lsTag = ""
lsDate = ""
lsMonth = ""
lsFrom = ""
lsTo = ""
lsLimit = 0
lsAll = false
err := runLs(newCmd(), nil)
if err != nil {
t.Fatalf("runLs empty: %v", err)
}
}
func TestRunExport(t *testing.T) {
store := testStore(t)
seedEntity(t, store, "export me", db.GlyphNote)
seedEntity(t, store, "export me too", db.GlyphTodo)
store.Close()
outFile := filepath.Join(t.TempDir(), "export.json")
exportOutput = outFile
err := runExport(newCmd(), nil)
if err != nil {
t.Fatalf("runExport: %v", err)
}
data, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("read export: %v", err)
}
var entities []exportEntity
if err := json.Unmarshal(data, &entities); err != nil {
t.Fatalf("unmarshal export: %v", err)
}
if len(entities) != 2 {
t.Fatalf("expected 2 entities, got %d", len(entities))
}
}
func TestRunBackup(t *testing.T) {
store := testStore(t)
seedEntity(t, store, "backup me", db.GlyphNote)
store.Close()
dst := filepath.Join(t.TempDir(), "backup.db")
err := runBackup(newCmd(), []string{dst})
if err != nil {
t.Fatalf("runBackup: %v", err)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatalf("backup file missing: %v", err)
}
if info.Size() == 0 {
t.Fatal("backup file is empty")
}
backed, err := db.Open(dst)
if err != nil {
t.Fatalf("open backup: %v", err)
}
defer backed.Close()
entities, err := backed.List(context.Background(), db.DefaultListParams())
if err != nil {
t.Fatalf("list backup: %v", err)
}
if len(entities) != 1 {
t.Fatalf("expected 1 entity in backup, got %d", len(entities))
}
}
+4 -4
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(copyCmd)
}
func runCopy(_ *cobra.Command, args []string) error {
func runCopy(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
e, err := store.Get(id)
e, err := store.Get(cmd.Context(), id)
if err != nil {
return err
}
@@ -40,7 +40,7 @@ func runCopy(_ *cobra.Command, args []string) error {
return fmt.Errorf("clipboard: %w", err)
}
if err := store.IncrementUse(id); err != nil {
if err := store.IncrementUse(cmd.Context(), id); err != nil {
return err
}
+3 -3
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(deleteCmd)
}
func runDelete(_ *cobra.Command, args []string) error {
func runDelete(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
result, err := store.SoftDelete(id)
result, err := store.SoftDelete(cmd.Context(), id)
if err != nil {
return err
}
+140
View File
@@ -0,0 +1,140 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/lerko/nib/internal/db"
"github.com/spf13/cobra"
)
var demoCmd = &cobra.Command{
Use: "demo",
Short: "start server with pre-populated demo data",
RunE: runDemo,
}
func init() {
rootCmd.AddCommand(demoCmd)
}
type demoEntity struct {
Body string `json:"body"`
Glyph string `json:"glyph"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
TimeAnchor *string `json:"time_anchor,omitempty"`
Pinned bool `json:"pinned"`
Completed bool `json:"completed"`
Deleted bool `json:"deleted"`
CardType *string `json:"card_type,omitempty"`
CardData *string `json:"card_data,omitempty"`
Tags []string `json:"tags"`
}
func runDemo(cmd *cobra.Command, _ []string) error {
tmpDir, err := os.MkdirTemp("", "nib-demo-*")
if err != nil {
return err
}
dbPath := filepath.Join(tmpDir, "demo.db")
fmt.Printf("demo db: %s\n", dbPath)
store, err := db.Open(dbPath)
if err != nil {
return err
}
if err := seedDemo(cmd.Context(), store); err != nil {
store.Close()
return fmt.Errorf("seed demo data: %w", err)
}
store.Close()
os.Setenv("NIB_DB", dbPath)
return runServe(nil, nil)
}
func seedDemo(ctx context.Context, store *db.Store) error {
data, err := findDemoFile()
if err != nil {
return err
}
var entries []demoEntity
if err := json.Unmarshal(data, &entries); err != nil {
return fmt.Errorf("parse demo.json: %w", err)
}
now := time.Now().UTC()
for i, entry := range entries {
e := &db.Entity{
Body: entry.Body,
Glyph: db.Glyph(entry.Glyph),
Tags: entry.Tags,
}
if entry.Title != nil {
e.Title = entry.Title
}
if entry.Description != nil {
e.Description = entry.Description
}
if entry.TimeAnchor != nil {
e.TimeAnchor = entry.TimeAnchor
}
if entry.Pinned {
e.Pinned = true
}
if entry.Completed {
t := now.Add(-time.Duration(i) * time.Hour)
e.CompletedAt = &t
}
if err := store.Create(ctx, e); err != nil {
return fmt.Errorf("entity %d: %w", i, err)
}
if entry.CardType != nil {
ct := db.CardType(*entry.CardType)
if err := store.Promote(ctx, e.ID, ct, entry.CardData); err != nil {
return fmt.Errorf("promote entity %d: %w", i, err)
}
}
if entry.Deleted {
store.SoftDelete(ctx, e.ID)
}
}
fmt.Printf("seeded %d entities\n", len(entries))
return nil
}
func findDemoFile() ([]byte, error) {
candidates := []string{
"testdata/demo.json",
filepath.Join(execDir(), "testdata", "demo.json"),
}
for _, path := range candidates {
data, err := os.ReadFile(path)
if err == nil {
return data, nil
}
}
return nil, fmt.Errorf("demo.json not found (looked in: %v)", candidates)
}
func execDir() string {
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
+3 -3
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(demoteCmd)
}
func runDemote(_ *cobra.Command, args []string) error {
func runDemote(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
if err := store.Demote(id); err != nil {
if err := store.Demote(cmd.Context(), id); err != nil {
if err == db.ErrAlreadyFluid {
return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id))
}
+9 -9
View File
@@ -21,19 +21,19 @@ func init() {
rootCmd.AddCommand(editCmd)
}
func runEdit(_ *cobra.Command, args []string) error {
func runEdit(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
e, err := store.Get(id)
e, err := store.Get(cmd.Context(), id)
if err != nil {
return err
}
@@ -55,11 +55,11 @@ func runEdit(_ *cobra.Command, args []string) error {
editor = "vi"
}
cmd := exec.Command(editor, tmpfile.Name())
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
editorCmd := exec.Command(editor, tmpfile.Name())
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
return fmt.Errorf("editor: %w", err)
}
@@ -74,7 +74,7 @@ func runEdit(_ *cobra.Command, args []string) error {
return nil
}
if err := store.Update(id, &db.EntityUpdate{Body: &body}); err != nil {
if err := store.Update(cmd.Context(), id, &db.EntityUpdate{Body: &body}); err != nil {
return err
}
+167
View File
@@ -0,0 +1,167 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/export"
"github.com/spf13/cobra"
)
var (
exportOutput string
exportFormat string
exportTag string
exportTitle string
)
var exportCmd = &cobra.Command{
Use: "export",
Short: "export entities to JSON or HTML card deck",
RunE: runExport,
}
func init() {
exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout")
exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "json", "output format: json or html")
exportCmd.Flags().StringVarP(&exportTag, "tag", "t", "", "filter by tag (used as deck name for HTML)")
exportCmd.Flags().StringVar(&exportTitle, "title", "", "deck title for HTML export")
rootCmd.AddCommand(exportCmd)
}
type exportEntity struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Body string `json:"body"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Glyph string `json:"glyph"`
TimeAnchor *string `json:"time_anchor,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"`
Pinned bool `json:"pinned"`
DeletedAt *string `json:"deleted_at,omitempty"`
Tags []string `json:"tags"`
CardType *string `json:"card_type,omitempty"`
CardData *string `json:"card_data,omitempty"`
UseCount int `json:"use_count"`
LastUsedAt *string `json:"last_used_at,omitempty"`
}
func runExport(cmd *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
p := db.DefaultListParams()
p.Limit = 10000
if exportTag != "" {
p.Tag = &exportTag
}
switch exportFormat {
case "html":
return runHTMLExport(cmd, store, p)
case "json":
p.IncludeDeleted = true
return runJSONExport(cmd, store, p)
default:
return fmt.Errorf("unknown format %q (use json or html)", exportFormat)
}
}
func runHTMLExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
p.CardsOnly = true
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
title := exportTitle
if title == "" && exportTag != "" {
title = "#" + exportTag
}
if title == "" {
title = "nib cards"
}
if exportOutput != "" {
f, err := os.Create(exportOutput)
if err != nil {
return err
}
defer f.Close()
if err := export.RenderHTML(f, entities, title); err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d cards to %s\n", len(entities), exportOutput)
return nil
}
return export.RenderHTML(cmd.OutOrStdout(), entities, title)
}
func runJSONExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
out := make([]exportEntity, len(entities))
for i, e := range entities {
out[i] = exportEntity{
ID: e.ID,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
ModifiedAt: e.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"),
Body: e.Body,
Title: e.Title,
Glyph: string(e.Glyph),
TimeAnchor: e.TimeAnchor,
Pinned: e.Pinned,
Tags: e.Tags,
CardData: e.CardData,
UseCount: e.UseCount,
}
if e.Description != nil {
out[i].Description = e.Description
}
if e.CompletedAt != nil {
s := e.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
out[i].CompletedAt = &s
}
if e.DeletedAt != nil {
s := e.DeletedAt.Format("2006-01-02T15:04:05Z07:00")
out[i].DeletedAt = &s
}
if e.CardType != nil {
s := string(*e.CardType)
out[i].CardType = &s
}
if e.LastUsedAt != nil {
s := e.LastUsedAt.Format("2006-01-02T15:04:05Z07:00")
out[i].LastUsedAt = &s
}
}
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if exportOutput != "" {
if err := os.WriteFile(exportOutput, data, 0o600); err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d entities to %s\n", len(out), exportOutput)
return nil
}
fmt.Println(string(data))
return nil
}
+8 -3
View File
@@ -36,7 +36,7 @@ func init() {
lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
}
func runLs(_ *cobra.Command, _ []string) error {
func runLs(cmd *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
@@ -88,7 +88,7 @@ func runLs(_ *cobra.Command, _ []string) error {
p.Since = &since
}
entities, err := store.List(p)
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
@@ -142,8 +142,13 @@ func printEntity(e *db.Entity) {
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
shortID := display.FormatID(e.ID)
label := e.Body
if e.Title != nil {
label = *e.Title
}
var line strings.Builder
fmt.Fprintf(&line, "%s %-40s", glyph, e.Body)
fmt.Fprintf(&line, "%s %-40s", glyph, label)
if e.TimeAnchor != nil {
fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor)
+6 -79
View File
@@ -1,11 +1,9 @@
package cmd
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
"github.com/spf13/cobra"
@@ -22,14 +20,14 @@ func init() {
rootCmd.AddCommand(promoteCmd)
}
func runPromote(_ *cobra.Command, args []string) error {
func runPromote(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
@@ -42,14 +40,14 @@ func runPromote(_ *cobra.Command, args []string) error {
cardType = db.CardType(args[1])
}
e, err := store.Get(id)
e, err := store.Get(cmd.Context(), id)
if err != nil {
return err
}
cardData := generateCardData(cardType, e.Body)
cd := carddata.GenerateCardData(cardType, e.Body)
if err := store.Promote(id, cardType, cardData); err != nil {
if err := store.Promote(cmd.Context(), id, cardType, cd); err != nil {
if err == db.ErrAlreadyPromoted {
return fmt.Errorf("invalid_promote — entity %s is already a %s",
display.FormatID(id), *e.CardType)
@@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error {
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
return nil
}
var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
func generateCardData(ct db.CardType, body string) *string {
var data string
switch ct {
case db.CardTemplate:
matches := templateSlotRe.FindAllStringSubmatch(body, -1)
type slot struct {
Name string `json:"name"`
Default string `json:"default"`
}
var slots []slot
seen := map[string]bool{}
for _, m := range matches {
name := m[1]
if !seen[name] {
slots = append(slots, slot{Name: name, Default: ""})
seen[name] = true
}
}
if slots == nil {
slots = []slot{}
}
b, _ := json.Marshal(map[string]any{"slots": slots})
data = string(b)
case db.CardChecklist:
type step struct {
Text string `json:"text"`
Done bool `json:"done"`
}
var steps []step
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
text := strings.TrimSpace(line[3:])
done := strings.HasPrefix(line, "[x]")
steps = append(steps, step{Text: text, Done: done})
}
}
if steps == nil {
steps = []step{{Text: body, Done: false}}
}
b, _ := json.Marshal(map[string]any{"steps": steps})
data = string(b)
case db.CardDecision:
b, _ := json.Marshal(map[string]any{
"chose": "",
"why": "",
"rejected": []string{},
})
data = string(b)
case db.CardLink:
url := ""
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
url = word
break
}
}
b, _ := json.Marshal(map[string]any{"url": url})
data = string(b)
default:
data = "{}"
}
return &data
}
+55
View File
@@ -1,6 +1,7 @@
package cmd
import (
"fmt"
"os"
"strings"
@@ -26,6 +27,10 @@ func Execute() error {
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
if first != "help" && first != "completion" &&
!isFlag && !isSubcommand(first) {
if near := nearSubcommand(first); near != "" {
fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near)
os.Exit(1)
}
// "--" stops cobra from parsing glyph prefixes like "-" as flags
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
}
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
return false
}
func nearSubcommand(name string) string {
for _, c := range rootCmd.Commands() {
if d := editDist(name, c.Name()); d > 0 && d <= 2 {
return c.Name()
}
for _, alias := range c.Aliases {
if d := editDist(name, alias); d > 0 && d <= 2 {
return alias
}
}
}
return ""
}
func editDist(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr := make([]int, lb+1)
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
ins := curr[j-1] + 1
del := prev[j] + 1
sub := prev[j-1] + cost
curr[j] = ins
if del < curr[j] {
curr[j] = del
}
if sub < curr[j] {
curr[j] = sub
}
}
prev = curr
}
return prev[lb]
}
func init() {
rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(lsCmd)
+29 -5
View File
@@ -19,7 +19,10 @@ var WebFS fs.FS
var (
servePort int
serveHost string
serveDev bool
tlsCert string
tlsKey string
)
var serveCmd = &cobra.Command{
@@ -29,12 +32,20 @@ var serveCmd = &cobra.Command{
}
func init() {
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444)")
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)")
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
rootCmd.AddCommand(serveCmd)
}
func runServe(_ *cobra.Command, _ []string) error {
useTLS := tlsCert != "" && tlsKey != ""
if (tlsCert != "") != (tlsKey != "") {
return fmt.Errorf("both --tls-cert and --tls-key are required for TLS")
}
port := servePort
if port == 0 {
if envPort := os.Getenv("NIB_PORT"); envPort != "" {
@@ -43,6 +54,8 @@ func runServe(_ *cobra.Command, _ []string) error {
return fmt.Errorf("invalid NIB_PORT: %w", err)
}
port = p
} else if useTLS {
port = 4443
} else {
port = 4444
}
@@ -59,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
router = api.NewRouter(store, serveDev, WebFS)
}
addr := fmt.Sprintf(":%d", port)
addr := fmt.Sprintf("%s:%d", serveHost, port)
srv := &http.Server{
Addr: addr,
Handler: router,
@@ -69,12 +82,23 @@ func runServe(_ *cobra.Command, _ []string) error {
defer stop()
go func() {
fmt.Printf("nib serving on %s\n", addr)
if useTLS {
fmt.Printf("nib serving on https://%s\n", addr)
} else {
fmt.Printf("nib serving on http://%s\n", addr)
}
if serveDev {
fmt.Println(" CORS enabled (dev mode)")
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
var listenErr error
if useTLS {
listenErr = srv.ListenAndServeTLS(tlsCert, tlsKey)
} else {
listenErr = srv.ListenAndServe()
}
if listenErr != nil && listenErr != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "server error: %v\n", listenErr)
}
}()
+25
View File
@@ -0,0 +1,25 @@
package cmd
import (
"github.com/lerko/nib/internal/tui"
"github.com/spf13/cobra"
)
var tuiCmd = &cobra.Command{
Use: "tui",
Short: "launch the terminal UI",
RunE: runTUI,
}
func init() {
rootCmd.AddCommand(tuiCmd)
}
func runTUI(_ *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
return tui.Run(store)
}
+97
View File
@@ -0,0 +1,97 @@
# Development Guide
## Prerequisites
- Go 1.24+
- [air](https://github.com/air-verse/air) (live-reload, install: `go install github.com/air-verse/air@latest`)
- OpenSSL (for dev TLS certs)
## Quick Start
```sh
make cert # one-time: generate self-signed TLS cert
make watch # start dev server with live-reload (HTTP, port 4444)
```
## Make Targets
### Build
| Target | Description |
|--------|-------------|
| `make build` | Compile production binary (`./nib`) |
| `make dev` | Run dev server from source (HTTP, port 4444) |
| `make watch` | Live-reload dev server via air — auto-rebuilds on save |
### Quality
| Target | Description |
|--------|-------------|
| `make test` | Run all tests |
| `make test-v` | Run all tests with verbose output |
| `make test-cover` | Run tests with per-function coverage report |
| `make lint` | Run vet + format check |
| `make vet` | Static analysis via `go vet` |
| `make fmt` | Auto-format all Go files |
| `make fmt-check` | Check formatting without modifying (CI-friendly) |
### Utility
| Target | Description |
|--------|-------------|
| `make cert` | Generate self-signed dev TLS cert in `certs/` (valid 365 days) |
| `make clean` | Remove build artifacts |
| `make tidy` | Tidy and verify Go module dependencies |
| `make run ARGS="..."` | Build then run with custom args |
| `make help` | List all targets |
## TLS
nib supports TLS via `--tls-cert` and `--tls-key` flags on the `serve` command.
### Dev (self-signed)
```sh
make cert
make run ARGS="serve --tls-cert certs/dev.crt --tls-key certs/dev.key"
```
Serves HTTPS on port 4443. Browser will warn about the self-signed cert — accept once.
This is needed for features that require a secure context (e.g. clipboard API).
### Production
For production, use a reverse proxy (Caddy, nginx) with real certificates in front of nib's HTTP server. Alternatively, pass real cert/key paths directly:
```sh
./nib serve --tls-cert /path/to/cert.pem --tls-key /path/to/key.pem
```
### Port Defaults
| Mode | Default Port |
|------|-------------|
| HTTP | 4444 |
| HTTPS | 4443 |
Override with `--port` or the `NIB_PORT` environment variable.
## Terminal UI
Run the TUI directly from source:
```sh
go run . tui
```
TUI code lives in `internal/tui/`. No live-reload — restart manually after changes.
## Typical Workflow
1. `make cert` — once, generates dev TLS cert
2. `make watch` — start coding, air rebuilds on save
3. Edit code, save, changes appear automatically
4. `make test` — verify nothing broke
5. `make lint` — check formatting and vet
6. Commit and push
+42 -6
View File
@@ -3,21 +3,57 @@ module github.com/lerko/nib
go 1.24.4
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/cobra v1.10.2
modernc.org/sqlite v1.37.1
)
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/glamour v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
)
+103 -2
View File
@@ -1,16 +1,73 @@
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
@@ -18,23 +75,67 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+249 -53
View File
@@ -25,15 +25,36 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store
}
func postJSON(srv *httptest.Server, path string, body any) *http.Response {
b, _ := json.Marshal(body)
resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
type listEnvelope struct {
Data []EntityResponse `json:"data"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
t.Helper()
var env listEnvelope
json.NewDecoder(resp.Body).Decode(&env)
return env.Data
}
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
return resp
}
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
t.Helper()
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": body,
"tags": tags,
})
@@ -49,7 +70,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st
func TestCreateEntity_Note(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "test note",
"tags": []string{"demo"},
})
@@ -76,7 +97,7 @@ func TestCreateEntity_Note(t *testing.T) {
func TestCreateEntity_MissingBody(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{})
resp := postJSON(t, srv, "/api/entities", map[string]any{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -93,7 +114,7 @@ func TestCreateEntity_MissingBody(t *testing.T) {
func TestCreateEntity_InvalidGlyph(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "test",
"glyph": "invalid",
})
@@ -108,7 +129,10 @@ func TestGetEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "test", nil)
resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID)
resp, err := http.Get(srv.URL + "/api/entities/" + created.ID)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -125,7 +149,10 @@ func TestGetEntity_Success(t *testing.T) {
func TestGetEntity_NotFound(t *testing.T) {
srv, _ := testServer(t)
resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT")
resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
@@ -138,11 +165,13 @@ func TestListEntities_Default(t *testing.T) {
createTestEntity(t, srv, "one", nil)
createTestEntity(t, srv, "two", nil)
resp, _ := http.Get(srv.URL + "/api/entities")
resp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 2 {
t.Fatalf("expected 2, got %d", len(entities))
}
@@ -153,11 +182,13 @@ func TestListEntities_FilterTag(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"home"})
resp, _ := http.Get(srv.URL + "/api/entities?tag=ops")
resp, err := http.Get(srv.URL + "/api/entities?tag=ops")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities))
}
@@ -167,17 +198,19 @@ func TestListEntities_CardsOnly(t *testing.T) {
srv, _ := testServer(t)
createTestEntity(t, srv, "fluid", nil)
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "card",
"card_type": "snippet",
})
resp.Body.Close()
resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true")
resp, err := http.Get(srv.URL + "/api/entities?cards_only=true")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 1 {
t.Fatalf("expected 1 card, got %d", len(entities))
}
@@ -189,14 +222,18 @@ func TestListEntities_Pagination(t *testing.T) {
createTestEntity(t, srv, "note", nil)
}
resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
var page1 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page1)
resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
if err != nil {
t.Fatal(err)
}
page1 := decodeList(t, resp)
resp.Body.Close()
resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
var page2 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page2)
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
if err != nil {
t.Fatal(err)
}
page2 := decodeList(t, resp)
resp.Body.Close()
if len(page1) != 2 || len(page2) != 2 {
@@ -211,10 +248,16 @@ func TestUpdateEntity_Body(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "old", nil)
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
mustJSON(map[string]any{"body": "new"})))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -233,8 +276,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
created := createTestEntity(t, srv, "doomed", nil)
// Soft delete
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
resp, _ := http.DefaultClient.Do(req)
req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var delResp DeleteResponse
json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close()
@@ -246,7 +295,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
}
// Hard delete
resp, _ = http.DefaultClient.Do(req)
req, err = http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
if err != nil {
t.Fatal(err)
}
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -257,7 +313,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
}
// Gone
resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID)
resp, err = http.Get(srv.URL + "/api/entities/" + created.ID)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
@@ -268,7 +327,7 @@ func TestPromoteEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
})
defer resp.Body.Close()
@@ -288,11 +347,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "template",
})
defer resp.Body.Close()
@@ -312,7 +371,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "bogus",
})
defer resp.Body.Close()
@@ -326,11 +385,11 @@ func TestDemoteEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -348,7 +407,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -360,7 +419,7 @@ func TestUseEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -379,7 +438,10 @@ func TestListTags_WithCounts(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
resp, _ := http.Get(srv.URL + "/api/tags")
resp, err := http.Get(srv.URL + "/api/tags")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var tags []TagResponse
@@ -391,14 +453,23 @@ func TestListTags_WithCounts(t *testing.T) {
func TestCORS_DevMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
store, _ := db.Open(path)
store, err := db.Open(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
router := NewRouter(store, true)
srv := httptest.NewServer(router)
defer srv.Close()
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req)
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
@@ -412,8 +483,14 @@ func TestCORS_DevMode(t *testing.T) {
func TestCORS_ProdMode(t *testing.T) {
srv, _ := testServer(t) // devMode=false
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req)
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
@@ -426,7 +503,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
target := createTestEntity(t, srv, "target body", []string{"ops"})
source := createTestEntity(t, srv, "source body", []string{"ops", "infra"})
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
"source_id": source.ID,
})
defer resp.Body.Close()
@@ -445,9 +522,11 @@ func TestAbsorbEntity_Success(t *testing.T) {
}
// Source should be soft-deleted (not in default list)
listResp, _ := http.Get(srv.URL + "/api/entities")
var entities []EntityResponse
json.NewDecoder(listResp.Body).Decode(&entities)
listResp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatal(err)
}
entities := decodeList(t, listResp)
listResp.Body.Close()
for _, ent := range entities {
if ent.ID == source.ID {
@@ -461,11 +540,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) {
target := createTestEntity(t, srv, "target", nil)
source := createTestEntity(t, srv, "source", nil)
postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{
postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
"source_id": source.ID,
})
defer resp.Body.Close()
@@ -485,7 +564,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) {
srv, _ := testServer(t)
e := createTestEntity(t, srv, "self", nil)
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
"source_id": e.ID,
})
defer resp.Body.Close()
@@ -499,7 +578,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
srv, _ := testServer(t)
e := createTestEntity(t, srv, "target", nil)
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -507,6 +586,123 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
}
}
func TestCreateEntity_WithTitle(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "body text",
"title": "nginx trick",
"description": "always forget this",
"tags": []string{"ops"},
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Title == nil || *e.Title != "nginx trick" {
t.Errorf("title: %v", e.Title)
}
if e.Description == nil || *e.Description != "always forget this" {
t.Errorf("description: %v", e.Description)
}
}
func TestCreateEntity_TitleOnly(t *testing.T) {
srv, _ := testServer(t)
title := "title only"
resp := postJSON(t, srv, "/api/entities", map[string]any{
"title": title,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Title == nil || *e.Title != "title only" {
t.Errorf("title: %v", e.Title)
}
}
func TestUpdateEntity_Title(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "body", nil)
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
mustJSON(map[string]any{"title": "new title"})))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Title == nil || *e.Title != "new title" {
t.Errorf("title: %v", e.Title)
}
}
func TestUpdateEntity_Description(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "body", nil)
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
mustJSON(map[string]any{"description": "new desc"})))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Description == nil || *e.Description != "new desc" {
t.Errorf("description: %v", e.Description)
}
}
func TestListEntities_TitleInResponse(t *testing.T) {
srv, _ := testServer(t)
title := "list title"
postJSON(t, srv, "/api/entities", map[string]any{
"body": "body",
"title": title,
}).Body.Close()
resp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
entities := decodeList(t, resp)
if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities))
}
if entities[0].Title == nil || *entities[0].Title != "list title" {
t.Errorf("title: %v", entities[0].Title)
}
}
func mustJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
+75 -34
View File
@@ -11,15 +11,20 @@ import (
type CreateEntityRequest struct {
Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
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"`
}
type UpdateEntityRequest struct {
Body *string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph *string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"`
Tags *[]string `json:"tags"`
@@ -87,6 +92,9 @@ func listEntities(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
return
}
if limit > 200 {
limit = 200
}
p.Limit = limit
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
@@ -97,18 +105,32 @@ func listEntities(store *db.Store) http.HandlerFunc {
}
p.Offset = offset
}
if p.Limit <= 0 {
p.Limit = 50
}
entities, err := store.List(p)
total, err := store.Count(r.Context(), p)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
resp := make([]EntityResponse, len(entities))
for i, e := range entities {
resp[i] = entityToResponse(e)
entities, err := store.List(r.Context(), p)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
items := make([]EntityResponse, len(entities))
for i, e := range entities {
items[i] = entityToResponse(e)
}
writeJSON(w, http.StatusOK, map[string]any{
"data": items,
"total": total,
"limit": p.Limit,
"offset": p.Offset,
})
}
}
@@ -119,8 +141,8 @@ func createEntity(store *db.Store) http.HandlerFunc {
return
}
if req.Body == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "body is required")
if req.Body == "" && req.Title == nil {
writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required")
return
}
@@ -135,10 +157,15 @@ func createEntity(store *db.Store) http.HandlerFunc {
e := &db.Entity{
Body: req.Body,
Title: req.Title,
Description: req.Description,
Glyph: glyph,
TimeAnchor: req.TimeAnchor,
Tags: req.Tags,
}
if req.Pinned != nil && *req.Pinned {
e.Pinned = true
}
if req.CardType != nil {
if !db.ValidCardType(*req.CardType) {
@@ -150,8 +177,12 @@ func createEntity(store *db.Store) http.HandlerFunc {
e.CardData = req.CardData
}
if err := store.Create(e); err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
if err := store.Create(r.Context(), e); err != nil {
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err)
return
}
@@ -162,13 +193,13 @@ func createEntity(store *db.Store) http.HandlerFunc {
func getEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -186,6 +217,8 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u := &db.EntityUpdate{}
u.Body = req.Body
u.Title = req.Title
u.Description = req.Description
u.Tags = req.Tags
u.Pinned = req.Pinned
u.CardData = req.CardData
@@ -210,18 +243,22 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u.CardType = &ct
}
if err := store.Update(id, u); err != nil {
if err := store.Update(r.Context(), id, u); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err)
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -235,13 +272,13 @@ type DeleteResponse struct {
func deleteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
result, err := store.SoftDelete(id)
result, err := store.SoftDelete(r.Context(), id)
if err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
label := "soft"
@@ -270,7 +307,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
return
}
if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil {
if err := store.Promote(r.Context(), id, db.CardType(req.CardType), req.CardData); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
@@ -279,13 +316,17 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err)
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -296,7 +337,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := store.Demote(id); err != nil {
if err := store.Demote(r.Context(), id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
@@ -305,13 +346,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -340,7 +381,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
return
}
if err := store.Absorb(id, req.SourceID); err != nil {
if err := store.Absorb(r.Context(), id, req.SourceID); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
return
@@ -349,13 +390,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -366,18 +407,18 @@ func useEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := store.IncrementUse(id); err != nil {
if err := store.IncrementUse(r.Context(), id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
+13
View File
@@ -2,12 +2,15 @@ package api
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/lerko/nib/internal/db"
)
const maxBodySize = 1 << 20 // 1 MB
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
@@ -18,6 +21,8 @@ type EntityResponse struct {
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"`
CompletedAt *string `json:"completed_at"`
@@ -41,6 +46,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
return false
@@ -48,12 +54,19 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
return true
}
func writeInternalError(w http.ResponseWriter, err error) {
log.Printf("internal error: %v", err)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
func entityToResponse(e *db.Entity) EntityResponse {
resp := EntityResponse{
ID: e.ID,
CreatedAt: e.CreatedAt.Format(time.RFC3339),
ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
Body: e.Body,
Title: e.Title,
Description: e.Description,
Glyph: string(e.Glyph),
Pinned: e.Pinned,
Tags: e.Tags,
+3 -2
View File
@@ -13,9 +13,10 @@ type TagResponse struct {
func listTags(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tags, err := store.ListTags()
cardsOnly := r.URL.Query().Get("cards_only") == "true"
tags, err := store.ListTags(r.Context(), cardsOnly)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
+102
View File
@@ -0,0 +1,102 @@
package carddata
import (
"encoding/json"
"regexp"
"strings"
"github.com/lerko/nib/internal/db"
)
var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
func GenerateCardData(ct db.CardType, body string) *string {
var data string
switch ct {
case db.CardTemplate:
matches := TemplateSlotRe.FindAllStringSubmatch(body, -1)
type slot struct {
Name string `json:"name"`
Default string `json:"default"`
}
var slots []slot
seen := map[string]bool{}
for _, m := range matches {
name := m[1]
if !seen[name] {
slots = append(slots, slot{Name: name, Default: ""})
seen[name] = true
}
}
if slots == nil {
slots = []slot{}
}
b, _ := json.Marshal(map[string]any{"slots": slots})
data = string(b)
case db.CardChecklist:
type step struct {
Text string `json:"text"`
Done bool `json:"done"`
}
var steps []step
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
text := strings.TrimSpace(line[3:])
done := strings.HasPrefix(line, "[x]")
steps = append(steps, step{Text: text, Done: done})
}
}
if steps == nil {
steps = []step{{Text: body, Done: false}}
}
b, _ := json.Marshal(map[string]any{"steps": steps})
data = string(b)
case db.CardDecision:
b, _ := json.Marshal(map[string]any{
"chose": "",
"why": "",
"rejected": []string{},
})
data = string(b)
case db.CardLink:
url := ""
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
url = word
break
}
}
b, _ := json.Marshal(map[string]any{"url": url})
data = string(b)
default:
data = "{}"
}
return &data
}
func DetectCardType(body string) *db.CardType {
if TemplateSlotRe.MatchString(body) {
ct := db.CardTemplate
return &ct
}
if strings.Contains(body, "chose:") || strings.Contains(body, "why:") {
ct := db.CardDecision
return &ct
}
if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") {
ct := db.CardChecklist
return &ct
}
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
ct := db.CardLink
return &ct
}
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package carddata
import (
"encoding/json"
"testing"
"github.com/lerko/nib/internal/db"
)
func TestGenerateCardData_Snippet(t *testing.T) {
data := GenerateCardData(db.CardSnippet, "some snippet")
if data == nil || *data != "{}" {
t.Errorf("snippet should produce {}, got %v", data)
}
}
func TestGenerateCardData_Template(t *testing.T) {
data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
if data == nil {
t.Fatal("expected non-nil data")
}
var parsed struct {
Slots []struct {
Name string `json:"name"`
Default string `json:"default"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Slots) != 2 {
t.Fatalf("expected 2 slots, got %d", len(parsed.Slots))
}
if parsed.Slots[0].Name != "host" {
t.Errorf("first slot: %q", parsed.Slots[0].Name)
}
if parsed.Slots[1].Name != "env" {
t.Errorf("second slot: %q", parsed.Slots[1].Name)
}
}
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
var parsed struct {
Slots []struct {
Name string `json:"name"`
} `json:"slots"`
}
json.Unmarshal([]byte(*data), &parsed)
if len(parsed.Slots) != 1 {
t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots))
}
}
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
data := GenerateCardData(db.CardTemplate, "no placeholders here")
var parsed struct {
Slots []struct {
Name string `json:"name"`
} `json:"slots"`
}
json.Unmarshal([]byte(*data), &parsed)
if len(parsed.Slots) != 0 {
t.Errorf("expected empty slots, got %d", len(parsed.Slots))
}
}
func TestGenerateCardData_Checklist(t *testing.T) {
body := "[ ] step one\n[x] step two\n[ ] step three"
data := GenerateCardData(db.CardChecklist, body)
if data == nil {
t.Fatal("expected non-nil data")
}
var parsed struct {
Steps []struct {
Text string `json:"text"`
Done bool `json:"done"`
} `json:"steps"`
}
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Steps) != 3 {
t.Fatalf("expected 3 steps, got %d", len(parsed.Steps))
}
if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done {
t.Errorf("step 0: %+v", parsed.Steps[0])
}
if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done {
t.Errorf("step 1: %+v", parsed.Steps[1])
}
}
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
var parsed struct {
Steps []struct {
Text string `json:"text"`
Done bool `json:"done"`
} `json:"steps"`
}
json.Unmarshal([]byte(*data), &parsed)
if len(parsed.Steps) != 1 {
t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps))
}
if parsed.Steps[0].Text != "no checkbox syntax" {
t.Errorf("fallback step text: %q", parsed.Steps[0].Text)
}
}
func TestGenerateCardData_Decision(t *testing.T) {
data := GenerateCardData(db.CardDecision, "which db?")
var parsed struct {
Chose string `json:"chose"`
Why string `json:"why"`
Rejected []string `json:"rejected"`
}
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
t.Fatal(err)
}
if parsed.Chose != "" || parsed.Why != "" {
t.Error("decision fields should start empty")
}
if len(parsed.Rejected) != 0 {
t.Error("rejected should start empty")
}
}
func TestGenerateCardData_Link(t *testing.T) {
data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
var parsed struct {
URL string `json:"url"`
}
json.Unmarshal([]byte(*data), &parsed)
if parsed.URL != "https://example.com/path" {
t.Errorf("url: %q", parsed.URL)
}
}
func TestGenerateCardData_LinkNoURL(t *testing.T) {
data := GenerateCardData(db.CardLink, "no url here")
var parsed struct {
URL string `json:"url"`
}
json.Unmarshal([]byte(*data), &parsed)
if parsed.URL != "" {
t.Errorf("expected empty url, got %q", parsed.URL)
}
}
+148 -8
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
@@ -14,6 +15,7 @@ var (
ErrAlreadyPromoted = errors.New("invalid_promote")
ErrAlreadyFluid = errors.New("invalid_demote")
ErrTargetCrystallized = errors.New("invalid_absorb")
ErrInvalidCardData = errors.New("invalid_card_data")
)
type Store struct {
@@ -49,22 +51,28 @@ func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
func (s *Store) Backup(dst string) error {
_, err := s.db.Exec("VACUUM INTO ?", dst)
return err
}
const currentSchema = 5
var migrations = []func(db *sql.DB) error{
// v1: initial schema
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS 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')),
glyph TEXT NOT NULL,
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')
OR card_type IS NULL),
card_type TEXT,
card_data TEXT,
use_count INTEGER NOT NULL DEFAULT 0,
last_used_at TEXT
@@ -85,6 +93,138 @@ func (s *Store) migrate() error {
ON entity_tags(tag);
`)
return err
},
// v2: add title and description columns
func(db *sql.DB) error {
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil {
return fmt.Errorf("add title column: %w", err)
}
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil {
return fmt.Errorf("add description column: %w", err)
}
return nil
},
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
func(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Disable FK checks during rebuild to avoid dangling references
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
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', 'reminder')),
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)
}
// Rebuild entity_tags to point FK at new entities table
if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags rename: %w", err)
}
if _, err := tx.Exec(`CREATE TABLE entity_tags (
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (entity_id, tag)
)`); err != nil {
return fmt.Errorf("migrate tags create: %w", err)
}
if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags copy: %w", err)
}
if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags drop: %w", err)
}
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("migrate fk on: %w", err)
}
return tx.Commit()
},
// v4: add indexes for common query filters
func(db *sql.DB) error {
for _, idx := range []string{
`CREATE INDEX IF NOT EXISTS idx_entities_deleted ON entities(deleted_at)`,
`CREATE INDEX IF NOT EXISTS idx_entities_modified ON entities(modified_at DESC) WHERE deleted_at IS NULL`,
} {
if _, err := db.Exec(idx); err != nil {
return fmt.Errorf("create index: %w", err)
}
}
return nil
},
// v5: add entity_links table for wiki-links
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE entity_links (
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
link_text TEXT NOT NULL,
PRIMARY KEY (from_id, link_text)
);
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
`)
return err
},
}
func (s *Store) migrate() error {
s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`)
var version int
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version)
if err != nil {
version = 0
}
for i := version; i < len(migrations); i++ {
if err := migrations[i](s.db); err != nil {
return fmt.Errorf("migration %d: %w", i+1, err)
}
}
if version == 0 {
_, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations))
} else if len(migrations) > version {
_, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations))
}
return err
}
func DefaultPath() (string, error) {
@@ -96,7 +236,7 @@ func DefaultPath() (string, error) {
return "", err
}
dir := filepath.Join(home, ".nib")
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return filepath.Join(dir, "nib.db"), nil
+204 -106
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -16,6 +17,7 @@ const (
GlyphNote Glyph = "note"
GlyphTodo Glyph = "todo"
GlyphEvent Glyph = "event"
GlyphReminder Glyph = "reminder"
)
type CardType string
@@ -26,11 +28,12 @@ const (
CardChecklist CardType = "checklist"
CardDecision CardType = "decision"
CardLink CardType = "link"
CardNote CardType = "note"
)
func ValidGlyph(s string) bool {
switch Glyph(s) {
case GlyphNote, GlyphTodo, GlyphEvent:
case GlyphNote, GlyphTodo, GlyphEvent, GlyphReminder:
return true
}
return false
@@ -38,7 +41,7 @@ func ValidGlyph(s string) bool {
func ValidCardType(s string) bool {
switch CardType(s) {
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink:
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote:
return true
}
return false
@@ -49,6 +52,8 @@ type Entity struct {
CreatedAt time.Time
ModifiedAt time.Time
Body string
Title *string
Description *string
Glyph Glyph
TimeAnchor *string
CompletedAt *time.Time
@@ -67,6 +72,7 @@ type ListParams struct {
From *string
To *string
Since *time.Time
ModifiedBefore *time.Time
CardsOnly bool
IncludeDeleted bool
CardTypeFilter *CardType
@@ -86,16 +92,23 @@ func DefaultListParams() ListParams {
type EntityUpdate struct {
Body *string
Title *string
Description *string
Glyph *Glyph
TimeAnchor *string
ClearTime bool
CompletedAt *time.Time
ClearCompleted bool
Pinned *bool
CardType *CardType
CardData *string
Tags *[]string
}
func (s *Store) Create(e *Entity) error {
func (s *Store) Create(ctx context.Context, e *Entity) error {
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
return ErrInvalidCardData
}
now := time.Now().UTC()
e.ID = nibulid.New()
e.CreatedAt = now
@@ -104,20 +117,23 @@ func (s *Store) Create(e *Entity) error {
e.Tags = []string{}
}
tx, err := s.db.Begin()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`
INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
_, err = tx.ExecContext(ctx, `
INSERT INTO entities (id, created_at, modified_at, body, title, description,
glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID,
e.CreatedAt.Format(time.RFC3339),
e.ModifiedAt.Format(time.RFC3339),
e.Body,
e.Title,
e.Description,
string(e.Glyph),
e.TimeAnchor,
formatTimePtr(e.CompletedAt),
@@ -132,27 +148,26 @@ func (s *Store) Create(e *Entity) error {
return err
}
if err := insertTags(tx, e.ID, e.Tags); err != nil {
if err := insertTags(ctx, tx, e.ID, e.Tags); err != nil {
return err
}
if err := syncLinks(ctx, tx, s, e.ID, e.Body); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) Get(id string) (*Entity, error) {
func (s *Store) Get(ctx context.Context, id string) (*Entity, error) {
e := &Entity{}
var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString
var pinned int
row := newEntityRow()
err := s.db.QueryRow(`
SELECT id, created_at, modified_at, body, glyph, time_anchor,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at
FROM entities WHERE id = ?`, id).Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
)
err := s.db.QueryRowContext(ctx, `
SELECT id, created_at, modified_at, body, title, description,
glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at
FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
@@ -160,17 +175,11 @@ func (s *Store) Get(id string) (*Entity, error) {
return nil, err
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0
e.DeletedAt = parseTimePtr(deletedAt)
e.CardType = nullToCardType(cardType)
e.CardData = nullToPtr(cardData)
e.LastUsedAt = parseTimePtr(lastUsedAt)
if err := row.apply(e); err != nil {
return nil, fmt.Errorf("scan entity %s: %w", id, err)
}
tags, err := s.loadTags(id)
tags, err := s.loadTags(ctx, id)
if err != nil {
return nil, err
}
@@ -179,7 +188,7 @@ func (s *Store) Get(id string) (*Entity, error) {
return e, nil
}
func (s *Store) List(params ListParams) ([]*Entity, error) {
func listWhere(params ListParams) (string, []any) {
var where []string
var args []any
@@ -213,19 +222,46 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
where = append(where, "e.card_type = ?")
args = append(args, string(*params.CardTypeFilter))
}
whereClause := ""
if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ")
if params.ModifiedBefore != nil {
where = append(where, "e.modified_at < ?")
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
}
clause := ""
if len(where) > 0 {
clause = "WHERE " + strings.Join(where, " AND ")
}
return clause, args
}
func (s *Store) Count(ctx context.Context, params ListParams) (int, error) {
whereClause, args := listWhere(params)
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
var count int
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
func (s *Store) List(ctx context.Context, params ListParams) ([]*Entity, error) {
whereClause, args := listWhere(params)
orderCol := "e.created_at"
if params.Sort == "use_count" {
switch params.Sort {
case "use_count":
orderCol = "e.use_count"
case "modified_at":
orderCol = "e.modified_at"
case "created_at", "":
orderCol = "e.created_at"
default:
orderCol = "e.created_at"
}
orderDir := "DESC"
if strings.EqualFold(params.Order, "asc") {
switch strings.ToLower(params.Order) {
case "asc":
orderDir = "ASC"
default:
orderDir = "DESC"
}
limit := params.Limit
@@ -234,9 +270,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
}
query := fmt.Sprintf(`
SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor,
e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data,
e.use_count, e.last_used_at
SELECT e.id, e.created_at, e.modified_at, e.body, e.title, e.description,
e.glyph, e.time_anchor, e.completed_at, e.pinned, e.deleted_at,
e.card_type, e.card_data, e.use_count, e.last_used_at
FROM entities e
%s
ORDER BY %s %s
@@ -244,7 +280,7 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
args = append(args, limit, params.Offset)
rows, err := s.db.Query(query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -253,46 +289,33 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
var entities []*Entity
for rows.Next() {
e := &Entity{}
var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString
var pinned int
if err := rows.Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
&completedAt, &pinned, &deletedAt, &cardType, &cardData,
&e.UseCount, &lastUsedAt,
); err != nil {
row := newEntityRow()
if err := rows.Scan(row.ptrs(e)...); err != nil {
return nil, err
}
if err := row.apply(e); err != nil {
return nil, err
}
entities = append(entities, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0
e.DeletedAt = parseTimePtr(deletedAt)
e.CardType = nullToCardType(cardType)
e.CardData = nullToPtr(cardData)
e.LastUsedAt = parseTimePtr(lastUsedAt)
entities = append(entities, e)
}
if err := s.batchLoadTags(entities); err != nil {
if err := s.batchLoadTags(ctx, entities); err != nil {
return nil, err
}
return entities, nil
}
func (s *Store) Update(id string, u *EntityUpdate) error {
existing, err := s.Get(id)
func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error {
existing, err := s.Get(ctx, id)
if err != nil {
return err
}
tx, err := s.db.Begin()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -308,6 +331,14 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "body = ?")
args = append(args, *u.Body)
}
if u.Title != nil {
sets = append(sets, "title = ?")
args = append(args, *u.Title)
}
if u.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *u.Description)
}
if u.Glyph != nil {
sets = append(sets, "glyph = ?")
args = append(args, string(*u.Glyph))
@@ -318,6 +349,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "time_anchor = ?")
args = append(args, *u.TimeAnchor)
}
if u.ClearCompleted {
sets = append(sets, "completed_at = NULL")
} else if u.CompletedAt != nil {
sets = append(sets, "completed_at = ?")
args = append(args, u.CompletedAt.Format(time.RFC3339))
}
if u.Pinned != nil {
sets = append(sets, "pinned = ?")
args = append(args, boolToInt(*u.Pinned))
@@ -327,6 +364,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
args = append(args, string(*u.CardType))
}
if u.CardData != nil {
if !json.Valid([]byte(*u.CardData)) {
return ErrInvalidCardData
}
sets = append(sets, "card_data = ?")
args = append(args, *u.CardData)
}
@@ -334,15 +374,21 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
args = append(args, existing.ID)
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
if _, err := tx.Exec(query, args...); err != nil {
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if u.Tags != nil {
if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
return err
}
if err := insertTags(tx, existing.ID, *u.Tags); err != nil {
if err := insertTags(ctx, tx, existing.ID, *u.Tags); err != nil {
return err
}
}
if u.Body != nil {
if err := syncLinks(ctx, tx, s, existing.ID, *u.Body); err != nil {
return err
}
}
@@ -350,8 +396,8 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
return tx.Commit()
}
func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
e, err := s.Get(id)
func (s *Store) Promote(ctx context.Context, id string, cardType CardType, cardData *string) error {
e, err := s.Get(ctx, id)
if err != nil {
return err
}
@@ -361,18 +407,21 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
dataVal := "{}"
if cardData != nil {
if !json.Valid([]byte(*cardData)) {
return ErrInvalidCardData
}
dataVal = *cardData
}
_, err = s.db.Exec(`
_, err = s.db.ExecContext(ctx, `
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
WHERE id = ?`,
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
return err
}
func (s *Store) Demote(id string) error {
e, err := s.Get(id)
func (s *Store) Demote(ctx context.Context, id string) error {
e, err := s.Get(ctx, id)
if err != nil {
return err
}
@@ -380,7 +429,7 @@ func (s *Store) Demote(id string) error {
return ErrAlreadyFluid
}
_, err = s.db.Exec(`
_, err = s.db.ExecContext(ctx, `
UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ?
WHERE id = ?`,
@@ -395,9 +444,9 @@ const (
DeletedHard
)
func (s *Store) SoftDelete(id string) (DeleteResult, error) {
func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error) {
var deletedAt sql.NullString
err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
err := s.db.QueryRowContext(ctx, "SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
if err == sql.ErrNoRows {
return 0, ErrNotFound
}
@@ -406,21 +455,21 @@ func (s *Store) SoftDelete(id string) (DeleteResult, error) {
}
if deletedAt.Valid {
_, err = s.db.Exec("DELETE FROM entities WHERE id = ?", id)
_, err = s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
return DeletedHard, err
}
_, err = s.db.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
_, err = s.db.ExecContext(ctx, "UPDATE entities SET deleted_at = ? WHERE id = ?",
time.Now().UTC().Format(time.RFC3339), id)
return DeletedSoft, err
}
func (s *Store) Absorb(targetID, sourceID string) error {
target, err := s.Get(targetID)
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
target, err := s.Get(ctx, targetID)
if err != nil {
return err
}
source, err := s.Get(sourceID)
source, err := s.Get(ctx, sourceID)
if err != nil {
return err
}
@@ -429,7 +478,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
return ErrTargetCrystallized
}
tx, err := s.db.Begin()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -438,7 +487,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
now := time.Now().UTC().Format(time.RFC3339)
merged := target.Body + "\n" + source.Body
if _, err := tx.Exec("UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
merged, now, targetID); err != nil {
return err
}
@@ -449,31 +498,36 @@ func (s *Store) Absorb(targetID, sourceID string) error {
}
for _, t := range source.Tags {
if !seen[t] {
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
targetID, t); err != nil {
return err
}
}
}
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
return err
}
if source.CardType != nil {
if _, err := tx.Exec(`UPDATE entities SET card_type = NULL, card_data = NULL,
if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
now, sourceID); err != nil {
return err
}
}
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
now, sourceID); err != nil {
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
absorbNote, now, now, sourceID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) IncrementUse(id string) error {
res, err := s.db.Exec(`
func (s *Store) IncrementUse(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
@@ -487,8 +541,8 @@ func (s *Store) IncrementUse(id string) error {
return nil
}
func (s *Store) Resolve(prefix string) (string, error) {
rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
func (s *Store) Resolve(ctx context.Context, prefix string) (string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
if err != nil {
return "", err
}
@@ -502,6 +556,9 @@ func (s *Store) Resolve(prefix string) (string, error) {
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return "", err
}
switch len(ids) {
case 0:
@@ -513,9 +570,45 @@ func (s *Store) Resolve(prefix string) (string, error) {
}
}
// helpers
type entityRow struct {
createdAt, modifiedAt string
completedAt, deletedAt, lastUsedAt sql.NullString
timeAnchor, cardType, cardData sql.NullString
title, description sql.NullString
pinned int
}
func (s *Store) batchLoadTags(entities []*Entity) error {
func newEntityRow() *entityRow { return &entityRow{} }
func (r *entityRow) ptrs(e *Entity) []any {
return []any{
&e.ID, &r.createdAt, &r.modifiedAt, &e.Body, &r.title, &r.description,
&e.Glyph, &r.timeAnchor, &r.completedAt, &r.pinned, &r.deletedAt,
&r.cardType, &r.cardData, &e.UseCount, &r.lastUsedAt,
}
}
func (r *entityRow) apply(e *Entity) error {
var err error
if e.CreatedAt, err = time.Parse(time.RFC3339, r.createdAt); err != nil {
return fmt.Errorf("created_at: %w", err)
}
if e.ModifiedAt, err = time.Parse(time.RFC3339, r.modifiedAt); err != nil {
return fmt.Errorf("modified_at: %w", err)
}
e.Title = nullToPtr(r.title)
e.Description = nullToPtr(r.description)
e.TimeAnchor = nullToPtr(r.timeAnchor)
e.CompletedAt = parseTimePtr(r.completedAt)
e.Pinned = r.pinned != 0
e.DeletedAt = parseTimePtr(r.deletedAt)
e.CardType = nullToCardType(r.cardType)
e.CardData = nullToPtr(r.cardData)
e.LastUsedAt = parseTimePtr(r.lastUsedAt)
return nil
}
func (s *Store) batchLoadTags(ctx context.Context, entities []*Entity) error {
if len(entities) == 0 {
return nil
}
@@ -535,7 +628,7 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
strings.Join(placeholders, ","),
)
rows, err := s.db.Query(query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
@@ -553,8 +646,8 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
return rows.Err()
}
func (s *Store) loadTags(entityID string) ([]string, error) {
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
func (s *Store) loadTags(ctx context.Context, entityID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
if err != nil {
return nil, err
}
@@ -568,15 +661,18 @@ func (s *Store) loadTags(entityID string) ([]string, error) {
}
tags = append(tags, tag)
}
if err := rows.Err(); err != nil {
return nil, err
}
if tags == nil {
tags = []string{}
}
return tags, nil
}
func insertTags(tx *sql.Tx, entityID string, tags []string) error {
func insertTags(ctx context.Context, tx *sql.Tx, entityID string, tags []string) error {
for _, tag := range tags {
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
entityID, tag); err != nil {
return err
}
@@ -631,11 +727,13 @@ func boolToInt(b bool) int {
return 0
}
func (e *Entity) CardDataJSON() map[string]interface{} {
func (e *Entity) CardDataJSON() (map[string]interface{}, error) {
if e.CardData == nil {
return nil
return nil, nil
}
var m map[string]interface{}
json.Unmarshal([]byte(*e.CardData), &m)
return m
if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil {
return nil, fmt.Errorf("card_data: %w", err)
}
return m, nil
}
+222 -66
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"testing"
"time"
)
@@ -11,15 +12,16 @@ func ptr[T any](v T) *T {
func TestCreate_Note(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "hello world", Glyph: GlyphNote}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
if e.ID == "" {
t.Fatal("ID not set")
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -33,12 +35,13 @@ func TestCreate_Note(t *testing.T) {
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -49,12 +52,13 @@ func TestCreate_TodoWithTimeAnchor(t *testing.T) {
func TestCreate_WithTags(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -65,13 +69,14 @@ func TestCreate_WithTags(t *testing.T) {
func TestCreate_WithCardType(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -82,7 +87,7 @@ func TestCreate_WithCardType(t *testing.T) {
func TestGet_NotFound(t *testing.T) {
s := testStore(t)
_, err := s.Get("01NONEXISTENT0000000000000")
_, err := s.Get(context.Background(), "01NONEXISTENT0000000000000")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
@@ -90,11 +95,12 @@ func TestGet_NotFound(t *testing.T) {
func TestList_DefaultParams(t *testing.T) {
s := testStore(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
}
entities, err := s.List(DefaultListParams())
entities, err := s.List(ctx, DefaultListParams())
if err != nil {
t.Fatal(err)
}
@@ -109,15 +115,16 @@ func TestList_DefaultParams(t *testing.T) {
func TestList_FilterByTag(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
p := DefaultListParams()
tag := "ops"
p.Tag = &tag
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -128,13 +135,14 @@ func TestList_FilterByTag(t *testing.T) {
func TestList_FilterByDate(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "today", Glyph: GlyphNote})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "today", Glyph: GlyphNote})
p := DefaultListParams()
date := time.Now().UTC().Format("2006-01-02")
p.Date = &date
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -144,7 +152,7 @@ func TestList_FilterByDate(t *testing.T) {
otherDate := "2020-01-01"
p.Date = &otherDate
entities, err = s.List(p)
entities, err = s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -155,13 +163,14 @@ func TestList_FilterByDate(t *testing.T) {
func TestList_CardsOnly(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote})
ct := CardSnippet
s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
p := DefaultListParams()
p.CardsOnly = true
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -175,12 +184,13 @@ func TestList_CardsOnly(t *testing.T) {
func TestList_IncludeDeleted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote}
s.Create(e)
s.SoftDelete(e.ID)
s.Create(ctx, e)
s.SoftDelete(ctx, e.ID)
p := DefaultListParams()
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -189,7 +199,7 @@ func TestList_IncludeDeleted(t *testing.T) {
}
p.IncludeDeleted = true
entities, err = s.List(p)
entities, err = s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -200,17 +210,18 @@ func TestList_IncludeDeleted(t *testing.T) {
func TestList_SortByUseCount(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
s.Create(e1)
s.Create(e2)
s.IncrementUse(e2.ID)
s.IncrementUse(e2.ID)
s.Create(ctx, e1)
s.Create(ctx, e2)
s.IncrementUse(ctx, e2.ID)
s.IncrementUse(ctx, e2.ID)
p := DefaultListParams()
p.Sort = "use_count"
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -221,14 +232,15 @@ func TestList_SortByUseCount(t *testing.T) {
func TestList_Pagination(t *testing.T) {
s := testStore(t)
ctx := context.Background()
for i := 0; i < 10; i++ {
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
}
p := DefaultListParams()
p.Limit = 3
p.Offset = 0
page1, err := s.List(p)
page1, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -237,7 +249,7 @@ func TestList_Pagination(t *testing.T) {
}
p.Offset = 3
page2, err := s.List(p)
page2, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -251,16 +263,17 @@ func TestList_Pagination(t *testing.T) {
func TestUpdate_Body(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "old", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
time.Sleep(1100 * time.Millisecond)
newBody := "new"
if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil {
if err := s.Update(ctx, e.ID, &EntityUpdate{Body: &newBody}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.Body != "new" {
t.Errorf("body not updated: %q", got.Body)
}
@@ -271,15 +284,16 @@ func TestUpdate_Body(t *testing.T) {
func TestUpdate_Tags(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
s.Create(e)
s.Create(ctx, e)
newTags := []string{"new1", "new2"}
if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
if err := s.Update(ctx, e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if len(got.Tags) != 2 {
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
}
@@ -287,14 +301,15 @@ func TestUpdate_Tags(t *testing.T) {
func TestPromote_Success(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "trick", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
if err := s.Promote(e.ID, CardSnippet, nil); err != nil {
if err := s.Promote(ctx, e.ID, CardSnippet, nil); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.CardType == nil || *got.CardType != CardSnippet {
t.Errorf("expected snippet, got %v", got.CardType)
}
@@ -302,26 +317,28 @@ func TestPromote_Success(t *testing.T) {
func TestPromote_AlreadyPromoted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
s.Create(e)
s.Create(ctx, e)
if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
if err := s.Promote(ctx, e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
}
}
func TestDemote_Success(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "trick", Glyph: GlyphNote}
s.Create(e)
s.Promote(e.ID, CardSnippet, nil)
s.Create(ctx, e)
s.Promote(ctx, e.ID, CardSnippet, nil)
if err := s.Demote(e.ID); err != nil {
if err := s.Demote(ctx, e.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.CardType != nil {
t.Errorf("expected nil card_type, got %v", got.CardType)
}
@@ -332,20 +349,22 @@ func TestDemote_Success(t *testing.T) {
func TestDemote_AlreadyFluid(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "trick", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
if err := s.Demote(e.ID); err != ErrAlreadyFluid {
if err := s.Demote(ctx, e.ID); err != ErrAlreadyFluid {
t.Errorf("expected ErrAlreadyFluid, got %v", err)
}
}
func TestSoftDelete_First(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
result, err := s.SoftDelete(e.ID)
result, err := s.SoftDelete(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -353,7 +372,7 @@ func TestSoftDelete_First(t *testing.T) {
t.Errorf("expected DeletedSoft, got %d", result)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.DeletedAt == nil {
t.Error("expected deleted_at to be set")
}
@@ -361,11 +380,12 @@ func TestSoftDelete_First(t *testing.T) {
func TestSoftDelete_Second(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
s.SoftDelete(e.ID)
result, err := s.SoftDelete(e.ID)
s.SoftDelete(ctx, e.ID)
result, err := s.SoftDelete(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -373,7 +393,7 @@ func TestSoftDelete_Second(t *testing.T) {
t.Errorf("expected DeletedHard, got %d", result)
}
_, err = s.Get(e.ID)
_, err = s.Get(ctx, e.ID)
if err != ErrNotFound {
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
}
@@ -381,7 +401,7 @@ func TestSoftDelete_Second(t *testing.T) {
func TestSoftDelete_NotFound(t *testing.T) {
s := testStore(t)
_, err := s.SoftDelete("01NONEXISTENT0000000000000")
_, err := s.SoftDelete(context.Background(), "01NONEXISTENT0000000000000")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
@@ -389,15 +409,16 @@ func TestSoftDelete_NotFound(t *testing.T) {
func TestIncrementUse(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
s.Create(e)
s.Create(ctx, e)
if err := s.IncrementUse(e.ID); err != nil {
if err := s.IncrementUse(ctx, e.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.UseCount != 1 {
t.Errorf("expected use_count=1, got %d", got.UseCount)
}
@@ -408,10 +429,11 @@ func TestIncrementUse(t *testing.T) {
func TestResolve_FullID(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "test", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
got, err := s.Resolve(e.ID)
got, err := s.Resolve(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -422,10 +444,11 @@ func TestResolve_FullID(t *testing.T) {
func TestResolve_Prefix(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "test", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
got, err := s.Resolve(e.ID[:6])
got, err := s.Resolve(ctx, e.ID[:6])
if err != nil {
t.Fatal(err)
}
@@ -436,8 +459,141 @@ func TestResolve_Prefix(t *testing.T) {
func TestResolve_NotFound(t *testing.T) {
s := testStore(t)
_, err := s.Resolve("ZZZZZZZZZ")
_, err := s.Resolve(context.Background(), "ZZZZZZZZZ")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestAbsorb_SourceIsCard(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
s.Create(ctx, target)
source := &Entity{Body: "source", Glyph: GlyphNote}
s.Create(ctx, source)
s.Promote(ctx, source.ID, CardSnippet, nil)
s.IncrementUse(ctx, source.ID)
if err := s.Absorb(ctx, target.ID, source.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(ctx, target.ID)
if got.Body != "target\nsource" {
t.Errorf("merged body: %q", got.Body)
}
src, _ := s.Get(ctx, source.ID)
if src.CardType != nil {
t.Error("source card_type should be cleared after absorb")
}
if src.UseCount != 0 {
t.Errorf("source use_count should be reset, got %d", src.UseCount)
}
if src.DeletedAt == nil {
t.Error("source should be soft-deleted")
}
}
func TestCreate_WithTitleAndDescription(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{
Body: "body text",
Title: ptr("nginx trick"),
Description: ptr("always forget this"),
Glyph: GlyphNote,
Tags: []string{"ops"},
}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
if got.Title == nil || *got.Title != "nginx trick" {
t.Errorf("title: got %v", got.Title)
}
if got.Description == nil || *got.Description != "always forget this" {
t.Errorf("description: got %v", got.Description)
}
if got.Body != "body text" {
t.Errorf("body: got %q", got.Body)
}
}
func TestCreate_WithoutTitle(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "just body", Glyph: GlyphNote}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, _ := s.Get(ctx, e.ID)
if got.Title != nil {
t.Errorf("expected nil title, got %v", got.Title)
}
if got.Description != nil {
t.Errorf("expected nil description, got %v", got.Description)
}
}
func TestUpdate_Title(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "body", Glyph: GlyphNote}
s.Create(ctx, e)
newTitle := "new title"
if err := s.Update(ctx, e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(ctx, e.ID)
if got.Title == nil || *got.Title != "new title" {
t.Errorf("title: got %v", got.Title)
}
}
func TestUpdate_Description(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "body", Glyph: GlyphNote}
s.Create(ctx, e)
newDesc := "new desc"
if err := s.Update(ctx, e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(ctx, e.ID)
if got.Description == nil || *got.Description != "new desc" {
t.Errorf("description: got %v", got.Description)
}
}
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
s.Create(ctx, target)
s.Create(ctx, source)
if err := s.Absorb(ctx, target.ID, source.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(ctx, target.ID)
if got.Title == nil || *got.Title != "target title" {
t.Errorf("target title should be preserved, got %v", got.Title)
}
if got.Body != "target body\nsource body" {
t.Errorf("body: got %q", got.Body)
}
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"context"
"database/sql"
"strings"
"github.com/lerko/nib/internal/link"
)
type Backlink struct {
EntityID string
Title *string
Body string
LinkText string
}
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
lower := strings.ToLower(linkText)
var id string
err := tx.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(title) = ? AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, lower, excludeID).Scan(&id)
if err == nil {
return &id
}
err = tx.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(body) LIKE ? AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%", excludeID).Scan(&id)
if err == nil {
return &id
}
return nil
}
func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body string) error {
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_links WHERE from_id = ?", entityID); err != nil {
return err
}
linkTexts := link.ExtractLinks(body)
for _, lt := range linkTexts {
toID := s.resolveLink(ctx, tx, lt, entityID)
if _, err := tx.ExecContext(ctx,
"INSERT OR IGNORE INTO entity_links (from_id, to_id, link_text) VALUES (?, ?, ?)",
entityID, toID, lt); err != nil {
return err
}
}
return nil
}
func (s *Store) ResolveLink(ctx context.Context, linkText string) (*Entity, error) {
lower := strings.ToLower(linkText)
var id string
err := s.db.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(title) = ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, lower).Scan(&id)
if err != nil {
err = s.db.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(body) LIKE ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%").Scan(&id)
}
if err != nil {
return nil, ErrNotFound
}
return s.Get(ctx, id)
}
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.id, e.title, e.body, el.link_text
FROM entity_links el
JOIN entities e ON e.id = el.from_id
WHERE el.to_id = ? AND e.deleted_at IS NULL
ORDER BY e.created_at DESC`, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
var backlinks []Backlink
for rows.Next() {
var bl Backlink
var title sql.NullString
if err := rows.Scan(&bl.EntityID, &title, &bl.Body, &bl.LinkText); err != nil {
return nil, err
}
if title.Valid {
bl.Title = &title.String
}
backlinks = append(backlinks, bl)
}
return backlinks, rows.Err()
}
+272
View File
@@ -0,0 +1,272 @@
package db
import (
"context"
"testing"
)
func TestSyncLinks_OnCreate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "nginx proxy config", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[nginx proxy config]] for setup", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
}
if backlinks[0].EntityID != source.ID {
t.Errorf("backlink entity = %s, want %s", backlinks[0].EntityID, source.ID)
}
if backlinks[0].LinkText != "nginx proxy config" {
t.Errorf("link text = %q, want %q", backlinks[0].LinkText, "nginx proxy config")
}
}
func TestSyncLinks_TitleMatch(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "deploy checklist"
target := &Entity{Body: "steps to deploy", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "follow [[deploy checklist]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
}
}
func TestSyncLinks_TitlePriority(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "nginx config"
titled := &Entity{Body: "some body", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, titled); err != nil {
t.Fatal(err)
}
bodyMatch := &Entity{Body: "nginx config details", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, bodyMatch); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[nginx config]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, titled.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("title match should win, got %d backlinks on titled entity", len(backlinks))
}
bodyBacklinks, err := s.LoadBacklinks(ctx, bodyMatch.ID)
if err != nil {
t.Fatal(err)
}
if len(bodyBacklinks) != 0 {
t.Fatalf("body match entity should have 0 backlinks, got %d", len(bodyBacklinks))
}
}
func TestSyncLinks_Unresolved(t *testing.T) {
s := testStore(t)
ctx := context.Background()
source := &Entity{Body: "see [[nonexistent entry]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", source.ID).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 link row (unresolved), got %d", count)
}
var toID *string
err = s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", source.ID).Scan(&toID)
if err != nil {
t.Fatal(err)
}
if toID != nil {
t.Errorf("expected NULL to_id for unresolved link, got %v", *toID)
}
}
func TestSyncLinks_OnUpdate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "original target", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "no links yet", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
newBody := "now has [[original target]]"
if err := s.Update(ctx, source.ID, &EntityUpdate{Body: &newBody}); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink after update, got %d", len(backlinks))
}
}
func TestSyncLinks_SelfLinkUnresolved(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "I reference [[I reference]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
var toID *string
err := s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", e.ID).Scan(&toID)
if err != nil {
t.Fatal(err)
}
if toID != nil {
t.Fatalf("self-matching link should be unresolved (NULL to_id), got %v", *toID)
}
}
func TestSyncLinks_NoLinks(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "plain text no links", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", e.ID).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("expected 0 links, got %d", count)
}
}
func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target entry", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[target entry]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink before delete, got %d", len(backlinks))
}
if _, err := s.SoftDelete(ctx, source.ID); err != nil {
t.Fatal(err)
}
backlinks, err = s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 0 {
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
}
}
func TestResolveLink_TitleMatch(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "nginx config"
target := &Entity{Body: "proxy_pass details", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
resolved, err := s.ResolveLink(ctx, "nginx config")
if err != nil {
t.Fatal(err)
}
if resolved.ID != target.ID {
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
}
}
func TestResolveLink_BodyFallback(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "deploy staging checklist", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
resolved, err := s.ResolveLink(ctx, "deploy staging")
if err != nil {
t.Fatal(err)
}
if resolved.ID != target.ID {
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
}
}
func TestResolveLink_NotFound(t *testing.T) {
s := testStore(t)
ctx := context.Background()
_, err := s.ResolveLink(ctx, "nonexistent entry")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
+12 -3
View File
@@ -1,16 +1,22 @@
package db
import "context"
type TagCount struct {
Tag string
Count int
}
func (s *Store) ListTags() ([]TagCount, error) {
rows, err := s.db.Query(`
func (s *Store) ListTags(ctx context.Context, cardsOnly bool) ([]TagCount, error) {
where := "WHERE e.deleted_at IS NULL"
if cardsOnly {
where += " AND e.card_type IS NOT NULL"
}
rows, err := s.db.QueryContext(ctx, `
SELECT t.tag, COUNT(*) as cnt
FROM entity_tags t
JOIN entities e ON t.entity_id = e.id
WHERE e.deleted_at IS NULL
`+where+`
GROUP BY t.tag
ORDER BY t.tag`)
if err != nil {
@@ -26,6 +32,9 @@ func (s *Store) ListTags() ([]TagCount, error) {
}
tags = append(tags, tc)
}
if err := rows.Err(); err != nil {
return nil, err
}
if tags == nil {
tags = []TagCount{}
}
+50 -10
View File
@@ -1,10 +1,13 @@
package db
import "testing"
import (
"context"
"testing"
)
func TestListTags_Empty(t *testing.T) {
s := testStore(t)
tags, err := s.ListTags()
tags, err := s.ListTags(context.Background(), false)
if err != nil {
t.Fatal(err)
}
@@ -15,11 +18,12 @@ func TestListTags_Empty(t *testing.T) {
func TestListTags_Counts(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
tags, err := s.ListTags()
tags, err := s.ListTags(ctx, false)
if err != nil {
t.Fatal(err)
}
@@ -44,13 +48,14 @@ func TestListTags_Counts(t *testing.T) {
func TestListTags_ExcludesDeleted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
s.Create(e)
s.SoftDelete(e.ID)
s.Create(ctx, e)
s.SoftDelete(ctx, e.ID)
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
s.Create(ctx, &Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
tags, err := s.ListTags()
tags, err := s.ListTags(ctx, false)
if err != nil {
t.Fatal(err)
}
@@ -61,3 +66,38 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
t.Errorf("expected 'here', got %q", tags[0].Tag)
}
}
func TestListTags_CardsOnly(t *testing.T) {
s := testStore(t)
ctx := context.Background()
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}})
ct := CardSnippet
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct})
all, err := s.ListTags(ctx, false)
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("all tags: expected 3, got %d", len(all))
}
cards, err := s.ListTags(ctx, true)
if err != nil {
t.Fatal(err)
}
if len(cards) != 2 {
t.Fatalf("card tags: expected 2, got %d", len(cards))
}
counts := map[string]int{}
for _, tc := range cards {
counts[tc.Tag] = tc.Count
}
if counts["ops"] != 1 {
t.Errorf("ops count: expected 1 (card only), got %d", counts["ops"])
}
if counts["code"] != 1 {
t.Errorf("code count: expected 1, got %d", counts["code"])
}
}
+5 -3
View File
@@ -3,9 +3,10 @@ package display
import "github.com/lerko/nib/internal/db"
var glyphMap = map[db.Glyph]string{
db.GlyphNote: "",
db.GlyphTodo: "",
db.GlyphNote: "",
db.GlyphTodo: "",
db.GlyphEvent: "◇",
db.GlyphReminder: "△",
}
var cardGlyphMap = map[db.CardType]string{
@@ -14,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{
db.CardChecklist: "☐",
db.CardDecision: "⚖",
db.CardLink: "↗",
db.CardNote: "¶",
}
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
@@ -25,7 +27,7 @@ func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
if g, ok := glyphMap[glyph]; ok {
return g
}
return ""
return ""
}
func FormatID(id string) string {
+80
View File
@@ -0,0 +1,80 @@
package display
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func TestDisplayGlyph_Fluid(t *testing.T) {
tests := []struct {
glyph db.Glyph
want string
}{
{db.GlyphNote, "—"},
{db.GlyphTodo, "○"},
{db.GlyphEvent, "◇"},
}
for _, tt := range tests {
got := DisplayGlyph(tt.glyph, nil)
if got != tt.want {
t.Errorf("DisplayGlyph(%q, nil) = %q, want %q", tt.glyph, got, tt.want)
}
}
}
func TestDisplayGlyph_Card(t *testing.T) {
tests := []struct {
cardType db.CardType
want string
}{
{db.CardSnippet, "◆"},
{db.CardTemplate, "◈"},
{db.CardChecklist, "☐"},
{db.CardDecision, "⚖"},
{db.CardLink, "↗"},
}
for _, tt := range tests {
ct := tt.cardType
got := DisplayGlyph(db.GlyphNote, &ct)
if got != tt.want {
t.Errorf("DisplayGlyph(note, %q) = %q, want %q", tt.cardType, got, tt.want)
}
}
}
func TestDisplayGlyph_CardOverridesGlyph(t *testing.T) {
ct := db.CardSnippet
got := DisplayGlyph(db.GlyphTodo, &ct)
if got != "◆" {
t.Errorf("card_type should override glyph, got %q", got)
}
}
func TestDisplayGlyph_UnknownFallback(t *testing.T) {
got := DisplayGlyph(db.Glyph("unknown"), nil)
if got != "—" {
t.Errorf("unknown glyph should fall back to —, got %q", got)
}
}
func TestFormatID_Long(t *testing.T) {
got := FormatID("01HXYZ1234567890ABCDEFGH")
if got != "01HXYZ123456" {
t.Errorf("expected 12-char truncation, got %q", got)
}
}
func TestFormatID_Short(t *testing.T) {
got := FormatID("ABC")
if got != "ABC" {
t.Errorf("short ID should pass through, got %q", got)
}
}
func TestFormatID_Exact12(t *testing.T) {
got := FormatID("123456789012")
if got != "123456789012" {
t.Errorf("exact 12-char should pass through, got %q", got)
}
}
+181
View File
@@ -0,0 +1,181 @@
package export
import (
_ "embed"
"encoding/json"
"html/template"
"io"
"strings"
"time"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
//go:embed template.html
var templateHTML string
type TemplateSlot struct {
Name string `json:"name"`
Default string `json:"default"`
}
type CheckStep struct {
Text string `json:"text"`
Done bool `json:"done"`
}
type DecisionData struct {
Chose string `json:"chose"`
Why string `json:"why"`
Rejected []string `json:"rejected"`
}
type CardView struct {
ID string
Glyph string
CardType string
Title string
Body template.HTML
Description template.HTML
SearchText string
Pinned bool
Tags []string
UseCount int
LinkURL string
Slots []TemplateSlot
TemplateBody string
Steps []CheckStep
Progress int
Decision DecisionData
}
type DeckView struct {
Title string
Count int
ExportedAt string
Types []string
Cards []CardView
}
func RenderHTML(w io.Writer, entities []*db.Entity, title string) error {
tmpl, err := template.New("deck").Parse(templateHTML)
if err != nil {
return err
}
seen := map[string]bool{}
var types []string
var cards []CardView
for _, e := range entities {
if e.CardType == nil {
continue
}
ct := string(*e.CardType)
if !seen[ct] {
seen[ct] = true
types = append(types, ct)
}
cards = append(cards, buildCardView(e))
}
data := DeckView{
Title: title,
Count: len(cards),
ExportedAt: time.Now().Format("Jan 2, 2006"),
Types: types,
Cards: cards,
}
return tmpl.Execute(w, data)
}
func buildCardView(e *db.Entity) CardView {
ct := ""
if e.CardType != nil {
ct = string(*e.CardType)
}
title := ""
if e.Title != nil {
title = *e.Title
}
body := e.Body
desc := ""
if e.Description != nil {
desc = *e.Description
}
search := strings.ToLower(title + " " + body + " " + desc + " " + strings.Join(e.Tags, " "))
cv := CardView{
ID: e.ID,
Glyph: display.DisplayGlyph(e.Glyph, e.CardType),
CardType: ct,
Title: title,
Body: template.HTML(template.HTMLEscapeString(body)),
SearchText: search,
Pinned: e.Pinned,
Tags: e.Tags,
UseCount: e.UseCount,
}
if desc != "" {
cv.Description = template.HTML(template.HTMLEscapeString(desc))
}
if e.CardData != nil {
parseCardData(&cv, ct, *e.CardData, body)
}
return cv
}
func parseCardData(cv *CardView, ct string, raw string, body string) {
switch ct {
case "snippet":
// body is the snippet content, already set
case "template":
var data struct {
Slots []TemplateSlot `json:"slots"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
cv.Slots = data.Slots
}
cv.TemplateBody = body
case "checklist":
var data struct {
Steps []CheckStep `json:"steps"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
cv.Steps = data.Steps
done := 0
for _, s := range cv.Steps {
if s.Done {
done++
}
}
if len(cv.Steps) > 0 {
cv.Progress = (done * 100) / len(cv.Steps)
}
}
case "decision":
var dec DecisionData
if json.Unmarshal([]byte(raw), &dec) == nil {
cv.Decision = dec
}
case "link":
var data struct {
URL string `json:"url"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
cv.LinkURL = data.URL
}
}
}
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{.Title}}</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
--border:#2e2e2c;--border-focus:#4a4a47;
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
--accent:#c4a46c;--accent2:#a68a52;
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
--card-radius:12px;
--safe-bottom:env(safe-area-inset-bottom,0px);
}
html{font-size:16px;-webkit-text-size-adjust:100%}
body{
font-family:var(--sans);color:var(--text);background:var(--bg);
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
-webkit-font-smoothing:antialiased;
}
.deck-header{
position:sticky;top:0;z-index:10;
background:var(--bg);border-bottom:1px solid var(--border);
padding:16px 20px 12px;
}
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
.filter-bar{
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
-webkit-overflow-scrolling:touch;scrollbar-width:none;
}
.filter-bar::-webkit-scrollbar{display:none}
.filter-chip{
flex-shrink:0;padding:6px 14px;border-radius:20px;
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
background:var(--surface);color:var(--text2);cursor:pointer;
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
}
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
.search-wrap{padding:8px 20px 4px}
.search-input{
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
background:var(--surface);color:var(--text);font-size:0.9rem;
font-family:var(--sans);outline:none;transition:border-color 0.15s;
}
.search-input:focus{border-color:var(--accent)}
.search-input::placeholder{color:var(--text3)}
.card-list{padding:8px 16px}
.card{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
transition:border-color 0.15s;position:relative;
}
.card:active{border-color:var(--border-focus)}
.card.pinned{border-left:3px solid var(--accent)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.card-glyph{
font-size:1.1rem;width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
background:var(--surface2);border-radius:6px;flex-shrink:0;
}
.card-type{
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
color:var(--text3);
}
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
.card-title{
font-size:0.95rem;font-weight:600;color:var(--text);
line-height:1.4;margin-bottom:6px;
}
.card-body{
font-size:0.85rem;color:var(--text2);line-height:1.55;
white-space:pre-wrap;word-break:break-word;
}
.card-body code{
font-family:var(--mono);font-size:0.8rem;
background:var(--surface2);padding:2px 5px;border-radius:4px;
}
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.tag{
font-size:0.7rem;color:var(--accent);background:var(--surface2);
padding:3px 8px;border-radius:6px;
}
/* snippet */
.snippet-block{
background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
}
.snippet-block pre{
font-family:var(--mono);font-size:0.8rem;color:var(--text);
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
}
.copy-btn{
position:absolute;top:8px;right:8px;
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
background:var(--surface);color:var(--text2);font-size:0.7rem;
cursor:pointer;transition:all 0.15s;
}
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
/* checklist */
.checklist{margin-top:8px;list-style:none}
.checklist li{
display:flex;align-items:flex-start;gap:10px;
padding:8px 0;border-bottom:1px solid var(--border);
font-size:0.85rem;color:var(--text2);
}
.checklist li:last-child{border-bottom:none}
.check-box{
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:all 0.15s;margin-top:1px;
}
.check-box.checked{background:var(--green);border-color:var(--green)}
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
.check-text.done{text-decoration:line-through;color:var(--text3)}
.progress-bar{
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
}
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
/* template */
.template-field{margin-top:8px}
.template-field label{
display:block;font-size:0.7rem;font-weight:600;
text-transform:uppercase;letter-spacing:0.05em;
color:var(--text3);margin-bottom:4px;
}
.template-field input{
width:100%;padding:8px 12px;border-radius:8px;
border:1px solid var(--border);background:var(--surface2);
color:var(--text);font-size:0.85rem;font-family:var(--sans);
outline:none;transition:border-color 0.15s;
}
.template-field input:focus{border-color:var(--accent)}
.template-output{margin-top:10px}
.template-copy-btn{
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
}
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
/* decision */
.decision-grid{margin-top:8px}
.decision-row{
padding:8px 0;border-bottom:1px solid var(--border);
}
.decision-row:last-child{border-bottom:none}
.decision-label{
font-size:0.7rem;font-weight:600;text-transform:uppercase;
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
}
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
.decision-rejected{color:var(--red);font-style:italic}
/* link */
.link-target{
display:flex;align-items:center;gap:10px;margin-top:8px;
padding:12px;background:var(--bg);border:1px solid var(--border);
border-radius:8px;text-decoration:none;color:var(--text);
transition:border-color 0.15s;
}
.link-target:active{border-color:var(--accent)}
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
.empty-state{
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
}
@media(min-width:640px){
.card-list{
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
gap:10px;padding:8px 20px;
}
.card{margin-bottom:0}
}
</style>
</head>
<body>
<div class="deck-header">
<div class="deck-title">{{.Title}}</div>
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
</div>
<div class="search-wrap">
<input type="text" class="search-input" placeholder="search cards…" id="search">
</div>
<div class="filter-bar" id="filters">
<button class="filter-chip active" data-type="all">All</button>
{{range .Types}}
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
{{end}}
</div>
<div class="card-list" id="cards">
{{range .Cards}}
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
<div class="card-top">
<span class="card-glyph">{{.Glyph}}</span>
<span class="card-type">{{.CardType}}</span>
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
</div>
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
{{if eq .CardType "snippet"}}
<div class="snippet-block">
<button class="copy-btn" onclick="copyText(this)">copy</button>
<pre>{{.Body}}</pre>
</div>
{{else if eq .CardType "checklist"}}
<div class="card-body">{{.Description}}</div>
<ul class="checklist" data-card-id="{{.ID}}">
{{range $i, $step := .Steps}}
<li>
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
</li>
{{end}}
</ul>
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
{{else if eq .CardType "template"}}
<div class="card-body">{{.Description}}</div>
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
{{range .Slots}}
<div class="template-field">
<label>{{.Name}}</label>
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
</div>
{{end}}
<div class="template-output">
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
</div>
</form>
{{else if eq .CardType "decision"}}
<div class="decision-grid">
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
</div>
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
{{else if eq .CardType "link"}}
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
<span class="link-url">{{.LinkURL}}</span>
<span class="link-arrow"></span>
</a>
{{else}}
<div class="card-body">{{.Body}}</div>
{{end}}
{{if .Tags}}
<div class="card-tags">
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
<script>
(function(){
const cards=document.querySelectorAll('.card');
const chips=document.querySelectorAll('.filter-chip');
const search=document.getElementById('search');
const empty=document.getElementById('empty');
let activeType='all';
function applyFilters(){
const q=search.value.toLowerCase().trim();
let visible=0;
cards.forEach(c=>{
const typeMatch=activeType==='all'||c.dataset.type===activeType;
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
c.style.display=(typeMatch&&searchMatch)?'':'none';
if(typeMatch&&searchMatch)visible++;
});
empty.style.display=visible?'none':'block';
}
chips.forEach(chip=>{
chip.addEventListener('click',()=>{
chips.forEach(c=>c.classList.remove('active'));
chip.classList.add('active');
activeType=chip.dataset.type;
applyFilters();
});
});
search.addEventListener('input',applyFilters);
})();
function copyText(btn){
const pre=btn.parentElement.querySelector('pre');
navigator.clipboard.writeText(pre.textContent).then(()=>{
btn.textContent='copied!';
setTimeout(()=>btn.textContent='copy',1500);
});
}
function toggleCheck(box){
box.classList.toggle('checked');
const span=box.nextElementSibling;
span.classList.toggle('done');
const list=box.closest('.checklist');
const boxes=list.querySelectorAll('.check-box');
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
const bar=list.nextElementSibling.querySelector('.progress-fill');
bar.style.width=Math.round((done/boxes.length)*100)+'%';
}
function updateTemplate(input){
const form=input.closest('.template-form');
const tmpl=form.dataset.template;
let filled=tmpl;
form.querySelectorAll('input[data-slot]').forEach(inp=>{
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
});
form._filled=filled;
}
function copyTemplate(btn){
const form=btn.closest('.template-form');
const text=form._filled||form.dataset.template;
navigator.clipboard.writeText(text).then(()=>{
btn.textContent='copied!';
setTimeout(()=>btn.textContent='copy filled template',1500);
});
}
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
package link
import (
"regexp"
"strings"
)
var linkRe = regexp.MustCompile(`\[\[(.+?)\]\]`)
func ExtractLinks(body string) []string {
matches := linkRe.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
return nil
}
seen := map[string]bool{}
var result []string
for _, m := range matches {
text := strings.TrimSpace(m[1])
if text == "" || seen[text] {
continue
}
seen[text] = true
result = append(result, text)
}
return result
}
+38
View File
@@ -0,0 +1,38 @@
package link
import "testing"
func TestExtractLinks(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{"no links", "plain text with no links", nil},
{"single link", "see [[nginx config]] for details", []string{"nginx config"}},
{"multiple links", "see [[nginx config]] and [[deploy steps]]", []string{"nginx config", "deploy steps"}},
{"duplicate deduped", "[[foo]] then [[foo]] again", []string{"foo"}},
{"empty brackets", "empty [[ ]] ignored", nil},
{"just brackets no content", "[[]] empty", nil},
{"link with special chars", "see [[deploy: staging (v2)]]", []string{"deploy: staging (v2)"}},
{"link in markdown", "# heading\n\nsee [[my note]] for info", []string{"my note"}},
{"adjacent links", "[[one]][[two]]", []string{"one", "two"}},
{"partial brackets ignored", "not a [link] or [[incomplete", nil},
{"link with hash", "see [[#ops channel]]", []string{"#ops channel"}},
{"multiline body", "line one [[link one]]\nline two [[link two]]", []string{"link one", "link two"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractLinks(tt.body)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
+244 -31
View File
@@ -4,14 +4,23 @@ import (
"fmt"
"strconv"
"strings"
"time"
)
type Result struct {
Body string
Glyph string
Title *string
Description *string
TimeAnchor *string
Tags []string
FilterTags []string
CardSuffix *string
Pin bool
Query bool
QueryDateFrom *string
QueryDateTo *string
QueryCardType *string
}
var validCardTypes = map[string]string{
@@ -22,6 +31,8 @@ var validCardTypes = map[string]string{
"checklist": "checklist",
"decision": "decision",
"link": "link",
"note": "note",
"n": "note",
}
func Parse(input string) (*Result, error) {
@@ -35,38 +46,244 @@ func Parse(input string) (*Result, error) {
Tags: []string{},
}
tokens := strings.Fields(input)
if len(tokens) == 0 {
return nil, fmt.Errorf("empty input")
}
first := tokens[0]
switch first {
case "-", "▸":
r.Glyph = "todo"
tokens = tokens[1:]
case "*", "◇":
r.Glyph = "event"
tokens = tokens[1:]
remaining := input
// 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
}
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
seen := map[string]bool{}
now := time.Now()
for _, tok := range tokens {
switch {
case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:]
if err := validateTime(timeStr); err != nil {
return nil, fmt.Errorf("invalid time %q: %w", timeStr, err)
case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"):
tag := strings.ToLower(tok[1:])
r.FilterTags = append(r.FilterTags, tag)
case tok == "@today":
d := now.Format("2006-01-02")
r.QueryDateFrom = &d
r.QueryDateTo = &d
case tok == "@yesterday":
d := now.AddDate(0, 0, -1).Format("2006-01-02")
r.QueryDateFrom = &d
r.QueryDateTo = &d
case tok == "@week":
d := now.AddDate(0, 0, -7).Format("2006-01-02")
r.QueryDateFrom = &d
case tok == "@month":
d := now.AddDate(0, -1, 0).Format("2006-01-02")
r.QueryDateFrom = &d
case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"):
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
d := now.AddDate(0, 0, -n).Format("2006-01-02")
r.QueryDateTo = &d
} else {
bodyParts = append(bodyParts, tok)
}
if r.TimeAnchor != nil {
return nil, fmt.Errorf("multiple time anchors")
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
d := now.AddDate(0, 0, -n).Format("2006-01-02")
r.QueryDateFrom = &d
} else {
bodyParts = append(bodyParts, tok)
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
if ct, ok := validCardTypes[suffix]; ok {
r.QueryCardType = &ct
} else {
bodyParts = append(bodyParts, tok)
}
default:
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 = 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
lines := strings.SplitN(remaining, "\n", 2)
firstLine := strings.TrimSpace(lines[0])
if strings.HasPrefix(firstLine, "|") {
hasTitle = true
titleContent := firstLine[1:]
if idx := strings.Index(titleContent, " // "); idx >= 0 {
titleRaw = strings.TrimSpace(titleContent[:idx])
descRaw = strings.TrimSpace(titleContent[idx+4:])
} else {
titleRaw = strings.TrimSpace(titleContent)
}
if len(lines) > 1 {
remaining = lines[1]
} else {
remaining = ""
}
} else {
allLines := strings.Split(remaining, "\n")
var descParts []string
startBody := 0
for i, line := range allLines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "// ") || trimmed == "//" {
descParts = append(descParts, strings.TrimSpace(trimmed[2:]))
startBody = i + 1
} else {
break
}
}
if len(descParts) > 0 {
descRaw = strings.Join(descParts, " ")
remaining = strings.Join(allLines[startBody:], "\n")
} else if !strings.Contains(firstLine, "://") {
if idx := strings.Index(firstLine, " // "); idx >= 0 {
descRaw = strings.TrimSpace(firstLine[idx+4:])
remaining = strings.TrimSpace(firstLine[:idx])
if len(lines) > 1 {
remaining += "\n" + lines[1]
}
}
}
}
// Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body
if hasTitle {
clean, err := extractModifiers(r, titleRaw, false)
if err != nil {
return nil, err
}
if clean != "" {
r.Title = &clean
}
}
if descRaw != "" {
clean, err := extractModifiers(r, descRaw, false)
if err != nil {
return nil, err
}
if clean != "" {
r.Description = &clean
}
}
clean, err := extractModifiers(r, remaining, true)
if err != nil {
return nil, err
}
r.Body = clean
if r.Body == "" && r.Title == nil {
return nil, fmt.Errorf("empty body after extracting modifiers")
}
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) {
seen := map[string]bool{}
for _, t := range r.Tags {
seen[strings.ToLower(t)] = 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
case strings.HasPrefix(tok, "##") && len(tok) > 2:
lineParts = append(lineParts, "#"+tok[2:])
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 := tok[1:]
tag := strings.ToLower(tok[1:])
if !seen[tag] {
r.Tags = append(r.Tags, tag)
seen[tag] = true
@@ -76,24 +293,20 @@ func Parse(input string) (*Result, error) {
suffix := tok[1:]
cardType, ok := validCardTypes[suffix]
if !ok {
return nil, fmt.Errorf("invalid card type %q", suffix)
return "", fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return nil, fmt.Errorf("multiple card suffixes")
return "", fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
bodyParts = append(bodyParts, tok)
lineParts = append(lineParts, tok)
}
}
r.Body = strings.Join(bodyParts, " ")
if r.Body == "" {
return nil, fmt.Errorf("empty body after extracting modifiers")
outLines = append(outLines, strings.Join(lineParts, " "))
}
return r, nil
return strings.Join(outLines, "\n"), nil
}
func validateTime(s string) error {
+155 -28
View File
@@ -13,46 +13,97 @@ func TestParse(t *testing.T) {
input string
wantBody string
wantGlyph string
wantTitle *string
wantDesc *string
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, ""},
{"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""},
{"unicode todo", "deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", 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", sp("14:00"), nil, nil, ""},
{"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""},
{"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", 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, []string{"ops"}, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", 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, []string{"nginx"}, sp("snippet"), ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", 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", 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, []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, 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, 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, ""},
// 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, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"},
{"whitespace only", " ", "", "", 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 {
@@ -79,6 +130,12 @@ func TestParse(t *testing.T) {
if got.Glyph != tt.wantGlyph {
t.Errorf("glyph: got %q, want %q", got.Glyph, tt.wantGlyph)
}
if !ptrEq(got.Title, tt.wantTitle) {
t.Errorf("title: got %v, want %v", strPtr(got.Title), strPtr(tt.wantTitle))
}
if !ptrEq(got.Description, tt.wantDesc) {
t.Errorf("description: got %v, want %v", strPtr(got.Description), strPtr(tt.wantDesc))
}
if !ptrEq(got.TimeAnchor, tt.wantTime) {
t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime))
}
@@ -88,6 +145,76 @@ 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)
}
})
}
}
func TestParseQueryComposition(t *testing.T) {
tests := []struct {
name string
input string
wantBody string
wantTags []string
wantDateFrom bool
wantDateTo bool
wantCardType *string
}{
{"today", "?@today", "", nil, true, true, nil},
{"yesterday", "?@yesterday", "", nil, true, true, nil},
{"week", "?@week", "", nil, true, false, nil},
{"month", "?@month", "", nil, true, false, nil},
{"newer than", "?<7d", "", nil, true, false, nil},
{"older than", "?>30d", "", nil, false, true, nil},
{"card type snippet", "?^snippet", "", nil, false, false, sp("snippet")},
{"card type shorthand", "?^c", "", nil, false, false, sp("snippet")},
{"card type checklist", "?^checklist", "", nil, false, false, sp("checklist")},
{"invalid card type stays as body", "?^bogus", "^bogus", nil, false, false, nil},
{"combined text and date", "?deploy @today", "deploy", nil, true, true, nil},
{"combined tags and date", "?#ops @week", "", []string{"ops"}, true, false, nil},
{"combined all", "?deploy #ops @week ^snippet", "deploy", []string{"ops"}, true, false, sp("snippet")},
{"invalid age stays as body", "?>abcd", ">abcd", nil, false, false, nil},
{"zero days stays as body", "?>0d", ">0d", nil, false, false, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got.Query {
t.Fatal("expected Query=true")
}
if got.Body != tt.wantBody {
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
}
if !tagsEq(got.FilterTags, tt.wantTags) {
t.Errorf("tags: got %v, want %v", got.FilterTags, tt.wantTags)
}
if tt.wantDateFrom && got.QueryDateFrom == nil {
t.Error("expected QueryDateFrom to be set")
}
if !tt.wantDateFrom && got.QueryDateFrom != nil {
t.Errorf("expected QueryDateFrom nil, got %v", *got.QueryDateFrom)
}
if tt.wantDateTo && got.QueryDateTo == nil {
t.Error("expected QueryDateTo to be set")
}
if !tt.wantDateTo && got.QueryDateTo != nil {
t.Errorf("expected QueryDateTo nil, got %v", *got.QueryDateTo)
}
if !ptrEq(got.QueryCardType, tt.wantCardType) {
t.Errorf("card type: got %v, want %v", strPtr(got.QueryCardType), strPtr(tt.wantCardType))
}
})
}
}
+139
View File
@@ -0,0 +1,139 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type absorbModel struct {
targetID string
sources []*db.Entity
cursor int
offset int
height int
}
func newAbsorbModel(targetID string) absorbModel {
return absorbModel{targetID: targetID}
}
func (a *absorbModel) setSources(entities []*db.Entity) {
a.sources = nil
for _, e := range entities {
if e.ID != a.targetID {
a.sources = append(a.sources, e)
}
}
a.cursor = 0
a.offset = 0
}
func (a *absorbModel) setHeight(h int) {
a.height = h
}
func (a absorbModel) selectedSource() *db.Entity {
if len(a.sources) == 0 || a.cursor >= len(a.sources) {
return nil
}
return a.sources[a.cursor]
}
func (a absorbModel) update(msg tea.KeyMsg) absorbModel {
switch msg.String() {
case "up", "k":
if a.cursor > 0 {
a.cursor--
if a.cursor < a.offset {
a.offset = a.cursor
}
}
case "down", "j":
if a.cursor < len(a.sources)-1 {
a.cursor++
visible := a.visibleCount()
if a.cursor >= a.offset+visible {
a.offset = a.cursor - visible + 1
}
}
}
return a
}
func (a absorbModel) view(width int) string {
if len(a.sources) == 0 {
return statusStyle.Render("no other entities")
}
var b strings.Builder
b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID)))
b.WriteString("\n")
b.WriteString(helpStyle.Render("select source to merge"))
b.WriteString("\n\n")
visible := a.visibleCount() - 4
if visible <= 0 {
visible = 10
}
end := min(a.offset+visible, len(a.sources))
for i := a.offset; i < end; i++ {
e := a.sources[i]
line := renderAbsorbSource(e, width-4)
if i == a.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (a absorbModel) visibleCount() int {
if a.height <= 0 {
return 20
}
return a.height
}
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
var tags string
if len(e.Tags) > 0 {
limit := min(2, len(e.Tags))
tagParts := make([]string, limit)
for i := 0; i < limit; i++ {
tagParts[i] = tagStyle.Render("#" + e.Tags[i])
}
tags = " " + strings.Join(tagParts, " ")
}
line := fmt.Sprintf("%s %s%s", glyph, body, tags)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
}
return line
}
+112
View File
@@ -0,0 +1,112 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db"
)
const maxSuggestions = 5
type autocompleteModel struct {
suggestions []string
cursor int
active bool
prefix string
tokenStart int
tokenEnd int
}
func (a *autocompleteModel) moveUp() {
if a.cursor > 0 {
a.cursor--
}
}
func (a *autocompleteModel) moveDown() {
if a.cursor < len(a.suggestions)-1 {
a.cursor++
}
}
func (a autocompleteModel) selected() string {
if len(a.suggestions) == 0 || a.cursor >= len(a.suggestions) {
return ""
}
return a.suggestions[a.cursor]
}
func (a autocompleteModel) visibleCount() int {
if len(a.suggestions) > maxSuggestions {
return maxSuggestions
}
return len(a.suggestions)
}
func (a autocompleteModel) view(width int) string {
if !a.active || len(a.suggestions) == 0 {
return ""
}
var b strings.Builder
n := a.visibleCount()
for i := 0; i < n; i++ {
tag := "#" + a.suggestions[i]
if i == a.cursor {
b.WriteString(acSelectedStyle.Render(" " + tag))
} else {
b.WriteString(acItemStyle.Render(" " + tag))
}
if i < n-1 {
b.WriteString("\n")
}
}
if len(a.suggestions) > maxSuggestions {
b.WriteString("\n")
b.WriteString(acItemStyle.Render(" …"))
}
box := lipgloss.NewStyle().
Width(min(30, width)).
Render(b.String())
return box
}
func tagTokenAtCursor(val string, cursorPos int) (tokenStart, tokenEnd int, prefix string, ok bool) {
if cursorPos > len(val) {
cursorPos = len(val)
}
start := cursorPos
for start > 0 && val[start-1] != ' ' {
start--
}
if start >= len(val) || val[start] != '#' {
return 0, 0, "", false
}
end := cursorPos
for end < len(val) && val[end] != ' ' {
end++
}
prefix = strings.ToLower(val[start+1 : cursorPos])
return start, end, prefix, true
}
func filterTagSuggestions(tags []db.TagCount, prefix string) []string {
if prefix == "" {
return nil
}
prefix = strings.ToLower(prefix)
var result []string
for _, tc := range tags {
lower := strings.ToLower(tc.Tag)
if strings.HasPrefix(lower, prefix) && lower != prefix {
result = append(result, tc.Tag)
}
}
return result
}
+85
View File
@@ -0,0 +1,85 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func TestTagTokenAtCursor(t *testing.T) {
tests := []struct {
name string
val string
cursor int
wantStart int
wantEnd int
wantPfx string
wantOk bool
}{
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
{"not in tag", "hello world", 5, 0, 0, "", false},
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
{"cursor at hash", "#ops", 1, 0, 4, "", true},
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
{"empty string", "", 0, 0, 0, "", false},
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
if ok != tt.wantOk {
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
}
if !ok {
return
}
if start != tt.wantStart || end != tt.wantEnd {
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
}
if pfx != tt.wantPfx {
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
}
})
}
}
func TestFilterTagSuggestions(t *testing.T) {
tags := []db.TagCount{
{Tag: "ops", Count: 5},
{Tag: "ops-deploy", Count: 3},
{Tag: "infra", Count: 2},
{Tag: "ops-team", Count: 1},
}
tests := []struct {
name string
prefix string
want []string
}{
{"empty prefix", "", nil},
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
{"no match", "zzz", nil},
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
{"single match", "inf", []string{"infra"}},
{"full match excluded", "infra", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterTagSuggestions(tags, tt.prefix)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
+369
View File
@@ -0,0 +1,369 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type intent int
const (
intentAll intent = iota
intentGrab
intentRead
intentFill
)
func (i intent) String() string {
switch i {
case intentGrab:
return "grab"
case intentRead:
return "read"
case intentFill:
return "fill"
default:
return "all"
}
}
func (i intent) next() intent {
switch i {
case intentAll:
return intentGrab
case intentGrab:
return intentRead
case intentRead:
return intentFill
default:
return intentAll
}
}
func matchesIntent(e *db.Entity, i intent) bool {
if i == intentAll {
return true
}
ct := e.CardType
if ct == nil {
return i == intentGrab
}
switch i {
case intentGrab:
return *ct == db.CardSnippet
case intentRead:
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
case intentFill:
return *ct == db.CardTemplate || *ct == db.CardChecklist
}
return false
}
type cardGroup struct {
label string
start int
count int
}
type cardsModel struct {
entities []*db.Entity
filtered []*db.Entity
groups []cardGroup
cursor int
offset int
height int
width int
intent intent
}
func newCardsModel() cardsModel {
return cardsModel{}
}
func (c *cardsModel) setEntities(entities []*db.Entity) {
c.entities = entities
c.applyFilter()
}
func (c *cardsModel) setIntent(i intent) {
c.intent = i
c.cursor = 0
c.offset = 0
c.applyFilter()
}
func (c *cardsModel) applyFilter() {
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1)
}
}
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
if intentFilter != intentAll {
var pinned, rest []*db.Entity
for _, e := range entities {
if !matchesIntent(e, intentFilter) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
return append(pinned, rest...), nil
}
var pinned, grab, read, fill []*db.Entity
for _, e := range entities {
if e.Pinned {
pinned = append(pinned, e)
} else {
switch {
case matchesIntent(e, intentGrab):
grab = append(grab, e)
case matchesIntent(e, intentRead):
read = append(read, e)
case matchesIntent(e, intentFill):
fill = append(fill, e)
}
}
}
var filtered []*db.Entity
var groups []cardGroup
for _, bucket := range []struct {
label string
entities []*db.Entity
}{
{"pinned", pinned},
{"grab", grab},
{"read", read},
{"fill", fill},
} {
if len(bucket.entities) == 0 {
continue
}
groups = append(groups, cardGroup{
label: bucket.label,
start: len(filtered),
count: len(bucket.entities),
})
filtered = append(filtered, bucket.entities...)
}
return filtered, groups
}
func (c *cardsModel) setSize(width, height int) {
c.width = width
c.height = height
}
func (c cardsModel) selected() *db.Entity {
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
return nil
}
return c.filtered[c.cursor]
}
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
switch msg.String() {
case "up", "k":
if c.cursor > 0 {
c.cursor--
if c.cursor < c.offset {
c.offset = c.cursor
}
}
case "down", "j":
if c.cursor < len(c.filtered)-1 {
c.cursor++
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
case "home", "g":
c.cursor = 0
c.offset = 0
case "end", "G":
c.cursor = max(0, len(c.filtered)-1)
visible := c.visibleCount()
if c.cursor >= visible {
c.offset = c.cursor - visible + 1
}
case "pgup", "ctrl+u":
c.cursor = max(0, c.cursor-c.visibleCount())
if c.cursor < c.offset {
c.offset = c.cursor
}
case "pgdown", "ctrl+d":
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
return c
}
func (c cardsModel) view(width int) string {
if len(c.filtered) == 0 {
return statusStyle.Render("no cards")
}
if len(c.groups) > 0 {
return c.groupedView(width)
}
var b strings.Builder
visible := c.visibleCount()
end := min(c.offset+visible, len(c.filtered))
for i := c.offset; i < end; i++ {
e := c.filtered[i]
line := renderCard(e, width-4)
if i == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) groupedView(width int) string {
entityWidth := width - 4 - dateGutterWidth
type displayLine struct {
text string
entityIdx int
}
var lines []displayLine
for _, g := range c.groups {
for i := 0; i < g.count; i++ {
eIdx := g.start + i
var gutter string
if i == 0 {
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
} else {
gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
}
}
visible := c.visibleCount()
offset := c.offset
if c.cursor < offset {
offset = c.cursor
}
if c.cursor >= offset+visible {
offset = c.cursor - visible + 1
}
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.entityIdx == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) visibleCount() int {
if c.height <= 0 {
return 20
}
return c.height
}
func renderCard(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
affordance := detectAffordance(e)
affordStr := ""
if affordance != "" {
affordStr = " " + affordanceStyle.Render(affordance)
}
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
limit := min(2, len(e.Tags))
for _, t := range e.Tags[:limit] {
extras = append(extras, tagStyle.Render("#"+t))
}
}
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
useStr := ""
if e.UseCount > 0 {
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
}
line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
}
return line
}
func detectAffordance(e *db.Entity) string {
if e.CardType == nil {
return ""
}
switch *e.CardType {
case db.CardSnippet:
return "code"
case db.CardTemplate:
return "fill"
case db.CardChecklist:
return "steps"
case db.CardDecision:
return "decide"
case db.CardLink:
return "link"
default:
return ""
}
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func TestMatchesIntent(t *testing.T) {
snippet := db.CardSnippet
template := db.CardTemplate
checklist := db.CardChecklist
note := db.CardNote
link := db.CardLink
decision := db.CardDecision
tests := []struct {
name string
cardType *db.CardType
intent intent
want bool
}{
{"all matches nil", nil, intentAll, true},
{"all matches snippet", &snippet, intentAll, true},
{"all matches template", &template, intentAll, true},
{"grab matches nil", nil, intentGrab, true},
{"grab matches snippet", &snippet, intentGrab, true},
{"grab rejects template", &template, intentGrab, false},
{"grab rejects note", &note, intentGrab, false},
{"read matches note", &note, intentRead, true},
{"read matches link", &link, intentRead, true},
{"read matches decision", &decision, intentRead, true},
{"read rejects snippet", &snippet, intentRead, false},
{"read rejects nil", nil, intentRead, false},
{"fill matches template", &template, intentFill, true},
{"fill matches checklist", &checklist, intentFill, true},
{"fill rejects snippet", &snippet, intentFill, false},
{"fill rejects nil", nil, intentFill, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &db.Entity{CardType: tt.cardType}
if got := matchesIntent(e, tt.intent); got != tt.want {
t.Fatalf("matchesIntent(%v, %v) = %v, want %v", tt.cardType, tt.intent, got, tt.want)
}
})
}
}
func TestDetectAffordance(t *testing.T) {
snippet := db.CardSnippet
template := db.CardTemplate
checklist := db.CardChecklist
decision := db.CardDecision
link := db.CardLink
note := db.CardNote
tests := []struct {
name string
cardType *db.CardType
want string
}{
{"nil", nil, ""},
{"snippet", &snippet, "code"},
{"template", &template, "fill"},
{"checklist", &checklist, "steps"},
{"decision", &decision, "decide"},
{"link", &link, "link"},
{"note", &note, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &db.Entity{CardType: tt.cardType}
if got := detectAffordance(e); got != tt.want {
t.Fatalf("detectAffordance(%v) = %q, want %q", tt.cardType, got, tt.want)
}
})
}
}
func TestIntentCycle(t *testing.T) {
order := []intent{intentAll, intentGrab, intentRead, intentFill, intentAll}
for i := 0; i < len(order)-1; i++ {
got := order[i].next()
if got != order[i+1] {
t.Fatalf("%v.next() = %v, want %v", order[i], got, order[i+1])
}
}
}
func TestApplyFilter_PinnedFirst(t *testing.T) {
snippet := db.CardSnippet
c := newCardsModel()
c.setEntities([]*db.Entity{
{ID: "1", Body: "a", CardType: &snippet},
{ID: "2", Body: "b", Pinned: true, CardType: &snippet},
{ID: "3", Body: "c", CardType: &snippet},
})
if len(c.filtered) != 3 {
t.Fatalf("expected 3 filtered, got %d", len(c.filtered))
}
if c.filtered[0].ID != "2" {
t.Fatalf("pinned entity should be first, got %s", c.filtered[0].ID)
}
}
func TestApplyFilter_CursorClamps(t *testing.T) {
snippet := db.CardSnippet
template := db.CardTemplate
c := newCardsModel()
c.setEntities([]*db.Entity{
{ID: "1", Body: "a", CardType: &snippet},
{ID: "2", Body: "b", CardType: &snippet},
{ID: "3", Body: "c", CardType: &template},
})
c.cursor = 2
c.setIntent(intentFill)
if len(c.filtered) != 1 {
t.Fatalf("expected 1 fill entity, got %d", len(c.filtered))
}
if c.cursor != 0 {
t.Fatalf("cursor should clamp to 0, got %d", c.cursor)
}
}
+368
View File
@@ -0,0 +1,368 @@
package tui
import (
"context"
"os"
"os/exec"
"strings"
"time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
)
type entitiesLoadedMsg struct {
entities []*db.Entity
}
type entityCreatedMsg struct {
entity *db.Entity
}
type entityDeletedMsg struct {
id string
}
type entityUpdatedMsg struct {
entity *db.Entity
action string
}
type entityPromotedMsg struct {
id string
cardType db.CardType
}
type entityDemotedMsg struct {
id string
}
type entityCopiedMsg struct{}
type entityAbsorbedMsg struct {
targetID string
}
type absorbSourcesLoadedMsg struct {
targetID string
entities []*db.Entity
}
type stepsPersistedMsg struct{}
type templateCopiedMsg struct{}
type backlinksLoadedMsg struct {
backlinks []db.Backlink
}
type linkFollowedMsg struct {
entity *db.Entity
}
type tagsLoadedMsg struct {
tags []db.TagCount
}
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type staleEntitiesLoadedMsg struct {
entities []*db.Entity
}
type stumbleActionMsg struct {
action string
}
type statusClearMsg struct{ seq int }
type editorFinishedMsg struct {
err error
}
type errMsg struct {
err error
}
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), params)
if err != nil {
return errMsg{err}
}
return entitiesLoadedMsg{entities}
}
}
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := store.Create(context.Background(), e); err != nil {
return errMsg{err}
}
return entityCreatedMsg{e}
}
}
func deleteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return entityDeletedMsg{id}
}
}
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
var update db.EntityUpdate
if e.CompletedAt == nil {
now := time.Now().UTC()
update = db.EntityUpdate{CompletedAt: &now}
} else {
update = db.EntityUpdate{ClearCompleted: true}
}
if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(context.Background(), e.ID)
if err != nil {
return errMsg{err}
}
action := "completed"
if e.CompletedAt != nil {
action = "reopened"
}
return entityUpdatedMsg{updated, action}
}
}
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
newPinned := !e.Pinned
update := db.EntityUpdate{Pinned: &newPinned}
if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(context.Background(), e.ID)
if err != nil {
return errMsg{err}
}
action := "pinned"
if !newPinned {
action = "unpinned"
}
return entityUpdatedMsg{updated, action}
}
}
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
return func() tea.Msg {
cd := carddata.GenerateCardData(ct, body)
if err := store.Promote(context.Background(), id, ct, cd); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id, ct}
}
}
func demoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Demote(context.Background(), id); err != nil {
return errMsg{err}
}
return entityDemotedMsg{id}
}
}
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(e.Body); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(context.Background(), e.ID); err != nil {
return errMsg{err}
}
return entityCopiedMsg{}
}
}
func loadTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false)
if err != nil {
return errMsg{err}
}
return tagsLoadedMsg{tags}
}
}
func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
return func() tea.Msg {
backlinks, err := store.LoadBacklinks(context.Background(), entityID)
if err != nil {
return errMsg{err}
}
return backlinksLoadedMsg{backlinks}
}
}
func followLink(store *db.Store, linkText string) tea.Cmd {
return func() tea.Msg {
entity, err := store.ResolveLink(context.Background(), linkText)
if err != nil {
return errMsg{err}
}
return linkFollowedMsg{entity}
}
}
func followLinkByID(store *db.Store, entityID string) tea.Cmd {
return func() tea.Msg {
entity, err := store.Get(context.Background(), entityID)
if err != nil {
return errMsg{err}
}
return linkFollowedMsg{entity}
}
}
func loadRailTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false)
if err != nil {
return errMsg{err}
}
return railTagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editorEnv := os.Getenv("EDITOR")
if editorEnv == "" {
editorEnv = os.Getenv("VISUAL")
}
if editorEnv == "" {
editorEnv = "vi"
}
parts := strings.Fields(editorEnv)
editor, editorArgs := parts[0], parts[1:]
f, err := os.CreateTemp("", "nib-edit-*.md")
if err != nil {
return func() tea.Msg { return errMsg{err} }
}
if _, err := f.WriteString(e.Body); err != nil {
f.Close()
os.Remove(f.Name())
return func() tea.Msg { return errMsg{err} }
}
f.Close()
c := exec.Command(editor, append(editorArgs, f.Name())...)
return tea.ExecProcess(c, func(err error) tea.Msg {
defer os.Remove(f.Name())
if err != nil {
return editorFinishedMsg{err}
}
content, readErr := os.ReadFile(f.Name())
if readErr != nil {
return editorFinishedMsg{readErr}
}
newBody := string(content)
if newBody == e.Body {
return editorFinishedMsg{nil}
}
update := db.EntityUpdate{Body: &newBody}
if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
return editorFinishedMsg{updateErr}
}
return editorFinishedMsg{nil}
})
}
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), db.DefaultListParams())
if err != nil {
return errMsg{err}
}
return absorbSourcesLoadedMsg{targetID, entities}
}
}
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
return func() tea.Msg {
if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
return errMsg{err}
}
return entityAbsorbedMsg{targetID}
}
}
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
return func() tea.Msg {
update := db.EntityUpdate{CardData: &stepsJSON}
if err := store.Update(context.Background(), entityID, &update); err != nil {
return errMsg{err}
}
return stepsPersistedMsg{}
}
}
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(resolved); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(context.Background(), entityID); err != nil {
return errMsg{err}
}
return templateCopiedMsg{}
}
}
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg {
return statusClearMsg{seq: seq}
})
}
func loadStaleEntities(store *db.Store) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), staleParams())
if err != nil {
return errMsg{err}
}
return staleEntitiesLoadedMsg{entities}
}
}
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"dismissed"}
}
}
func stumblePin(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
pinned := true
update := db.EntityUpdate{Pinned: &pinned}
if err := store.Update(context.Background(), id, &update); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"pinned"}
}
}
+23
View File
@@ -0,0 +1,23 @@
package tui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/display"
)
type confirmTimeoutMsg struct{}
func confirmTimeout() tea.Cmd {
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return confirmTimeoutMsg{}
})
}
func renderConfirm(entityID string) string {
short := display.FormatID(entityID)
return errorStyle.Render(fmt.Sprintf("delete %s? y to confirm, any key to cancel", short))
}
+301
View File
@@ -0,0 +1,301 @@
package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type detailMode int
const (
detailPreview detailMode = iota
detailRun
detailFill
)
type detailModel struct {
entity *db.Entity
backlinks []db.Backlink
scroll int
height int
width int
mode detailMode
run runModel
fill fillModel
}
func newDetailModel() detailModel {
return detailModel{}
}
func (d *detailModel) setEntity(e *db.Entity) {
d.entity = e
d.backlinks = nil
d.scroll = 0
d.mode = detailPreview
}
func (d *detailModel) setSize(width, height int) {
d.width = width
d.height = height
}
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
switch d.mode {
case detailRun:
d.run = d.run.update(msg.String())
return d, nil
case detailFill:
var cmd tea.Cmd
d.fill, cmd = d.fill.update(msg)
return d, cmd
default:
switch msg.String() {
case "up", "k":
if d.scroll > 0 {
d.scroll--
}
case "down", "j":
d.scroll++
case "pgdown", "ctrl+d":
d.scroll += d.height
case "pgup", "ctrl+u":
d.scroll -= d.height
if d.scroll < 0 {
d.scroll = 0
}
case "home", "g":
d.scroll = 0
case "end", "G":
d.scroll = 1<<31 - 1
}
return d, nil
}
}
func (d detailModel) view(width int) string {
switch d.mode {
case detailRun:
return d.run.view(width)
case detailFill:
return d.fill.view(width)
default:
return d.previewView(width)
}
}
func (d detailModel) previewView(width int) string {
if d.entity == nil {
return ""
}
e := d.entity
var b strings.Builder
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
if e.CardType != nil {
header += " " + affordanceStyle.Render(string(*e.CardType))
}
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
if e.Title != nil {
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
b.WriteString("\n")
}
bodyWidth := width - 4
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath(glamourStyle()),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(detailBodyStyle.Render(rendered))
b.WriteString("\n")
if e.CardType != nil {
cardSection := renderCardData(e)
if cardSection != "" {
b.WriteString("\n")
b.WriteString(cardSection)
}
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
}
b.WriteString("\n")
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
b.WriteString("\n")
}
if len(d.backlinks) > 0 {
b.WriteString("\n")
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
b.WriteString("\n")
for _, bl := range d.backlinks {
label := bl.Body
if bl.Title != nil {
label = *bl.Title
} else if len(label) > 40 {
label = label[:40] + "…"
}
line := " " + backlinkStyle.Render(label)
if bl.LinkText != "" {
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
}
b.WriteString(line + "\n")
}
}
b.WriteString("\n")
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
if e.ModifiedAt != e.CreatedAt {
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
}
if e.TimeAnchor != nil {
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
}
if e.Pinned {
meta += "\n" + pinnedStyle.Render("pinned")
}
if e.CardType != nil {
meta += fmt.Sprintf("\ncard %s", *e.CardType)
}
if e.UseCount > 0 {
meta += fmt.Sprintf("\nused %d×", e.UseCount)
}
if e.CompletedAt != nil {
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
}
b.WriteString(idStyle.Render(meta))
lines := strings.Split(b.String(), "\n")
totalLines := len(lines)
maxScroll := totalLines - d.height
if maxScroll < 0 {
maxScroll = 0
}
scroll := d.scroll
if scroll > maxScroll {
scroll = maxScroll
}
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
lines[0] = lines[0] + indicator
}
if scroll > 0 && scroll < totalLines {
lines = lines[scroll:]
}
if d.height > 0 && len(lines) > d.height {
lines = lines[:d.height]
}
return strings.Join(lines, "\n")
}
func renderCardData(e *db.Entity) string {
if e.CardData == nil {
return ""
}
data, err := e.CardDataJSON()
if err != nil || data == nil {
return ""
}
var b strings.Builder
switch *e.CardType {
case db.CardChecklist:
steps, ok := data["steps"].([]interface{})
if !ok {
break
}
done := 0
for _, s := range steps {
step, ok := s.(map[string]interface{})
if !ok {
continue
}
text, _ := step["text"].(string)
isDone, _ := step["done"].(bool)
if isDone {
done++
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
} else {
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
}
}
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
b.WriteString(detailLabelStyle.Render(progress))
b.WriteString(" " + helpStyle.Render("r:run"))
case db.CardTemplate:
slots, ok := data["slots"].([]interface{})
if !ok {
break
}
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
for _, s := range slots {
slot, ok := s.(map[string]interface{})
if !ok {
continue
}
name, _ := slot["name"].(string)
def, _ := slot["default"].(string)
line := " ${" + name + "}"
if def != "" {
line += " " + detailValueStyle.Render("default: "+def)
}
b.WriteString(line + "\n")
}
b.WriteString(" " + helpStyle.Render("f:fill"))
case db.CardDecision:
if chose, ok := data["chose"].(string); ok && chose != "" {
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
}
if why, ok := data["why"].(string); ok && why != "" {
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
}
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
items := make([]string, 0, len(rejected))
for _, r := range rejected {
if s, ok := r.(string); ok {
items = append(items, s)
}
}
if len(items) > 0 {
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
}
}
case db.CardLink:
if url, ok := data["url"].(string); ok && url != "" {
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
}
}
return b.String()
}
+152
View File
@@ -0,0 +1,152 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
)
type fillSlot struct {
Name string
Default string
Value string
}
type fillModel struct {
slots []fillSlot
active int
body string
entityID string
ti textinput.Model
}
func newFillModel(entityID, body string) fillModel {
slots := discoverSlots(body)
m := fillModel{
slots: slots,
body: body,
entityID: entityID,
}
m.ti = textinput.New()
m.ti.CharLimit = 200
if len(slots) > 0 {
m.ti.Placeholder = slots[0].Name
m.ti.Focus()
if slots[0].Default != "" {
m.ti.SetValue(slots[0].Default)
}
}
return m
}
func discoverSlots(body string) []fillSlot {
matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1)
seen := map[string]bool{}
var slots []fillSlot
for _, m := range matches {
name := m[1]
if !seen[name] {
seen[name] = true
slots = append(slots, fillSlot{Name: name})
}
}
return slots
}
func (f fillModel) resolve() string {
result := f.body
for _, s := range f.slots {
val := s.Value
if val == "" {
val = "${" + s.Name + "}"
}
result = strings.ReplaceAll(result, "${"+s.Name+"}", val)
}
return result
}
func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) {
switch msg.String() {
case "tab":
f.commitActive()
if f.active < len(f.slots)-1 {
f.active++
} else {
f.active = 0
}
f.loadActive()
return f, nil
case "shift+tab":
f.commitActive()
if f.active > 0 {
f.active--
} else {
f.active = len(f.slots) - 1
}
f.loadActive()
return f, nil
}
f.ti, _ = f.ti.Update(msg)
return f, nil
}
func (f *fillModel) commitActive() {
if f.active < len(f.slots) {
f.slots[f.active].Value = f.ti.Value()
}
}
func (f *fillModel) loadActive() {
if f.active < len(f.slots) {
s := f.slots[f.active]
f.ti.SetValue(s.Value)
f.ti.Placeholder = s.Name
f.ti.Focus()
}
}
func (f fillModel) view(width int) string {
if len(f.slots) == 0 {
return statusStyle.Render("no slots")
}
var b strings.Builder
header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots))
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
for i, slot := range f.slots {
name := detailLabelStyle.Render(slot.Name)
var val string
if i == f.active {
val = f.ti.View()
} else if slot.Value != "" {
val = detailValueStyle.Render(slot.Value)
} else {
val = idStyle.Render("(empty)")
}
if i == f.active {
b.WriteString(selectedItemStyle.Render(" " + name + " " + val))
} else {
b.WriteString(listItemStyle.Render(name + " " + val))
}
b.WriteString("\n")
}
b.WriteString("\n")
preview := f.resolve()
if len(preview) > width-4 {
preview = preview[:width-7] + "…"
}
b.WriteString(detailBodyStyle.Render(preview))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel"))
return b.String()
}
+81
View File
@@ -0,0 +1,81 @@
package tui
import (
"testing"
)
func TestDiscoverSlots(t *testing.T) {
tests := []struct {
name string
body string
wantNames []string
}{
{"no slots", "plain text", nil},
{"single slot", "Hello ${name}", []string{"name"}},
{"multiple slots", "${greeting} ${name}, welcome to ${place}", []string{"greeting", "name", "place"}},
{"duplicate slot deduped", "${x} and ${x} again", []string{"x"}},
{"adjacent slots", "${a}${b}", []string{"a", "b"}},
{"nested braces ignored", "${{bad}}", nil},
{"empty body", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := discoverSlots(tt.body)
if len(tt.wantNames) == 0 && len(got) == 0 {
return
}
if len(got) != len(tt.wantNames) {
t.Fatalf("got %d slots, want %d", len(got), len(tt.wantNames))
}
for i, s := range got {
if s.Name != tt.wantNames[i] {
t.Fatalf("slot[%d].Name = %q, want %q", i, s.Name, tt.wantNames[i])
}
}
})
}
}
func TestResolve(t *testing.T) {
tests := []struct {
name string
body string
slots []fillSlot
want string
}{
{
"all filled",
"Hello ${name}, welcome to ${place}",
[]fillSlot{{Name: "name", Value: "Alice"}, {Name: "place", Value: "Nib"}},
"Hello Alice, welcome to Nib",
},
{
"unfilled stays as placeholder",
"${greeting} ${name}",
[]fillSlot{{Name: "greeting", Value: "Hi"}, {Name: "name"}},
"Hi ${name}",
},
{
"no slots",
"plain text",
nil,
"plain text",
},
{
"repeated slot filled everywhere",
"${x} and ${x}",
[]fillSlot{{Name: "x", Value: "Y"}},
"Y and Y",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := fillModel{body: tt.body, slots: tt.slots}
if got := f.resolve(); got != tt.want {
t.Fatalf("resolve() = %q, want %q", got, tt.want)
}
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"fmt"
"strings"
"github.com/lerko/nib/internal/db"
)
type filterModel struct {
tags []db.TagCount
cursor int
height int
}
func newFilterModel() filterModel {
return filterModel{}
}
func (f *filterModel) setTags(tags []db.TagCount) {
f.tags = tags
f.cursor = 0
}
func (f *filterModel) setHeight(h int) {
f.height = h
}
func (f filterModel) selectedTag() string {
if len(f.tags) == 0 || f.cursor >= len(f.tags) {
return ""
}
return f.tags[f.cursor].Tag
}
func (f filterModel) update(key string) filterModel {
switch key {
case "up", "k":
if f.cursor > 0 {
f.cursor--
}
case "down", "j":
if f.cursor < len(f.tags)-1 {
f.cursor++
}
}
return f
}
func (f filterModel) view(width int) string {
if len(f.tags) == 0 {
return statusStyle.Render("no tags")
}
var b strings.Builder
b.WriteString(titleStyle.Render("filter by tag"))
b.WriteString("\n\n")
visible := f.height - 4
if visible <= 0 {
visible = 10
}
offset := 0
if f.cursor >= visible {
offset = f.cursor - visible + 1
}
end := min(offset+visible, len(f.tags))
for i := offset; i < end; i++ {
tc := f.tags[i]
tag := fmt.Sprintf("#%-20s %d", tc.Tag, tc.Count)
if i == f.cursor {
b.WriteString(selectedItemStyle.Render(" " + tagStyle.Render(tag)))
} else {
b.WriteString(listItemStyle.Render(tagStyle.Render(tag)))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
+119
View File
@@ -0,0 +1,119 @@
package tui
import "strings"
func renderHelp(width, height int) string {
sections := []struct {
title string
binds [][2]string
}{
{"Focus", [][2]string{
{"tab", "toggle capture ↔ list"},
{"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"},
{"h", "focus tag rail (from list)"},
{"l", "focus detail (split view)"},
{"ctrl+b", "toggle tag rail"},
}},
{"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"},
{"?…", "search (type ?query)"},
{"#…", "tag (autocomplete with tab)"},
{"-", "todo prefix"},
{"@", "event prefix"},
{"!", "reminder prefix"},
}},
{"Query Operators", [][2]string{
{"?text", "substring search"},
{"?#tag1 #tag2", "filter by tags (AND)"},
{"?@today @week", "date filter (@yesterday @month)"},
{"?<7d >30d", "newer/older than N days"},
{"?^snippet", "card type filter"},
}},
{"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"},
{"enter", "view detail"},
}},
{"Views", [][2]string{
{"1", "stream view"},
{"2", "cards view"},
{"s", "cycle sort (cards)"},
{"i", "cycle intent (cards)"},
{"T", "cycle theme"},
}},
{"Actions", [][2]string{
{"d", "delete (with confirm)"},
{"x", "toggle todo completion"},
{"!", "toggle pin"},
{"#", "filter by tag"},
{"m", "absorb (merge into target)"},
{"p", "promote to card"},
{"S", "stumble (resurface stale entries)"},
}},
{"Detail View", [][2]string{
{"p", "promote to card"},
{"D", "demote to fluid"},
{"c", "copy to clipboard"},
{"e", "edit in $EDITOR"},
{"!", "toggle pin"},
{"r", "run checklist"},
{"f", "fill template"},
{"[", "follow [[link]]"},
{"esc", "back (pops link history)"},
}},
{"Stumble", [][2]string{
{"n / →", "skip to next"},
{"d", "dismiss (soft delete)"},
{"!", "pin"},
{"p", "promote to card"},
{"m", "absorb"},
{"esc", "exit"},
}},
{"Run Mode", [][2]string{
{"j/k", "move between steps"},
{"space", "toggle step"},
{"r", "reset all steps"},
{"esc", "save + exit"},
}},
{"Fill Mode", [][2]string{
{"tab", "next slot"},
{"shift+tab", "prev slot"},
{"enter", "copy resolved"},
{"esc", "cancel"},
}},
{"Split View", [][2]string{
{"l", "focus detail pane"},
{"h", "focus list pane"},
{"esc", "close detail / back"},
}},
{"Global", [][2]string{
{"?", "toggle help"},
{"q / ctrl+c", "quit"},
}},
}
var b strings.Builder
b.WriteString(detailHeaderStyle.Render("keybindings"))
b.WriteString("\n\n")
for _, s := range sections {
b.WriteString(titleStyle.Render(s.title))
b.WriteString("\n")
for _, bind := range s.binds {
key := helpKeyStyle.Render(bind[0])
desc := helpDescStyle.Render(bind[1])
b.WriteString(" " + key + " " + desc + "\n")
}
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("press ? or esc to close"))
lines := strings.Split(b.String(), "\n")
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
+177
View File
@@ -0,0 +1,177 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/parse"
)
type inputResult struct {
entity *db.Entity
query bool
body string
tags []string
dateFrom *string
dateTo *string
cardType *db.CardType
}
type inputModel struct {
ti textinput.Model
preview *parse.Result
}
func newInputModel() inputModel {
ti := textinput.New()
ti.Placeholder = "capture a thought…"
ti.Prompt = inputPromptStyle.Render(" ")
ti.CharLimit = 500
return inputModel{ti: ti}
}
func (i *inputModel) clearText() {
i.ti.SetValue("")
i.preview = nil
}
func (i inputModel) submit() *inputResult {
val := i.ti.Value()
if val == "" {
return nil
}
parsed, err := parse.Parse(val)
if err != nil {
return nil
}
if parsed.Query {
r := &inputResult{
query: true,
body: parsed.Body,
tags: parsed.FilterTags,
dateFrom: parsed.QueryDateFrom,
dateTo: parsed.QueryDateTo,
}
if parsed.QueryCardType != nil {
ct := db.CardType(*parsed.QueryCardType)
r.cardType = &ct
}
return r
}
e := &db.Entity{
Body: parsed.Body,
Title: parsed.Title,
Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags,
}
if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor
}
if parsed.CardSuffix != nil {
ct := db.CardType(*parsed.CardSuffix)
e.CardType = &ct
}
if parsed.Pin {
e.Pinned = true
}
if parsed.Description != nil {
e.Description = parsed.Description
}
return &inputResult{entity: e}
}
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
i.ti, _ = i.ti.Update(msg)
val := i.ti.Value()
if val != "" {
parsed, err := parse.Parse(val)
if err == nil {
i.preview = parsed
} else {
i.preview = nil
}
} else {
i.preview = nil
}
return i
}
func (i inputModel) viewBar(width int, focused bool) string {
tiView := i.ti.View()
if focused {
return tiView
}
val := i.ti.Value()
if val != "" {
return hintDescStyle.Render(" " + val)
}
return hintDescStyle.Render(" capture a thought…")
}
func (i inputModel) previewText() string {
if i.preview == nil {
return ""
}
p := i.preview
if p.Query {
q := "?"
if p.Body != "" {
q += p.Body
}
for _, t := range p.FilterTags {
q += " #" + t
}
if p.QueryDateFrom != nil {
q += " from:" + *p.QueryDateFrom
}
if p.QueryDateTo != nil {
q += " to:" + *p.QueryDateTo
}
if p.QueryCardType != nil {
q += " ^" + *p.QueryCardType
}
return "search: " + q
}
glyph := glyphForParsed(p.Glyph)
body := p.Body
if p.Title != nil {
body = *p.Title
}
var parts []string
parts = append(parts, glyph, body)
for _, t := range p.Tags {
parts = append(parts, "#"+t)
}
if p.Pin {
parts = append(parts, "•")
}
if p.CardSuffix != nil {
parts = append(parts, *p.CardSuffix)
}
return strings.Join(parts, " ")
}
func glyphForParsed(glyph string) string {
switch glyph {
case "todo":
return "○"
case "event":
return "◇"
case "reminder":
return "△"
default:
return "—"
}
}
+73
View File
@@ -0,0 +1,73 @@
package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Back key.Binding
Capture key.Binding
Delete key.Binding
Quit key.Binding
Help key.Binding
PageUp key.Binding
PageDn key.Binding
Top key.Binding
Bottom key.Binding
Todo key.Binding
Pin key.Binding
Filter key.Binding
Promote key.Binding
Demote key.Binding
Copy key.Binding
Edit key.Binding
Stream key.Binding
Cards key.Binding
Sort key.Binding
Intent key.Binding
Absorb key.Binding
Run key.Binding
Fill key.Binding
FocusLeft key.Binding
FocusRight key.Binding
Tab key.Binding
ToggleRail key.Binding
Stumble key.Binding
Theme key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")),
PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")),
Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")),
Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")),
Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")),
Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")),
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")),
}
+108
View File
@@ -0,0 +1,108 @@
package tui
import (
"strings"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/link"
)
type linkKind int
const (
linkOutgoing linkKind = iota
linkBacklink
)
type linkItem struct {
text string
entityID string
kind linkKind
}
type linkPickerModel struct {
items []linkItem
cursor int
}
func newLinkPicker(body string, backlinks []db.Backlink) linkPickerModel {
var items []linkItem
for _, lt := range link.ExtractLinks(body) {
items = append(items, linkItem{text: lt, kind: linkOutgoing})
}
for _, bl := range backlinks {
label := bl.Body
if bl.Title != nil {
label = *bl.Title
} else if len(label) > 50 {
label = label[:50] + "…"
}
items = append(items, linkItem{text: label, entityID: bl.EntityID, kind: linkBacklink})
}
return linkPickerModel{items: items}
}
func (lp linkPickerModel) selected() linkItem {
if len(lp.items) == 0 || lp.cursor >= len(lp.items) {
return linkItem{}
}
return lp.items[lp.cursor]
}
func (lp linkPickerModel) update(key string) linkPickerModel {
switch key {
case "up", "k":
if lp.cursor > 0 {
lp.cursor--
}
case "down", "j":
if lp.cursor < len(lp.items)-1 {
lp.cursor++
}
}
return lp
}
func (lp linkPickerModel) view(width int) string {
var b strings.Builder
b.WriteString(titleStyle.Render("follow link") + "\n\n")
if len(lp.items) == 0 {
b.WriteString(hintDescStyle.Render(" no links or backlinks"))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("esc:back"))
return b.String()
}
prevKind := linkKind(-1)
for i, item := range lp.items {
if item.kind != prevKind {
if item.kind == linkOutgoing {
b.WriteString(dateHeaderStyle.Render("── outgoing ──") + "\n")
} else {
b.WriteString(dateHeaderStyle.Render("── backlinks ──") + "\n")
}
prevKind = item.kind
}
var label string
if item.kind == linkOutgoing {
label = "[[" + item.text + "]]"
} else {
label = "← " + item.text
}
if i == lp.cursor {
b.WriteString(selectedItemStyle.Render(" " + label))
} else {
b.WriteString(listItemStyle.Render(label))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("enter:follow esc:back"))
return b.String()
}
+270
View File
@@ -0,0 +1,270 @@
package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type listModel struct {
entities []*db.Entity
filtered []*db.Entity
cursor int
offset int
height int
width int
}
func newListModel() listModel {
return listModel{}
}
func (l *listModel) setEntities(entities []*db.Entity) {
l.entities = entities
l.filtered = nil
if l.cursor >= len(entities) {
l.cursor = max(0, len(entities)-1)
}
}
func (l *listModel) setSize(width, height int) {
l.width = width
l.height = height
}
func (l listModel) displayEntities() []*db.Entity {
if l.filtered != nil {
return l.filtered
}
return l.entities
}
func (l listModel) selected() *db.Entity {
ents := l.displayEntities()
if len(ents) == 0 || l.cursor >= len(ents) {
return nil
}
return ents[l.cursor]
}
func (l listModel) update(msg tea.KeyMsg) listModel {
switch msg.String() {
case "up", "k":
if l.cursor > 0 {
l.cursor--
if l.cursor < l.offset {
l.offset = l.cursor
}
}
case "down", "j":
if l.cursor < len(l.displayEntities())-1 {
l.cursor++
visible := l.visibleCount()
if l.cursor >= l.offset+visible {
l.offset = l.cursor - visible + 1
}
}
case "home", "g":
l.cursor = 0
l.offset = 0
case "end", "G":
l.cursor = max(0, len(l.displayEntities())-1)
visible := l.visibleCount()
if l.cursor >= visible {
l.offset = l.cursor - visible + 1
}
case "pgup", "ctrl+u":
l.cursor = max(0, l.cursor-l.visibleCount())
if l.cursor < l.offset {
l.offset = l.cursor
}
case "pgdown", "ctrl+d":
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
visible := l.visibleCount()
if l.cursor >= l.offset+visible {
l.offset = l.cursor - visible + 1
}
}
return l
}
const dateGutterWidth = 9
func (l listModel) view(width int) string {
ents := l.displayEntities()
if len(ents) == 0 {
return statusStyle.Render("no entities")
}
groups := groupByDate(ents)
entityWidth := width - 4 - dateGutterWidth
type displayLine struct {
text string
entityIdx int
}
var lines []displayLine
entityIdx := 0
for _, g := range groups {
for i, e := range g.entities {
var gutter string
if i == 0 {
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
} else {
gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderEntity(e, entityWidth)
lines = append(lines, displayLine{
text: line,
entityIdx: entityIdx,
})
entityIdx++
}
}
visible := l.visibleCount()
offset := 0
if l.cursor >= visible {
offset = l.cursor - visible + 1
}
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.entityIdx == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (l listModel) visibleCount() int {
if l.height <= 0 {
return 20
}
return l.height
}
type dateGroup struct {
label string
entities []*db.Entity
}
func groupByDate(entities []*db.Entity) []dateGroup {
var groups []dateGroup
var current *dateGroup
for _, e := range entities {
label := formatDateLabel(e.CreatedAt)
if current == nil || current.label != label {
if current != nil {
groups = append(groups, *current)
}
current = &dateGroup{label: label}
}
current.entities = append(current.entities, e)
}
if current != nil {
groups = append(groups, *current)
}
return groups
}
func formatDateLabel(t time.Time) string {
return strings.ToLower(t.Format("Jan 2"))
}
func padRight(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
return s + strings.Repeat(" ", n-len(r))
}
func renderEntity(e *db.Entity, maxWidth int) string {
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
style := glyphStyle
if e.Glyph == db.GlyphTodo && e.CompletedAt != nil {
glyphStr = "●"
style = completedGlyphStyle
}
glyph := style.Render(glyphStr)
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
limit := min(2, len(e.Tags))
for _, t := range e.Tags[:limit] {
extras = append(extras, tagStyle.Render("#"+t))
}
}
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
line := fmt.Sprintf("%s %s%s", glyph, body, extraStr)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
}
return line
}
func truncate(s string, maxLen int) string {
if maxLen <= 3 {
return "…"
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen-1]) + "…"
}
func stripAnsi(s string) string {
var b strings.Builder
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
inEsc = false
}
continue
}
b.WriteRune(r)
}
return b.String()
}
+94
View File
@@ -0,0 +1,94 @@
package tui
import (
"testing"
"time"
"github.com/lerko/nib/internal/db"
)
func TestGroupByDate(t *testing.T) {
may19 := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC)
may19b := time.Date(2026, 5, 19, 14, 0, 0, 0, time.UTC)
may18 := time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC)
entities := []*db.Entity{
{ID: "1", CreatedAt: may19, Body: "a"},
{ID: "2", CreatedAt: may19b, Body: "b"},
{ID: "3", CreatedAt: may18, Body: "c"},
}
groups := groupByDate(entities)
if len(groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(groups))
}
if len(groups[0].entities) != 2 {
t.Fatalf("first group should have 2 entities, got %d", len(groups[0].entities))
}
if len(groups[1].entities) != 1 {
t.Fatalf("second group should have 1 entity, got %d", len(groups[1].entities))
}
if groups[0].label != "may 19" {
t.Fatalf("first group label = %q, want %q", groups[0].label, "may 19")
}
}
func TestGroupByDate_Empty(t *testing.T) {
groups := groupByDate(nil)
if len(groups) != 0 {
t.Fatalf("expected 0 groups, got %d", len(groups))
}
}
func TestGroupByDate_SingleEntity(t *testing.T) {
e := []*db.Entity{{ID: "1", CreatedAt: time.Now(), Body: "solo"}}
groups := groupByDate(e)
if len(groups) != 1 || len(groups[0].entities) != 1 {
t.Fatal("single entity should produce 1 group with 1 entity")
}
}
func TestTruncate(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{"short enough", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncated", "hello world", 6, "hello…"},
{"very short max", "hello", 3, "…"},
{"unicode", "héllo wörld", 7, "héllo …"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncate(tt.input, tt.maxLen)
if got != tt.want {
t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
})
}
}
func TestStripAnsi(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"no ansi", "hello", "hello"},
{"with color", "\x1b[31mred\x1b[0m", "red"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripAnsi(tt.input)
if got != tt.want {
t.Fatalf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type promoteOption struct {
cardType db.CardType
label string
group string
}
var promoteOptions = []promoteOption{
{db.CardSnippet, "snippet", "grab"},
{db.CardNote, "note", "read"},
{db.CardLink, "link", "read"},
{db.CardDecision, "decision", "read"},
{db.CardTemplate, "template", "fill"},
{db.CardChecklist, "checklist", "fill"},
}
type promoteModel struct {
cursor int
entityID string
body string
suggested *db.CardType
}
func newPromoteModel(entityID, body string) promoteModel {
return promoteModel{
entityID: entityID,
body: body,
suggested: carddata.DetectCardType(body),
}
}
func (p promoteModel) selectedType() db.CardType {
return promoteOptions[p.cursor].cardType
}
func (p promoteModel) update(key string) promoteModel {
switch key {
case "up", "k":
if p.cursor > 0 {
p.cursor--
}
case "down", "j":
if p.cursor < len(promoteOptions)-1 {
p.cursor++
}
}
return p
}
func (p promoteModel) view(width int) string {
var b string
b += titleStyle.Render("promote to card") + "\n\n"
currentGroup := ""
for i, opt := range promoteOptions {
if opt.group != currentGroup {
currentGroup = opt.group
b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n"
}
glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType)
label := glyph + " " + opt.label
if p.suggested != nil && *p.suggested == opt.cardType {
label += " " + affordanceStyle.Render("*")
}
if i == p.cursor {
b += selectedItemStyle.Render(" "+label) + "\n"
} else {
b += listItemStyle.Render(label) + "\n"
}
}
b += "\n" + helpStyle.Render("enter:select esc:cancel")
return b
}
+116
View File
@@ -0,0 +1,116 @@
package tui
import (
"encoding/json"
"fmt"
"strings"
)
type runStep struct {
Text string `json:"text"`
Done bool `json:"done"`
}
type runModel struct {
steps []runStep
cursor int
entityID string
dirty bool
}
func newRunModel(entityID string, cardData *string) runModel {
m := runModel{entityID: entityID}
m.steps = parseChecklist(cardData)
return m
}
func parseChecklist(cardData *string) []runStep {
if cardData == nil {
return nil
}
var data struct {
Steps []runStep `json:"steps"`
}
if err := json.Unmarshal([]byte(*cardData), &data); err != nil {
return nil
}
return data.Steps
}
func (r runModel) stepsJSON() string {
b, _ := json.Marshal(map[string]any{"steps": r.steps})
return string(b)
}
func (r runModel) doneCount() int {
n := 0
for _, s := range r.steps {
if s.Done {
n++
}
}
return n
}
func (r runModel) update(key string) runModel {
switch key {
case "up", "k":
if r.cursor > 0 {
r.cursor--
}
case "down", "j":
if r.cursor < len(r.steps)-1 {
r.cursor++
}
case " ":
if r.cursor < len(r.steps) {
r.steps[r.cursor].Done = !r.steps[r.cursor].Done
r.dirty = true
}
case "r":
for i := range r.steps {
r.steps[i].Done = false
}
r.dirty = true
}
return r
}
func (r runModel) view(width int) string {
if len(r.steps) == 0 {
return statusStyle.Render("no steps")
}
var b strings.Builder
done := r.doneCount()
total := len(r.steps)
pct := 0
if total > 0 {
pct = done * 100 / total
}
header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct)
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
for i, step := range r.steps {
var line string
if step.Done {
line = checkDoneStyle.Render("[✓] " + step.Text)
} else {
line = checkPendingStyle.Render("[ ] " + step.Text)
}
if i == r.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit"))
return b.String()
}
+65
View File
@@ -0,0 +1,65 @@
package tui
import (
"encoding/json"
"testing"
)
func TestParseChecklist(t *testing.T) {
tests := []struct {
name string
cardData *string
wantLen int
}{
{"nil data", nil, 0},
{"empty JSON", ptr("{}"), 0},
{"malformed JSON", ptr("{bad"), 0},
{"valid steps", ptr(`{"steps":[{"text":"step 1","done":false},{"text":"step 2","done":true}]}`), 2},
{"empty steps array", ptr(`{"steps":[]}`), 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseChecklist(tt.cardData)
if len(got) != tt.wantLen {
t.Fatalf("parseChecklist() returned %d steps, want %d", len(got), tt.wantLen)
}
})
}
}
func TestParseChecklist_PreservesDoneState(t *testing.T) {
data := `{"steps":[{"text":"first","done":false},{"text":"second","done":true}]}`
steps := parseChecklist(&data)
if steps[0].Done {
t.Fatal("step 0 should not be done")
}
if !steps[1].Done {
t.Fatal("step 1 should be done")
}
if steps[0].Text != "first" || steps[1].Text != "second" {
t.Fatalf("texts wrong: %q, %q", steps[0].Text, steps[1].Text)
}
}
func TestDoneCount(t *testing.T) {
r := newRunModel("id", ptr(`{"steps":[{"label":"a","done":true},{"label":"b","done":false},{"label":"c","done":true}]}`))
if got := r.doneCount(); got != 2 {
t.Fatalf("doneCount() = %d, want 2", got)
}
}
func TestStepsJSON_Roundtrip(t *testing.T) {
r := newRunModel("id", ptr(`{"steps":[{"text":"test","done":false}]}`))
out := r.stepsJSON()
var parsed struct {
Steps []runStep `json:"steps"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("stepsJSON() produced invalid JSON: %v", err)
}
if len(parsed.Steps) != 1 || parsed.Steps[0].Text != "test" {
t.Fatalf("roundtrip failed: %+v", parsed.Steps)
}
}
+55
View File
@@ -0,0 +1,55 @@
package tui
import (
"strings"
"github.com/lerko/nib/internal/db"
)
func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity {
if query == "" && len(tags) == 0 {
return entities
}
query = strings.ToLower(query)
lowerTags := make([]string, len(tags))
for i, t := range tags {
lowerTags[i] = strings.ToLower(t)
}
var result []*db.Entity
for _, e := range entities {
if !matchesSearch(e, query, lowerTags) {
continue
}
result = append(result, e)
}
return result
}
func matchesSearch(e *db.Entity, query string, tags []string) bool {
if len(tags) > 0 {
eTags := make(map[string]bool, len(e.Tags))
for _, t := range e.Tags {
eTags[strings.ToLower(t)] = true
}
for _, t := range tags {
if !eTags[t] {
return false
}
}
}
if query == "" {
return true
}
haystack := strings.ToLower(e.Body)
if e.Title != nil {
haystack += " " + strings.ToLower(*e.Title)
}
if e.Description != nil {
haystack += " " + strings.ToLower(*e.Description)
}
return strings.Contains(haystack, query)
}
+70
View File
@@ -0,0 +1,70 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func ptr[T any](v T) *T { return &v }
func TestFilterEntities(t *testing.T) {
entities := []*db.Entity{
{ID: "1", Body: "buy groceries", Tags: []string{"errand", "food"}},
{ID: "2", Body: "read chapter 5", Title: ptr("Go Book"), Tags: []string{"study"}},
{ID: "3", Body: "fix login bug", Description: ptr("auth middleware broken"), Tags: []string{"work", "urgent"}},
{ID: "4", Body: "empty tags"},
}
tests := []struct {
name string
query string
tags []string
wantIDs []string
}{
{"no filter returns all", "", nil, []string{"1", "2", "3", "4"}},
{"query matches body", "groceries", nil, []string{"1"}},
{"query case insensitive", "GROCERIES", nil, []string{"1"}},
{"query matches title", "go book", nil, []string{"2"}},
{"query matches description", "middleware", nil, []string{"3"}},
{"query no match", "nonexistent", nil, nil},
{"single tag filter", "", []string{"study"}, []string{"2"}},
{"multi tag filter all present", "", []string{"work", "urgent"}, []string{"3"}},
{"multi tag filter partial miss", "", []string{"work", "food"}, nil},
{"tag filter no match", "", []string{"missing"}, nil},
{"query plus tag", "fix", []string{"work"}, []string{"3"}},
{"query plus tag mismatch", "groceries", []string{"work"}, nil},
{"entity with nil title and description", "empty", nil, []string{"4"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterEntities(entities, tt.query, tt.tags)
gotIDs := make([]string, len(got))
for i, e := range got {
gotIDs[i] = e.ID
}
if len(tt.wantIDs) == 0 && len(gotIDs) == 0 {
return
}
if len(gotIDs) != len(tt.wantIDs) {
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
}
for i := range gotIDs {
if gotIDs[i] != tt.wantIDs[i] {
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
}
}
})
}
}
func TestMatchesSearch_NilFields(t *testing.T) {
e := &db.Entity{Body: "hello world"}
if !matchesSearch(e, "hello", nil) {
t.Fatal("should match body")
}
if matchesSearch(e, "title", nil) {
t.Fatal("should not match nil title")
}
}
+108
View File
@@ -0,0 +1,108 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type hint struct {
key string
desc string
}
func renderHints(hints []hint) string {
parts := make([]string, len(hints))
for i, h := range hints {
parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc)
}
return strings.Join(parts, " ")
}
func renderTab(label, key string, active bool) string {
if active {
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
}
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
}
func renderStatusBar(m model, width int) string {
var leftParts []string
if m.status != "" {
leftParts = append(leftParts, statusStyle.Render(m.status))
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
} else {
leftParts = append(leftParts, statusStyle.Render(countText(m)))
}
leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ")
right := renderHints(contextHints(m))
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + right
}
func countText(m model) string {
var total int
if m.mode == modeCards {
total = len(m.cards.filtered)
} else {
total = len(m.list.displayEntities())
}
if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
}
return fmt.Sprintf("%d entities", total)
}
func contextHints(m model) []hint {
switch m.state {
case stateDetail:
switch m.detail.mode {
case detailRun:
return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
case detailFill:
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
default:
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
}
case stateTagFilter:
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateConfirm:
return []hint{{"y", "confirm"}, {"n", "cancel"}}
case statePromote:
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateAbsorb:
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
case stateStumble:
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
}
switch m.focus {
case focusCapture:
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
case focusTagRail:
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
case focusDetail:
if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
}
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
default:
if m.splitDetail {
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
}
if m.mode == modeCards {
return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
}
return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
}
}
+164
View File
@@ -0,0 +1,164 @@
package tui
import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
const staleThresholdDays = 30
type stumbleModel struct {
entries []*db.Entity
cursor int
width int
height int
done bool
}
func newStumbleModel() stumbleModel {
return stumbleModel{}
}
func (s *stumbleModel) setEntries(entries []*db.Entity) {
s.entries = entries
s.cursor = 0
s.done = len(entries) == 0
}
func (s *stumbleModel) setSize(width, height int) {
s.width = width
s.height = height
}
func (s stumbleModel) current() *db.Entity {
if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) {
return nil
}
return s.entries[s.cursor]
}
func (s *stumbleModel) advance() {
s.cursor++
if s.cursor >= len(s.entries) {
s.done = true
}
}
func (s *stumbleModel) removeCurrent() {
if s.cursor < len(s.entries) {
s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...)
if s.cursor >= len(s.entries) {
s.done = true
}
}
}
func (s stumbleModel) total() int {
return len(s.entries)
}
func (s stumbleModel) view() string {
if s.done {
return s.doneView()
}
e := s.current()
if e == nil {
return s.doneView()
}
w := s.width
var b strings.Builder
progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries))
b.WriteString(detailHeaderStyle.Render(progress))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
b.WriteString("\n\n")
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
title := e.Body
if e.Title != nil {
title = *e.Title
}
if len(title) > w-6 {
title = title[:w-9] + "…"
}
b.WriteString(" " + glyphStyle.Render(glyph) + " " + title)
b.WriteString("\n")
var meta []string
meta = append(meta, string(e.Glyph))
if e.CardType != nil {
meta = append(meta, string(*e.CardType))
}
for _, t := range e.Tags {
meta = append(meta, tagStyle.Render("#"+t))
}
meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2"))
b.WriteString(" " + idStyle.Render(strings.Join(meta, " · ")))
b.WriteString("\n\n")
bodyWidth := w - 6
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath(glamourStyle()),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(" " + rendered)
b.WriteString("\n\n")
age := daysAgo(e.ModifiedAt)
ageText := fmt.Sprintf("last touched %d days ago", age)
b.WriteString(" " + stumbleAgeStyle.Render(ageText))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
lines := strings.Split(b.String(), "\n")
if len(lines) > s.height {
lines = lines[:s.height]
}
return strings.Join(lines, "\n")
}
func (s stumbleModel) doneView() string {
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(detailHeaderStyle.Render(" all caught up"))
b.WriteString("\n\n")
reviewed := s.total()
if reviewed > 0 {
b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed)))
} else {
b.WriteString(idStyle.Render(" no stale entries found"))
}
return b.String()
}
func daysAgo(t time.Time) int {
return int(math.Floor(time.Since(t).Hours() / 24))
}
func staleParams() db.ListParams {
threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays)
return db.ListParams{
ModifiedBefore: &threshold,
Sort: "modified_at",
Order: "asc",
Limit: 50,
}
}
+105
View File
@@ -0,0 +1,105 @@
package tui
import "github.com/charmbracelet/lipgloss"
var (
titleStyle lipgloss.Style
statusStyle lipgloss.Style
listItemStyle lipgloss.Style
selectedItemStyle lipgloss.Style
glyphStyle lipgloss.Style
completedGlyphStyle lipgloss.Style
tagStyle lipgloss.Style
idStyle lipgloss.Style
inputPromptStyle lipgloss.Style
detailHeaderStyle lipgloss.Style
detailBodyStyle lipgloss.Style
helpStyle lipgloss.Style
errorStyle lipgloss.Style
dateHeaderStyle lipgloss.Style
pinnedStyle lipgloss.Style
filterPillStyle lipgloss.Style
helpKeyStyle lipgloss.Style
helpDescStyle lipgloss.Style
affordanceStyle lipgloss.Style
useCountStyle lipgloss.Style
modeStyle lipgloss.Style
detailLabelStyle lipgloss.Style
detailValueStyle lipgloss.Style
checkDoneStyle lipgloss.Style
checkPendingStyle lipgloss.Style
searchPillStyle lipgloss.Style
gutterStyle lipgloss.Style
drawerBorderStyle lipgloss.Style
drawerHintsStyle lipgloss.Style
drawerPreviewStyle lipgloss.Style
separatorStyle lipgloss.Style
hintKeyStyle lipgloss.Style
hintDescStyle lipgloss.Style
railHeaderStyle lipgloss.Style
railTagStyle lipgloss.Style
railActiveTagStyle lipgloss.Style
railCountStyle lipgloss.Style
stumbleAgeStyle lipgloss.Style
acSelectedStyle lipgloss.Style
acItemStyle lipgloss.Style
backlinkStyle lipgloss.Style
)
func init() {
applyTheme()
}
func applyTheme() {
t := activeTheme()
accent := lipgloss.Color(t.Accent)
dim := lipgloss.Color(t.Dim)
muted := lipgloss.Color(t.Muted)
ok := lipgloss.Color(t.Ok)
todo := lipgloss.Color(t.Todo)
event := lipgloss.Color(t.Event)
remind := lipgloss.Color(t.Remind)
danger := lipgloss.Color(t.Danger)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1)
statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
listItemStyle = lipgloss.NewStyle().PaddingLeft(4)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("")
glyphStyle = lipgloss.NewStyle().Width(2)
completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim)
tagStyle = lipgloss.NewStyle().Foreground(ok)
idStyle = lipgloss.NewStyle().Foreground(dim)
inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1)
detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1)
helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().Foreground(todo)
filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18)
helpDescStyle = lipgloss.NewStyle().Foreground(dim)
affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true)
useCountStyle = lipgloss.NewStyle().Foreground(remind)
modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true)
detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
detailValueStyle = lipgloss.NewStyle().Foreground(muted)
checkDoneStyle = lipgloss.NewStyle().Foreground(ok)
checkPendingStyle = lipgloss.NewStyle().Foreground(dim)
searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true)
gutterStyle = lipgloss.NewStyle().Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().Foreground(dim)
hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
hintDescStyle = lipgloss.NewStyle().Foreground(dim)
railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim)
railTagStyle = lipgloss.NewStyle().Foreground(ok)
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
railCountStyle = lipgloss.NewStyle().Foreground(dim)
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
acItemStyle = lipgloss.NewStyle().Foreground(muted)
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
}
+139
View File
@@ -0,0 +1,139 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db"
)
type tagRailModel struct {
tags []db.TagCount
cursor int
offset int
height int
width int
activeTag string
}
func newTagRailModel() tagRailModel {
return tagRailModel{}
}
func (r *tagRailModel) setTags(tags []db.TagCount) {
r.tags = tags
if r.cursor >= len(tags) {
r.cursor = max(0, len(tags)-1)
}
}
func (r *tagRailModel) setSize(width, height int) {
r.width = width
r.height = height
}
func (r tagRailModel) selectedTag() string {
if len(r.tags) == 0 || r.cursor >= len(r.tags) {
return ""
}
return r.tags[r.cursor].Tag
}
func (r tagRailModel) update(key string) tagRailModel {
switch key {
case "up", "k":
if r.cursor > 0 {
r.cursor--
if r.cursor < r.offset {
r.offset = r.cursor
}
}
case "down", "j":
if r.cursor < len(r.tags)-1 {
r.cursor++
visible := r.visibleCount()
if r.cursor >= r.offset+visible {
r.offset = r.cursor - visible + 1
}
}
}
return r
}
func (r tagRailModel) visibleCount() int {
v := r.height - 2
if v <= 0 {
return 10
}
return v
}
func (r tagRailModel) view(focused bool) string {
w := r.width
if w <= 0 {
return ""
}
var b strings.Builder
headerStyle := railHeaderStyle
if focused {
headerStyle = headerStyle.Foreground(lipgloss.Color(activeTheme().Accent))
}
b.WriteString(headerStyle.Render("tags"))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
b.WriteString("\n")
if len(r.tags) == 0 {
b.WriteString(hintDescStyle.Render(" no tags"))
return b.String()
}
visible := r.visibleCount()
end := min(r.offset+visible, len(r.tags))
countW := 0
for _, tc := range r.tags {
cw := len(fmt.Sprintf("%d", tc.Count))
if cw > countW {
countW = cw
}
}
nameW := w - countW - 3
if nameW < 4 {
nameW = 4
}
for i := r.offset; i < end; i++ {
tc := r.tags[i]
name := "#" + tc.Tag
if len(name) > nameW {
name = name[:nameW-1] + "…"
}
count := fmt.Sprintf("%*d", countW, tc.Count)
gap := w - len(name) - len(count) - 1
if gap < 1 {
gap = 1
}
var line string
if i == r.cursor && focused {
line = selectedItemStyle.Render(" " + name + strings.Repeat(" ", gap) + railCountStyle.Render(count))
} else if tc.Tag == r.activeTag {
line = " " + railActiveTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
} else {
line = " " + railTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
}
b.WriteString(line)
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
+90
View File
@@ -0,0 +1,90 @@
package tui
import (
"os"
"path/filepath"
"strings"
)
type Theme struct {
Name string
Dark bool
Accent string
Dim string
Muted string
Ok string
Todo string
Event string
Remind string
Danger string
}
var themes = []Theme{
{Name: "dark", Dark: true, Accent: "#c8942a", Dim: "#504840", Muted: "#8c8070", Ok: "#7aab72", Todo: "#d4a84b", Event: "#6898c8", Remind: "#c8784a", Danger: "#b85858"},
{Name: "tinycard", Dark: true, Accent: "#ad8ee6", Dim: "#555a6a", Muted: "#8b90a0", Ok: "#4ade80", Todo: "#fbbf24", Event: "#22d3ee", Remind: "#e8845a", Danger: "#ef4444"},
{Name: "catppuccin", Dark: true, Accent: "#cba6f7", Dim: "#6c7086", Muted: "#a6adc8", Ok: "#a6e3a1", Todo: "#f9e2af", Event: "#89b4fa", Remind: "#fab387", Danger: "#f38ba8"},
{Name: "nord", Dark: true, Accent: "#88c0d0", Dim: "#4c566a", Muted: "#d8dee9", Ok: "#a3be8c", Todo: "#ebcb8b", Event: "#81a1c1", Remind: "#d08770", Danger: "#bf616a"},
{Name: "dracula", Dark: true, Accent: "#bd93f9", Dim: "#6272a4", Muted: "#bfbfbf", Ok: "#50fa7b", Todo: "#f1fa8c", Event: "#8be9fd", Remind: "#ffb86c", Danger: "#ff5555"},
{Name: "gruvbox", Dark: true, Accent: "#fabd2f", Dim: "#665c54", Muted: "#a89984", Ok: "#b8bb26", Todo: "#fabd2f", Event: "#83a598", Remind: "#fe8019", Danger: "#fb4934"},
{Name: "rosepine", Dark: true, Accent: "#c4a7e7", Dim: "#6e6a86", Muted: "#908caa", Ok: "#a6da95", Todo: "#f6c177", Event: "#31748f", Remind: "#ea9a97", Danger: "#eb6f92"},
{Name: "tokyonight", Dark: true, Accent: "#7aa2f7", Dim: "#565f89", Muted: "#a9b1d6", Ok: "#9ece6a", Todo: "#e0af68", Event: "#7aa2f7", Remind: "#ff9e64", Danger: "#f7768e"},
{Name: "solarized", Dark: true, Accent: "#268bd2", Dim: "#586e75", Muted: "#657b83", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
{Name: "paper", Dark: false, Accent: "#8a6018", Dim: "#a09080", Muted: "#6a5e50", Ok: "#2a6828", Todo: "#7a5c00", Event: "#245890", Remind: "#984020", Danger: "#882030"},
{Name: "catppuccin-latte", Dark: false, Accent: "#8839ef", Dim: "#9ca0b0", Muted: "#6c6f85", Ok: "#40a02b", Todo: "#df8e1d", Event: "#1e66f5", Remind: "#fe640b", Danger: "#d20f39"},
{Name: "rosepine-dawn", Dark: false, Accent: "#907aa9", Dim: "#9893a5", Muted: "#797593", Ok: "#56949f", Todo: "#ea9d34", Event: "#286983", Remind: "#d7827e", Danger: "#b4637a"},
{Name: "solarized-light", Dark: false, Accent: "#268bd2", Dim: "#93a1a1", Muted: "#586e75", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
}
var activeThemeIndex int
func activeTheme() Theme {
return themes[activeThemeIndex]
}
func cycleTheme() Theme {
activeThemeIndex = (activeThemeIndex + 1) % len(themes)
applyTheme()
saveTheme()
return themes[activeThemeIndex]
}
func glamourStyle() string {
if themes[activeThemeIndex].Dark {
return "dark"
}
return "light"
}
func themePath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".nib", "theme")
}
func loadTheme() {
p := themePath()
if p == "" {
return
}
data, err := os.ReadFile(p)
if err != nil {
return
}
name := strings.TrimSpace(string(data))
for i, t := range themes {
if t.Name == name {
activeThemeIndex = i
return
}
}
}
func saveTheme() {
p := themePath()
if p == "" {
return
}
_ = os.WriteFile(p, []byte(themes[activeThemeIndex].Name+"\n"), 0o644)
}
+20
View File
@@ -0,0 +1,20 @@
package tui
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
)
func Run(store *db.Store) error {
m := newModel(store)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
return err
}
return nil
}
+133
View File
@@ -0,0 +1,133 @@
[
{
"body": "Buy milk, eggs, and bread",
"glyph": "todo",
"tags": ["errands", "grocery"]
},
{
"body": "Fix leaking kitchen faucet",
"glyph": "todo",
"tags": ["home", "plumbing"]
},
{
"body": "Review pull request for auth refactor",
"glyph": "todo",
"tags": ["work", "code-review"],
"pinned": true
},
{
"body": "Dentist appointment",
"glyph": "event",
"time_anchor": "2026-05-20T10:00:00Z",
"tags": ["health"]
},
{
"body": "Team standup",
"glyph": "event",
"time_anchor": "2026-05-19T09:00:00Z",
"tags": ["work", "meetings"]
},
{
"body": "Kubernetes clusters use etcd as the backing store for all cluster data including state, config, and metadata.",
"glyph": "note",
"tags": ["devops", "k8s"]
},
{
"body": "The Go scheduler uses M:N threading — M goroutines multiplexed onto N OS threads.",
"glyph": "note",
"tags": ["golang", "til"]
},
{
"body": "Solar panel installation — get 3 quotes before June",
"glyph": "note",
"tags": ["home", "solar"],
"pinned": true
},
{
"body": "Submit quarterly tax estimate",
"glyph": "todo",
"time_anchor": "2026-06-15T00:00:00Z",
"tags": ["finance"]
},
{
"body": "Backup NAS to offsite",
"glyph": "todo",
"completed": true,
"tags": ["homelab", "backups"]
},
{
"body": "version: '3'\nservices:\n traefik:\n image: traefik:v2.10\n ports:\n - \"${host_port:-443}:443\"\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n environment:\n - CF_DNS_API_TOKEN=${cf_token}\n labels:\n - traefik.http.routers.dashboard.rule=Host(`${dashboard_domain}`)",
"glyph": "note",
"title": "Traefik Reverse Proxy",
"description": "Production-ready compose with auto-TLS renewal",
"card_type": "snippet",
"card_data": "{\"language\":\"yaml\",\"source\":\"personal\"}",
"tags": ["homelab", "docker", "traefik"]
},
{
"body": "## Weekly Review\n- [ ] Clear inbox\n- [ ] Review calendar\n- [ ] Update project boards\n- [ ] Plan next week",
"glyph": "note",
"title": "Weekly Review Checklist",
"card_type": "checklist",
"card_data": "{\"items\":4,\"completed\":0}",
"tags": ["productivity", "routine"]
},
{
"body": "PRAGMA journal_mode = WAL;\nPRAGMA busy_timeout = ${timeout_ms:-5000};\nPRAGMA synchronous = ${sync_mode:-NORMAL};",
"glyph": "note",
"title": "SQLite Concurrency",
"description": "Key settings for multi-reader single-writer",
"card_type": "snippet",
"card_data": "{\"language\":\"sql\",\"source\":\"docs\"}",
"tags": ["sqlite", "til"]
},
{
"body": "Decided to use CalVer (YYYY.0M.MICRO) instead of SemVer for nib releases. Rationale: nib is an app not a library, no API stability contract needed.",
"glyph": "note",
"title": "Versioning Strategy",
"card_type": "decision",
"card_data": "{\"status\":\"accepted\",\"date\":\"2026-04-01\"}",
"tags": ["nib", "decisions"]
},
{
"body": "https://github.com/charmbracelet/bubbletea",
"glyph": "note",
"title": "Bubbletea TUI Framework",
"description": "Go TUI framework based on Elm architecture",
"card_type": "link",
"card_data": "{\"url\":\"https://github.com/charmbracelet/bubbletea\",\"domain\":\"github.com\"}",
"tags": ["golang", "tui", "libraries"]
},
{
"body": "Remember to rotate API keys every 90 days",
"glyph": "todo",
"time_anchor": "2026-07-01T00:00:00Z",
"tags": ["security", "homelab"]
},
{
"body": "Interesting idea: build a CLI that converts natural language to nib captures using local LLM",
"glyph": "note",
"tags": ["ideas", "nib", "ai"]
},
{
"body": "Garage door opener warranty expires in August",
"glyph": "event",
"time_anchor": "2026-08-15T00:00:00Z",
"tags": ["home"]
},
{
"body": "Consolidate all docker services to single compose file",
"glyph": "todo",
"tags": ["homelab", "docker"],
"deleted": true
},
{
"body": "## ${project_name}\n- [ ] Create repo at ${git_host}/${org}/${project_name}\n- [ ] Add CI pipeline\n- [ ] Write README\n- [ ] Add LICENSE (${license_type})\n- [ ] First release tag",
"glyph": "note",
"title": "Project Bootstrap",
"description": "Standard checklist for starting new projects",
"card_type": "template",
"card_data": "{\"items\":5}",
"tags": ["productivity", "dev"]
}
]
+1469 -182
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M16 1 L28 16 L20 30 L16 24 L12 30 L4 16 Z" fill="#c8942a"/>
</svg>

After

Width:  |  Height:  |  Size: 139 B

+67
View File
@@ -0,0 +1,67 @@
/* ── Self-hosted fonts ─────────────────────────────── */
/* Satoshi — primary sans */
@font-face {
font-family: 'Satoshi';
src: url('/fonts/Satoshi-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('/fonts/Satoshi-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('/fonts/Satoshi-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('/fonts/Satoshi-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* JetBrains Mono — mono */
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50 -22
View File
@@ -1,31 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nib</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/fonts.css">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<header>
<div class="header-left">
<h1 class="logo">nib</h1>
<span class="logo">nib</span>
<nav>
<button data-view="stream" class="nav-btn active">stream</button>
<button data-view="cards" class="nav-btn">cards</button>
</nav>
</div>
<form id="capture-bar" autocomplete="off">
<input type="text" id="capture-input" placeholder="capture... (n to focus)" spellcheck="false">
</form>
<div class="header-search">
<input type="text" id="search-input" placeholder="? search #tag" spellcheck="false">
</div>
<button class="theme-toggle" id="theme-toggle" title="toggle theme"></button>
</header>
<main>
<aside id="tag-rail"></aside>
<div class="resize-handle" data-panel="rail"></div>
<section id="entity-panel">
<div id="month-nav"></div>
<div id="entity-list"></div>
<div id="capture-bar"></div>
</section>
<div class="resize-handle" data-panel="peek"></div>
<aside id="detail-pane">
<div class="detail-empty">select an entity</div>
</aside>
@@ -36,27 +42,47 @@
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3>promote to card</h3>
<div class="modal-sub" id="promote-sub"></div>
<div class="type-picker">
<button data-type="snippet" class="type-btn">
<span class="type-glyph"></span>
<span>snippet</span>
</button>
<button data-type="template" class="type-btn">
<span class="type-glyph"></span>
<span>template</span>
</button>
<button data-type="checklist" class="type-btn">
<span class="type-glyph"></span>
<span>checklist</span>
</button>
<button data-type="decision" class="type-btn">
<span class="type-glyph"></span>
<span>decision</span>
<div class="type-col">
<div class="type-col-lbl">read</div>
<button data-type="note" class="type-btn">
<span class="type-glyph glyph-note"></span>
<span class="type-name">note</span>
<span class="type-hint">markdown content</span>
</button>
<button data-type="link" class="type-btn">
<span class="type-glyph"></span>
<span>link</span>
<span class="type-glyph glyph-link"></span>
<span class="type-name">link</span>
<span class="type-hint">reference URL</span>
</button>
<button data-type="decision" class="type-btn">
<span class="type-glyph glyph-decision"></span>
<span class="type-name">decision</span>
<span class="type-hint">choice + rationale</span>
</button>
</div>
<div class="type-col">
<div class="type-col-lbl">grab</div>
<button data-type="snippet" class="type-btn">
<span class="type-glyph glyph-snippet"></span>
<span class="type-name">snippet</span>
<span class="type-hint">code, command, text</span>
</button>
</div>
<div class="type-col">
<div class="type-col-lbl">fill</div>
<button data-type="template" class="type-btn">
<span class="type-glyph glyph-template"></span>
<span class="type-name">template</span>
<span class="type-hint">fillable ${slot}s</span>
</button>
<button data-type="checklist" class="type-btn">
<span class="type-glyph glyph-checklist"></span>
<span class="type-name">checklist</span>
<span class="type-hint">step-by-step</span>
</button>
</div>
</div>
<button class="modal-close">esc to cancel</button>
</div>
@@ -71,6 +97,8 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
<script src="/app.js"></script>
</body>
</html>
+1554 -295
View File
File diff suppressed because it is too large Load Diff