Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e66b7d19f6 | |||
| 38db465cc2 | |||
| 7023806e1a | |||
| fa960ec204 | |||
| ad44d35d9b | |||
| 35df7dcb69 | |||
| 694dfe1c89 | |||
| 180757827b | |||
| 3084152695 | |||
| f449562b27 | |||
| 1c5f6836f5 | |||
| ff190e395b | |||
| 0316076bf8 | |||
| a399c4fb15 | |||
| 03e982281c | |||
| 5fd324e4bb | |||
| b456dca4b3 | |||
| 4c3cdc55c6 | |||
| 9ea00c235b | |||
| b580ed46b0 | |||
| ef647aea7a | |||
| 35fe97a166 | |||
| 1f2daf4d0e | |||
| 8bfa9b15ed | |||
| ab07f631a7 | |||
| db3f88508e | |||
| 1c6ba2b34c | |||
| 13cb7b420e | |||
| 5bb6e89523 | |||
| f6602e3595 | |||
| b7dd58bf3e | |||
| 6654907c41 | |||
| 464ff5a8be | |||
| a8ea8f099f | |||
| 97ad71d66b | |||
| 957265f7b4 | |||
| 6802474595 | |||
| f26716a9ee | |||
| 1c95902e2b | |||
| 156ea6ea1c | |||
| dda8426113 | |||
| f4e178e3ee | |||
| fadc6d9a2a | |||
| b2d6603dcf | |||
| 80b8a950a3 |
@@ -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"
|
||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+133
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user