45 Commits

Author SHA1 Message Date
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
34 changed files with 3107 additions and 534 deletions
+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/"
+3 -3
View File
@@ -22,9 +22,6 @@ nib
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Spec (not shipped)
nib-unified-spec.md
# Shell/profile dotfiles (host artifacts) # Shell/profile dotfiles (host artifacts)
.bash_profile .bash_profile
.bashrc .bashrc
@@ -35,3 +32,6 @@ nib-unified-spec.md
.gitmodules .gitmodules
.ripgreprc .ripgreprc
.mcp.json .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.
+156
View File
@@ -0,0 +1,156 @@
# 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
# 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`) |
IDs are prefix-matchable. If `01KRQ4` is unique, that's enough.
## 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
+1
View File
@@ -37,6 +37,7 @@ func runAdd(_ *cobra.Command, args []string) error {
Description: parsed.Description, Description: parsed.Description,
Glyph: db.Glyph(parsed.Glyph), Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags, Tags: parsed.Tags,
Pinned: parsed.Pin,
} }
if parsed.TimeAnchor != nil { if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor e.TimeAnchor = parsed.TimeAnchor
+6 -1
View File
@@ -63,8 +63,13 @@ func runCards(_ *cobra.Command, _ []string) error {
tagStr += " #" + tag tagStr += " #" + tag
} }
label := e.Body
if e.Title != nil {
label = *e.Title
}
fmt.Printf("%s %-40s %-16s %3d× %s\n", fmt.Printf("%s %-40s %-16s %3d× %s\n",
glyph, e.Body, glyph, label,
strings.TrimSpace(tagStr), strings.TrimSpace(tagStr),
e.UseCount, shortID) e.UseCount, shortID)
} }
+139
View File
@@ -0,0 +1,139 @@
package cmd
import (
"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(_ *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(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(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(e); err != nil {
return fmt.Errorf("entity %d: %w", i, err)
}
if entry.CardType != nil {
ct := db.CardType(*entry.CardType)
if err := store.Promote(e.ID, ct, entry.CardData); err != nil {
return fmt.Errorf("promote entity %d: %w", i, err)
}
}
if entry.Deleted {
store.SoftDelete(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)
}
+6 -1
View File
@@ -142,8 +142,13 @@ func printEntity(e *db.Entity) {
glyph := display.DisplayGlyph(e.Glyph, e.CardType) glyph := display.DisplayGlyph(e.Glyph, e.CardType)
shortID := display.FormatID(e.ID) shortID := display.FormatID(e.ID)
label := e.Body
if e.Title != nil {
label = *e.Title
}
var line strings.Builder var line strings.Builder
fmt.Fprintf(&line, "%s %-40s", glyph, e.Body) fmt.Fprintf(&line, "%s %-40s", glyph, label)
if e.TimeAnchor != nil { if e.TimeAnchor != nil {
fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor) fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor)
+4
View File
@@ -16,6 +16,7 @@ type CreateEntityRequest struct {
Glyph *string `json:"glyph"` Glyph *string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"` TimeAnchor *string `json:"time_anchor"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Pinned *bool `json:"pinned"`
CardType *string `json:"card_type"` CardType *string `json:"card_type"`
CardData *string `json:"card_data"` CardData *string `json:"card_data"`
} }
@@ -145,6 +146,9 @@ func createEntity(store *db.Store) http.HandlerFunc {
TimeAnchor: req.TimeAnchor, TimeAnchor: req.TimeAnchor,
Tags: req.Tags, Tags: req.Tags,
} }
if req.Pinned != nil && *req.Pinned {
e.Pinned = true
}
if req.CardType != nil { if req.CardType != nil {
if !db.ValidCardType(*req.CardType) { if !db.ValidCardType(*req.CardType) {
+2 -1
View File
@@ -13,7 +13,8 @@ type TagResponse struct {
func listTags(store *db.Store) http.HandlerFunc { func listTags(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tags, err := store.ListTags() cardsOnly := r.URL.Query().Get("cards_only") == "true"
tags, err := store.ListTags(cardsOnly)
if err != nil { if err != nil {
writeInternalError(w, err) writeInternalError(w, err)
return return
+2 -1
View File
@@ -16,6 +16,7 @@ const (
GlyphNote Glyph = "note" GlyphNote Glyph = "note"
GlyphTodo Glyph = "todo" GlyphTodo Glyph = "todo"
GlyphEvent Glyph = "event" GlyphEvent Glyph = "event"
GlyphReminder Glyph = "reminder"
) )
type CardType string type CardType string
@@ -30,7 +31,7 @@ const (
func ValidGlyph(s string) bool { func ValidGlyph(s string) bool {
switch Glyph(s) { switch Glyph(s) {
case GlyphNote, GlyphTodo, GlyphEvent: case GlyphNote, GlyphTodo, GlyphEvent, GlyphReminder:
return true return true
} }
return false return false
+6 -2
View File
@@ -5,12 +5,16 @@ type TagCount struct {
Count int Count int
} }
func (s *Store) ListTags() ([]TagCount, error) { func (s *Store) ListTags(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.Query(` rows, err := s.db.Query(`
SELECT t.tag, COUNT(*) as cnt SELECT t.tag, COUNT(*) as cnt
FROM entity_tags t FROM entity_tags t
JOIN entities e ON t.entity_id = e.id JOIN entities e ON t.entity_id = e.id
WHERE e.deleted_at IS NULL ` + where + `
GROUP BY t.tag GROUP BY t.tag
ORDER BY t.tag`) ORDER BY t.tag`)
if err != nil { if err != nil {
+37 -3
View File
@@ -4,7 +4,7 @@ import "testing"
func TestListTags_Empty(t *testing.T) { func TestListTags_Empty(t *testing.T) {
s := testStore(t) s := testStore(t)
tags, err := s.ListTags() tags, err := s.ListTags(false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -19,7 +19,7 @@ func TestListTags_Counts(t *testing.T) {
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}}) s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}}) s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
tags, err := s.ListTags() tags, err := s.ListTags(false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -50,7 +50,7 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}}) s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
tags, err := s.ListTags() tags, err := s.ListTags(false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,3 +61,37 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
t.Errorf("expected 'here', got %q", tags[0].Tag) t.Errorf("expected 'here', got %q", tags[0].Tag)
} }
} }
func TestListTags_CardsOnly(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}})
ct := CardSnippet
s.Create(&Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct})
all, err := s.ListTags(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(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"])
}
}
+1
View File
@@ -6,6 +6,7 @@ var glyphMap = map[db.Glyph]string{
db.GlyphNote: "—", db.GlyphNote: "—",
db.GlyphTodo: "○", db.GlyphTodo: "○",
db.GlyphEvent: "◇", db.GlyphEvent: "◇",
db.GlyphReminder: "△",
} }
var cardGlyphMap = map[db.CardType]string{ var cardGlyphMap = map[db.CardType]string{
+143 -53
View File
@@ -13,7 +13,10 @@ type Result struct {
Description *string Description *string
TimeAnchor *string TimeAnchor *string
Tags []string Tags []string
FilterTags []string
CardSuffix *string CardSuffix *string
Pin bool
Query bool
} }
var validCardTypes = map[string]string{ var validCardTypes = map[string]string{
@@ -39,26 +42,70 @@ func Parse(input string) (*Result, error) {
remaining := input remaining := input
if sp := strings.IndexByte(remaining, ' '); sp >= 0 { // Step 1: Escape check — `\` prefix → thought, no prefix detection
switch remaining[:sp] { if strings.HasPrefix(remaining, `\`) {
case "-", "▸": remaining = remaining[1:]
r.Glyph = "todo" r.Glyph = "note"
remaining = strings.TrimSpace(remaining[sp+1:]) clean, err := extractModifiers(r, remaining, false)
case "*", "◇": if err != nil {
r.Glyph = "event" return nil, err
remaining = strings.TrimSpace(remaining[sp+1:])
} }
r.Body = clean
if r.Body == "" && r.Title == nil {
return nil, fmt.Errorf("empty body after extracting modifiers")
}
return r, nil
}
// Step 2: Query check — `?` prefix → search mode
if strings.HasPrefix(remaining, "?") {
remaining = strings.TrimSpace(remaining[1:])
r.Query = true
r.Glyph = ""
tokens := strings.Fields(remaining)
var bodyParts []string
for _, tok := range tokens {
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
tag := strings.ToLower(tok[1:])
r.FilterTags = append(r.FilterTags, tag)
} else { } else {
switch remaining { bodyParts = append(bodyParts, tok)
case "-", "▸": }
}
r.Body = strings.Join(bodyParts, " ")
return r, nil
}
// Step 3: Kind prefix — `-`, `@time`, `!time`
// `@` and `!` are kind prefixes ONLY if followed by a valid time token.
// Otherwise the input is treated as a plain note.
if strings.HasPrefix(remaining, "- ") {
r.Glyph = "todo"
remaining = strings.TrimSpace(remaining[2:])
} else if remaining == "-" {
r.Glyph = "todo" r.Glyph = "todo"
remaining = "" remaining = ""
case "*", "": } else if strings.HasPrefix(remaining, "@") {
if rest, ok := tryPrefixTime(r, remaining[1:]); ok {
r.Glyph = "event" r.Glyph = "event"
remaining = "" remaining = rest
}
} else if strings.HasPrefix(remaining, "!") {
afterBang := remaining[1:]
// `!pin` is a flag, not a reminder prefix
firstWord := ""
if fields := strings.Fields(afterBang); len(fields) > 0 {
firstWord = fields[0]
}
if !strings.EqualFold(firstWord, "pin") {
if rest, ok := tryPrefixTime(r, afterBang); ok {
r.Glyph = "reminder"
remaining = rest
}
} }
} }
// Steps 4-5: Title and description extraction
var titleRaw, descRaw string var titleRaw, descRaw string
hasTitle := false hasTitle := false
@@ -106,46 +153,9 @@ func Parse(input string) (*Result, error) {
} }
} }
seen := map[string]bool{} // Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body
extract := func(text string) (string, error) {
tokens := strings.Fields(text)
var parts []string
for _, tok := range tokens {
switch {
case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:]
if err := validateTime(timeStr); err != nil {
return "", fmt.Errorf("invalid time %q: %w", timeStr, err)
}
if r.TimeAnchor != nil {
return "", fmt.Errorf("multiple time anchors")
}
r.TimeAnchor = &timeStr
case strings.HasPrefix(tok, "#") && len(tok) > 1:
tag := tok[1:]
if !seen[tag] {
r.Tags = append(r.Tags, tag)
seen[tag] = true
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
cardType, ok := validCardTypes[suffix]
if !ok {
return "", fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return "", fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
parts = append(parts, tok)
}
}
return strings.Join(parts, " "), nil
}
if hasTitle { if hasTitle {
clean, err := extract(titleRaw) clean, err := extractModifiers(r, titleRaw, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -154,7 +164,7 @@ func Parse(input string) (*Result, error) {
} }
} }
if descRaw != "" { if descRaw != "" {
clean, err := extract(descRaw) clean, err := extractModifiers(r, descRaw, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -163,7 +173,7 @@ func Parse(input string) (*Result, error) {
} }
} }
clean, err := extract(remaining) clean, err := extractModifiers(r, remaining, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -176,6 +186,86 @@ func Parse(input string) (*Result, error) {
return r, nil return r, nil
} }
// tryPrefixTime attempts to extract a time token from the start of text.
// Returns (remaining text, true) on success, or ("", false) if no valid time found.
func tryPrefixTime(r *Result, text string) (string, bool) {
text = strings.TrimSpace(text)
if text == "" {
return "", false
}
sp := strings.IndexByte(text, ' ')
var timeStr, rest string
if sp >= 0 {
timeStr = text[:sp]
rest = strings.TrimSpace(text[sp+1:])
} else {
timeStr = text
rest = ""
}
if validateTime(timeStr) != nil {
return "", false
}
r.TimeAnchor = &timeStr
return rest, true
}
// extractModifiers extracts tags, flags, time anchors, and card suffixes from text.
// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts).
func extractModifiers(r *Result, text string, handleFlags bool) (string, error) {
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 := strings.ToLower(tok[1:])
if !seen[tag] {
r.Tags = append(r.Tags, tag)
seen[tag] = true
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
cardType, ok := validCardTypes[suffix]
if !ok {
return "", fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return "", fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
lineParts = append(lineParts, tok)
}
}
outLines = append(outLines, strings.Join(lineParts, " "))
}
return strings.Join(outLines, "\n"), nil
}
func validateTime(s string) error { func validateTime(s string) error {
parts := strings.SplitN(s, ":", 2) parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 { if len(parts) != 2 {
+82 -37
View File
@@ -18,56 +18,92 @@ func TestParse(t *testing.T) {
wantTime *string wantTime *string
wantTags []string wantTags []string
wantCard *string wantCard *string
wantPin bool
wantQuery bool
wantFilter []string
wantErrSub string wantErrSub string
}{ }{
// Glyph detection // Kind prefixes
{"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, ""}, {"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""}, {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, false, false, nil, ""},
{"unicode todo", "deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""}, {"dash todo requires space", "-deploy", "-deploy", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""}, {"event prefix", "@14:00 dentist", "dentist", "event", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, nil, ""}, {"event no body", "@9:30", "", "event", nil, nil, sp("9:30"), nil, nil, false, false, nil, "empty body"},
{"reminder prefix", "!15:00 call dentist", "call dentist", "reminder", nil, nil, sp("15:00"), nil, nil, false, false, nil, ""},
{"reminder no body", "!9:30", "", "reminder", nil, nil, sp("9:30"), nil, nil, false, false, nil, "empty body"},
// Time anchor // Event/reminder with invalid time — @ stays as body token, ! stays as body token
{"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, ""}, {"at-sign not time", "@nottime hello", "@nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"time at start", "@9:30 standup", "standup", "note", nil, nil, sp("9:30"), nil, nil, ""}, {"bang not time", "!nottime hello", "!nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"invalid hours", "meeting @25:00", "", "", nil, nil, nil, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, nil, nil, "invalid time"},
// Tags // Escape prefix
{"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""}, {"escape dash", `\- this is not a todo`, "- this is not a todo", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""}, {"escape at", `\@14:00 not event`, "not event", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""}, {"escape plain", `\hello`, "hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""},
// Card suffix // Query mode
{"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""}, {"query basic", "? proxy config", "proxy config", "", nil, nil, nil, nil, nil, false, true, nil, ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""}, {"query with tags", "? proxy config #ops #infra", "proxy config", "", nil, nil, nil, nil, nil, false, true, []string{"ops", "infra"}, ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""}, {"query tags only", "? #ops", "", "", nil, nil, nil, nil, nil, false, true, []string{"ops"}, ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, "invalid card type"}, // Inline time anchor
{"inline time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
{"todo due time", "- buy milk @9:30", "buy milk", "todo", nil, nil, sp("9:30"), nil, nil, false, false, nil, ""},
{"invalid hours stays as body", "meeting @25:00", "meeting @25:00", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"invalid minutes stays as body", "meeting @14:60", "meeting @14:60", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
// Tags (lowercased)
{"single tag", "deploy #Ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"multiple tags", "deploy #ops #Infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, false, false, nil, ""},
{"duplicate tags", "deploy #ops #Ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, false, false, nil, ""},
// Hash escape
{"double hash escape", "use ##channel in slack", "use #channel in slack", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"double hash with tag", "use ##channel #ops", "use #channel", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""},
// Pin flag
{"pin flag", "important thing !pin", "important thing", "note", nil, nil, nil, nil, nil, true, false, nil, ""},
{"pin case insensitive", "important !Pin #work", "important", "note", nil, nil, nil, []string{"work"}, nil, true, false, nil, ""},
{"pin with todo", "- urgent task !pin", "urgent task", "todo", nil, nil, nil, nil, nil, true, false, nil, ""},
// !pin at start — not a reminder, flag is extracted
{"bang pin only", "!pin important", "important", "note", nil, nil, nil, nil, nil, true, false, nil, ""},
// Card suffix (kept for now)
{"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), false, false, nil, ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), false, false, nil, ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), false, false, nil, ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, false, false, nil, "invalid card type"},
// Combined // Combined
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, ""}, {"full todo", "- deploy nginx @15:00 #ops", "deploy nginx", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, false, false, nil, ""},
{"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""}, {"full event", "@14:00 lunch with alex #personal", "lunch with alex", "event", nil, nil, sp("14:00"), []string{"personal"}, nil, false, false, nil, ""},
{"full reminder", "!15:00 call dentist #health", "call dentist", "reminder", nil, nil, sp("15:00"), []string{"health"}, nil, false, false, nil, ""},
// Title // Title
{"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, ""}, {"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, ""}, {"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, ""}, {"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, ""}, {"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, false, false, nil, ""},
{"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, ""}, {"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, false, false, nil, ""},
{"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, ""}, {"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, false, false, nil, ""},
// Description without title // Description without title
{"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, ""}, {"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, false, false, nil, ""},
{"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, ""}, {"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, false, false, nil, ""},
{"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, ""}, {"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
// Multiline body preserves newlines
{"multiline body", "hello\nworld", "hello\nworld", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"multiline with tags", "line one #ops\nline two", "line one\nline two", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"title multiline body", "|my title\nfirst line\nsecond line", "first line\nsecond line", "note", sp("my title"), nil, nil, nil, nil, false, false, nil, ""},
// Edge cases // Edge cases
{"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"}, {"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"}, {"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, "empty body"}, {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
{"whitespace only", " ", "", "", nil, nil, nil, nil, nil, "empty"}, {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -109,6 +145,15 @@ func TestParse(t *testing.T) {
if !ptrEq(got.CardSuffix, tt.wantCard) { if !ptrEq(got.CardSuffix, tt.wantCard) {
t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(tt.wantCard)) t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(tt.wantCard))
} }
if got.Pin != tt.wantPin {
t.Errorf("pin: got %v, want %v", got.Pin, tt.wantPin)
}
if got.Query != tt.wantQuery {
t.Errorf("query: got %v, want %v", got.Query, tt.wantQuery)
}
if !tagsEq(got.FilterTags, tt.wantFilter) {
t.Errorf("filter_tags: got %v, want %v", got.FilterTags, tt.wantFilter)
}
}) })
} }
} }
+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"]
}
]
+1160 -193
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.
+26 -23
View File
@@ -4,38 +4,34 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>nib</title> <title>nib</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="/fonts.css">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<style>
@font-face { font-family: 'Monaspace Neon'; font-weight: 300; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Light.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 400; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 500; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 700; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Bold.woff2') format('woff2'); }
</style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<header> <header>
<div class="header-left"> <div class="header-left">
<h1 class="logo">nib</h1> <span class="logo">nib</span>
<nav> <nav>
<button data-view="stream" class="nav-btn active">stream</button> <button data-view="stream" class="nav-btn active">stream</button>
<button data-view="cards" class="nav-btn">cards</button> <button data-view="cards" class="nav-btn">cards</button>
</nav> </nav>
</div> </div>
<form id="capture-bar" autocomplete="off"> <div class="header-search">
<input type="text" id="capture-input" placeholder="capture — - todo # note * event" spellcheck="false"> <input type="text" id="search-input" placeholder="? search #tag" spellcheck="false">
</form> </div>
<button class="theme-toggle" id="theme-toggle" title="toggle theme"></button> <button class="theme-toggle" id="theme-toggle" title="toggle theme"></button>
</header> </header>
<main> <main>
<aside id="tag-rail"></aside> <aside id="tag-rail"></aside>
<div class="resize-handle" data-panel="rail"></div>
<section id="entity-panel"> <section id="entity-panel">
<div id="month-nav"></div> <div id="month-nav"></div>
<div id="entity-list"></div> <div id="entity-list"></div>
<div id="capture-bar"></div>
</section> </section>
<div class="resize-handle" data-panel="peek"></div>
<aside id="detail-pane"> <aside id="detail-pane">
<div class="detail-empty">select an entity</div> <div class="detail-empty">select an entity</div>
</aside> </aside>
@@ -46,26 +42,32 @@
<div class="modal-backdrop"></div> <div class="modal-backdrop"></div>
<div class="modal-content"> <div class="modal-content">
<h3>promote to card</h3> <h3>promote to card</h3>
<div class="modal-sub" id="promote-sub"></div>
<div class="type-picker"> <div class="type-picker">
<button data-type="snippet" class="type-btn"> <button data-type="snippet" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-snippet"></span>
<span>snippet</span> <span class="type-name">snippet</span>
<span class="type-hint">quick reference, command, code</span>
</button> </button>
<button data-type="template" class="type-btn"> <button data-type="template" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-template"></span>
<span>template</span> <span class="type-name">template</span>
<span class="type-hint">fillable with ${slot}s</span>
</button> </button>
<button data-type="checklist" class="type-btn"> <button data-type="checklist" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-checklist"></span>
<span>checklist</span> <span class="type-name">checklist</span>
<span class="type-hint">step-by-step process</span>
</button> </button>
<button data-type="decision" class="type-btn"> <button data-type="decision" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-decision"></span>
<span>decision</span> <span class="type-name">decision</span>
<span class="type-hint">record a choice + rationale</span>
</button> </button>
<button data-type="link" class="type-btn"> <button data-type="link" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-link"></span>
<span>link</span> <span class="type-name">link</span>
<span class="type-hint">reference URL</span>
</button> </button>
</div> </div>
<button class="modal-close">esc to cancel</button> <button class="modal-close">esc to cancel</button>
@@ -81,6 +83,7 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
+1020 -185
View File
File diff suppressed because it is too large Load Diff