Compare commits
114 Commits
f5b46585c3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8426c2fbc1 | |||
| 1e58433936 | |||
| d24df8432f | |||
| e22e040688 | |||
| 29bd7d3dc6 | |||
| a9da5c1765 | |||
| b9b3f99be9 | |||
| cae651302a | |||
| 8fc686ec6d | |||
| 564039112a | |||
| eea59b3f3c | |||
| ceb29fdd7b | |||
| 2152baeb4f | |||
| 33f6d99ba7 | |||
| d715b053e7 | |||
| 50b80f4407 | |||
| 8663beeb96 | |||
| 1ac4196547 | |||
| a96c1a52f4 | |||
| db1dc135d2 | |||
| 7d1e0f895c | |||
| 82bc6e7ba1 | |||
| 533e086ffb | |||
| 989aa86679 | |||
| 3eb778f31b | |||
| 98fdae1e3a | |||
| a567b2ce73 | |||
| 388ae88d4a | |||
| 60705463c1 | |||
| b5b7f6b6ee | |||
| 3f57531995 | |||
| a2dac64d1f | |||
| 3daa5a2e11 | |||
| c26e2d2022 | |||
| cb10d1e93d | |||
| e20fae3543 | |||
| 4e0ac8402f | |||
| e2d0f3e997 | |||
| 618335513b | |||
| 476abbed00 | |||
| 39975a6787 | |||
| 778fab3edd | |||
| a141b2fd4f | |||
| f89ca8acb9 | |||
| e09919b679 | |||
| babf1d6620 | |||
| 77222ff1b8 | |||
| 1066c0bc7d | |||
| ce335cabd6 | |||
| c2ea63dd16 | |||
| 36999cd825 | |||
| d995d1e708 | |||
| dd8878ebcf | |||
| 805467486b | |||
| 4980714583 | |||
| 6d8170d219 | |||
| 73c6a315c1 | |||
| d5fa6cc56b | |||
| 8555d0da19 | |||
| ec907d0e0d | |||
| a854f02854 | |||
| 824192f581 | |||
| c2506ef7fd | |||
| 2b177eeae9 | |||
| 840084fbb0 | |||
| 4ec876b2d2 | |||
| 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 | |||
| c8e18f0bc1 | |||
| e477e8d512 | |||
| 6278cb1022 |
@@ -0,0 +1,18 @@
|
|||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
bin = "./tmp/nib"
|
||||||
|
cmd = "go build -o ./tmp/nib ."
|
||||||
|
args_bin = ["serve"]
|
||||||
|
delay = 500
|
||||||
|
exclude_dir = ["tmp", "testdata", "docs"]
|
||||||
|
exclude_regex = ["_test\\.go$"]
|
||||||
|
include_ext = ["go", "html", "css", "js", "svg"]
|
||||||
|
kill_delay = 500
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
@@ -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/"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: sh
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: |
|
||||||
|
diff=$(gofmt -l .)
|
||||||
|
if [ -n "$diff" ]; then
|
||||||
|
echo "Files need formatting:"
|
||||||
|
echo "$diff"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -count=1 ./...
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -trimpath -o nib .
|
||||||
+5
-3
@@ -1,5 +1,7 @@
|
|||||||
# Binary
|
# Binary
|
||||||
nib
|
nib
|
||||||
|
tmp/
|
||||||
|
certs/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
@@ -22,9 +24,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 +34,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,71 @@
|
|||||||
|
BINARY := nib
|
||||||
|
MODULE := github.com/lerko/nib
|
||||||
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
|
.PHONY: build dev tui watch test lint fmt vet clean run cert help
|
||||||
|
|
||||||
|
## —— Build ——————————————————————————————————
|
||||||
|
|
||||||
|
build: ## Build production binary
|
||||||
|
go build $(GOFLAGS) -o $(BINARY) .
|
||||||
|
|
||||||
|
dev: ## Build and run with default serve
|
||||||
|
go run . serve
|
||||||
|
|
||||||
|
tui: ## Launch the terminal UI
|
||||||
|
go run . tui
|
||||||
|
|
||||||
|
watch: ## Live-reload dev server (requires air)
|
||||||
|
air
|
||||||
|
|
||||||
|
## —— Quality ————————————————————————————————
|
||||||
|
|
||||||
|
test: ## Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
test-v: ## Run all tests (verbose)
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
test-cover: ## Run tests with coverage report
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -func=coverage.out
|
||||||
|
@rm -f coverage.out
|
||||||
|
|
||||||
|
lint: vet fmt-check ## Run all linters
|
||||||
|
|
||||||
|
vet: ## Run go vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt: ## Format all Go files
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
fmt-check: ## Check formatting (fails if unformatted)
|
||||||
|
@test -z "$$(gofmt -l .)" || (echo "unformatted files:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
|
## —— Utility ————————————————————————————————
|
||||||
|
|
||||||
|
cert: ## Generate self-signed dev TLS cert (certs/)
|
||||||
|
@mkdir -p certs
|
||||||
|
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||||
|
-nodes -keyout certs/dev.key -out certs/dev.crt -days 365 \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
@echo " certs/dev.crt and certs/dev.key ready"
|
||||||
|
@echo " usage: make run ARGS=\"serve --tls-cert certs/dev.crt --tls-key certs/dev.key\""
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts
|
||||||
|
rm -f $(BINARY) coverage.out
|
||||||
|
|
||||||
|
run: build ## Build then run with args (usage: make run ARGS="serve")
|
||||||
|
./$(BINARY) $(ARGS)
|
||||||
|
|
||||||
|
tidy: ## Tidy and verify module dependencies
|
||||||
|
go mod tidy
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
## —— Help ———————————————————————————————————
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# nib
|
||||||
|
|
||||||
|
Capture-first note system. Catch thoughts, todos, and events before they slip — from terminal or browser. Everything lives in a single SQLite file.
|
||||||
|
|
||||||
|
nib uses a tiny grammar to extract structure from plain text. You type naturally, and nib figures out what it is: a thought, a todo with a due date, an event, a titled reference. Tags, descriptions, and time anchors are pulled out automatically. The body stays as markdown.
|
||||||
|
|
||||||
|
When something proves useful, promote it to a card (snippet, template, checklist, decision, link) for quick reuse. Cards track usage and float to the top.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Requires Go 1.24+.
|
||||||
|
|
||||||
|
```
|
||||||
|
go build -o nib .
|
||||||
|
```
|
||||||
|
|
||||||
|
Data lives at `~/.nib/nib.db` by default. Override with `NIB_DB=/path/to/file.db`.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# capture a thought
|
||||||
|
nib "proxy_pass needs a trailing slash"
|
||||||
|
|
||||||
|
# todo
|
||||||
|
nib "- buy mass gainer @tomorrow #errands"
|
||||||
|
|
||||||
|
# titled entry with description
|
||||||
|
nib "|nginx proxy trick // always forget this #ops"
|
||||||
|
|
||||||
|
# todo with a title
|
||||||
|
nib "- |deploy staging // rebuild docker image #ops"
|
||||||
|
|
||||||
|
# list recent entries
|
||||||
|
nib ls
|
||||||
|
|
||||||
|
# list by tag
|
||||||
|
nib ls --tag ops
|
||||||
|
|
||||||
|
# list a specific month
|
||||||
|
nib ls --month 2026-05
|
||||||
|
|
||||||
|
# terminal UI
|
||||||
|
nib tui
|
||||||
|
|
||||||
|
# start the web UI
|
||||||
|
nib serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:4444` for the browser interface.
|
||||||
|
|
||||||
|
## Grammar
|
||||||
|
|
||||||
|
The full grammar fits on an index card. Here's what matters:
|
||||||
|
|
||||||
|
### Kind prefixes
|
||||||
|
|
||||||
|
The first character decides what kind of entry you're creating.
|
||||||
|
|
||||||
|
| Input | Kind | Example |
|
||||||
|
|-------|------|---------|
|
||||||
|
| bare text | thought | `just an idea` |
|
||||||
|
| `- text` | todo | `- buy milk @tomorrow` |
|
||||||
|
| `@time text` | event | `@friday 2pm lunch with alex` |
|
||||||
|
| `!time text` | reminder | `!3pm call dentist` |
|
||||||
|
|
||||||
|
The dash needs a space after it. `-deploy` is a thought, `- deploy` is a todo.
|
||||||
|
|
||||||
|
### Titles and descriptions
|
||||||
|
|
||||||
|
Give an entry a name with `|` at the start. Add a description with `//`.
|
||||||
|
|
||||||
|
```
|
||||||
|
|nginx proxy trick
|
||||||
|
proxy_pass http://backend/;
|
||||||
|
|
||||||
|
|deploy checklist // for staging #ops
|
||||||
|
1. docker build
|
||||||
|
2. docker push
|
||||||
|
3. ssh prod && restart
|
||||||
|
|
||||||
|
// quick reference for proxy config
|
||||||
|
the actual body text goes here
|
||||||
|
|
||||||
|
body text // this part becomes the description
|
||||||
|
```
|
||||||
|
|
||||||
|
Title shows as the scan label in list view. Description appears in the detail pane. Both are optional — most captures won't need them.
|
||||||
|
|
||||||
|
### Tags and flags
|
||||||
|
|
||||||
|
Tags and flags work anywhere in the input. They're extracted and removed from the body.
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy nginx #ops #infra → tags: ops, infra
|
||||||
|
important thing !pin → pinned to top
|
||||||
|
use ##channel in slack → literal #channel in body (escaped)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
Promote a fluid entry to a card for reuse:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nib promote <id> snippet # code trick, copy-to-clipboard
|
||||||
|
nib promote <id> template # has ${slots} to fill
|
||||||
|
nib promote <id> checklist # step-through items
|
||||||
|
nib promote <id> decision # chose/why/rejected
|
||||||
|
nib promote <id> link # URL with an open button
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `^type` inline: `nib "proxy trick #nginx ^card"`
|
||||||
|
|
||||||
|
## CLI commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `nib <input>` | Capture (shorthand for `nib add`) |
|
||||||
|
| `nib ls` | List entries — filter with `--tag`, `--date`, `--month`, `--from`/`--to` |
|
||||||
|
| `nib cards` | List cards sorted by usage |
|
||||||
|
| `nib edit <id>` | Open in `$EDITOR` |
|
||||||
|
| `nib copy <id>` | Copy body to clipboard |
|
||||||
|
| `nib promote <id> [type]` | Promote to card |
|
||||||
|
| `nib demote <id>` | Strip card, back to fluid |
|
||||||
|
| `nib absorb <target> <source>` | Merge source into target |
|
||||||
|
| `nib delete <id>` | Soft delete (repeat to hard delete) |
|
||||||
|
| `nib serve` | Start web UI on `:4444` (or `--port`) |
|
||||||
|
| `nib tui` | Launch the terminal UI |
|
||||||
|
|
||||||
|
IDs are prefix-matchable. If `01KRQ4` is unique, that's enough.
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
`nib tui` launches a keyboard-driven interface in your terminal.
|
||||||
|
|
||||||
|
- **Stream view** — entries grouped by date with compact gutter headers
|
||||||
|
- **Cards view** — promoted cards filtered by intent (grab/read/fill), sorted by usage
|
||||||
|
- **Split-pane detail** — wide terminals (100+ cols) show list and detail side-by-side
|
||||||
|
- **Capture drawer** — inline add with live preview of parsed entity
|
||||||
|
- **Search** — type `?query #tag` in the capture bar to filter
|
||||||
|
|
||||||
|
Navigation: `j`/`k` move, `enter` opens detail, `h`/`l` switch panes, `a` to add, `?` for full keybindings.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
`nib serve` starts a local web interface with:
|
||||||
|
|
||||||
|
- **Capture bar** — same grammar as the CLI
|
||||||
|
- **Stream view** — entries grouped by date, newest first
|
||||||
|
- **Cards view** — promoted cards sorted by use count
|
||||||
|
- **Tag rail** — filter by tag
|
||||||
|
- **Month navigator** — browse by date range
|
||||||
|
- **Detail pane** — full entry view, double-click to edit
|
||||||
|
- **Keyboard shortcuts** — `j`/`k` navigate, `n` to capture, `p` to promote, `e` to edit, `dd` to delete
|
||||||
|
|
||||||
|
Dark and light themes. Toggle with the button in the header.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
Everything is one SQLite file. Back it up, sync it, move it between machines — it's just a file. WAL mode is on for concurrent reads.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# back up
|
||||||
|
cp ~/.nib/nib.db ~/.nib/nib.db.bak
|
||||||
|
|
||||||
|
# use a different database
|
||||||
|
NIB_DB=/path/to/other.db nib ls
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Code Hardening — Senior Dev Audit Fixes
|
||||||
|
|
||||||
|
## Phase 1: Quick Wins (safety + correctness)
|
||||||
|
- [x] Cap API list limit at 200
|
||||||
|
- [x] Fix markdown XSS — add DOMPurify to sanitize marked output
|
||||||
|
- [x] Add missing DB indexes (deleted_at, modified_at) via v4 migration
|
||||||
|
- [x] Fix v2 migration error handling (swallowed ALTER TABLE errors)
|
||||||
|
- [x] Fix ~/.nib directory permissions (0o755 → 0o700)
|
||||||
|
|
||||||
|
## Phase 2: CI Pipeline
|
||||||
|
- [x] Gitea Actions workflow: test + lint on PR
|
||||||
|
|
||||||
|
## Phase 3: context.Context in Store
|
||||||
|
- [x] Thread context.Context through all Store methods
|
||||||
|
- [x] Use context in API handlers (from r.Context())
|
||||||
|
- [x] Use context in CLI commands (cobra context)
|
||||||
|
|
||||||
|
## Phase 4: cmd/ Tests
|
||||||
|
- [x] Test add command
|
||||||
|
- [x] Test ls command
|
||||||
|
- [x] Test promote/demote commands
|
||||||
|
- [x] Test delete command
|
||||||
|
- [x] Test absorb command
|
||||||
|
|
||||||
|
## Phase 5: Backup/Export
|
||||||
|
- [x] nib export — dump entities to JSON
|
||||||
|
- [x] nib backup — safe SQLite backup (handles WAL)
|
||||||
+4
-4
@@ -19,19 +19,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(absorbCmd)
|
rootCmd.AddCommand(absorbCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAbsorb(_ *cobra.Command, args []string) error {
|
func runAbsorb(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
targetID, err := store.Resolve(args[0])
|
targetID, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID, err := store.Resolve(args[1])
|
sourceID, err := store.Resolve(cmd.Context(), args[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[1])
|
return fmt.Errorf("not_found — no entity with id %s", args[1])
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func runAbsorb(_ *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("target and source must be different entities")
|
return fmt.Errorf("target and source must be different entities")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Absorb(targetID, sourceID); err != nil {
|
if err := store.Absorb(cmd.Context(), targetID, sourceID); err != nil {
|
||||||
if err == db.ErrTargetCrystallized {
|
if err == db.ErrTargetCrystallized {
|
||||||
return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first",
|
return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first",
|
||||||
display.FormatID(targetID))
|
display.FormatID(targetID))
|
||||||
|
|||||||
+3
-2
@@ -17,7 +17,7 @@ var addCmd = &cobra.Command{
|
|||||||
RunE: runAdd,
|
RunE: runAdd,
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAdd(_ *cobra.Command, args []string) error {
|
func runAdd(cmd *cobra.Command, args []string) error {
|
||||||
input := strings.Join(args, " ")
|
input := strings.Join(args, " ")
|
||||||
|
|
||||||
parsed, err := parse.Parse(input)
|
parsed, err := parse.Parse(input)
|
||||||
@@ -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
|
||||||
@@ -46,7 +47,7 @@ func runAdd(_ *cobra.Command, args []string) error {
|
|||||||
e.CardType = &ct
|
e.CardType = &ct
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Create(e); err != nil {
|
if err := store.Create(cmd.Context(), e); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var backupCmd = &cobra.Command{
|
||||||
|
Use: "backup [path]",
|
||||||
|
Short: "create a safe backup of the database",
|
||||||
|
Long: "Creates an atomic backup using VACUUM INTO. Safe with WAL mode — no need to stop the server.",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: runBackup,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(backupCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBackup(cmd *cobra.Command, args []string) error {
|
||||||
|
srcPath, err := db.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405"))
|
||||||
|
if len(args) > 0 {
|
||||||
|
dst = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := db.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.Backup(dst); err != nil {
|
||||||
|
return fmt.Errorf("backup failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("backed up to %s\n", dst)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+8
-3
@@ -26,7 +26,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(cardsCmd)
|
rootCmd.AddCommand(cardsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCards(_ *cobra.Command, _ []string) error {
|
func runCards(cmd *cobra.Command, _ []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -49,7 +49,7 @@ func runCards(_ *cobra.Command, _ []string) error {
|
|||||||
p.CardTypeFilter = &ct
|
p.CardTypeFilter = &ct
|
||||||
}
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
entities, err := store.List(cmd.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testStore(t *testing.T) *db.Store {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
t.Setenv("NIB_DB", dbPath)
|
||||||
|
store, err := db.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{}
|
||||||
|
c.SetContext(context.Background())
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureOutput(t *testing.T, fn func()) string {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
defer rootCmd.SetOut(nil)
|
||||||
|
fn()
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedEntity(t *testing.T, store *db.Store, body string, glyph db.Glyph) *db.Entity {
|
||||||
|
t.Helper()
|
||||||
|
e := &db.Entity{Body: body, Glyph: glyph, Tags: []string{}}
|
||||||
|
if err := store.Create(context.Background(), e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAdd(t *testing.T) {
|
||||||
|
testStore(t)
|
||||||
|
|
||||||
|
err := runAdd(newCmd(), []string{"hello", "world"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runAdd: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAddWithGlyph(t *testing.T) {
|
||||||
|
testStore(t)
|
||||||
|
|
||||||
|
err := runAdd(newCmd(), []string{"-", "buy", "milk", "#errands"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runAdd todo: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAddWithTimeAnchor(t *testing.T) {
|
||||||
|
testStore(t)
|
||||||
|
|
||||||
|
err := runAdd(newCmd(), []string{"@", "dentist", "@14:00"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runAdd event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDelete(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
e := seedEntity(t, store, "to delete", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runDelete(newCmd(), []string{e.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runDelete soft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = runDelete(newCmd(), []string{e.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runDelete hard: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDeleteNotFound(t *testing.T) {
|
||||||
|
testStore(t)
|
||||||
|
|
||||||
|
err := runDelete(newCmd(), []string{"nonexistent"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent id")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not_found") {
|
||||||
|
t.Fatalf("expected not_found error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPromote(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
e := seedEntity(t, store, "reusable snippet", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runPromote(newCmd(), []string{e.ID, "snippet"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runPromote: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPromoteAlreadyPromoted(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
e := seedEntity(t, store, "already a card", db.GlyphNote)
|
||||||
|
store.Promote(context.Background(), e.ID, db.CardSnippet, nil)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runPromote(newCmd(), []string{e.ID, "snippet"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for already promoted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDemote(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
e := seedEntity(t, store, "demote me", db.GlyphNote)
|
||||||
|
store.Promote(context.Background(), e.ID, db.CardSnippet, nil)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runDemote(newCmd(), []string{e.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runDemote: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDemoteAlreadyFluid(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
e := seedEntity(t, store, "already fluid", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runDemote(newCmd(), []string{e.ID})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for already fluid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAbsorb(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
target := seedEntity(t, store, "target body", db.GlyphNote)
|
||||||
|
source := seedEntity(t, store, "source body", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runAbsorb(newCmd(), []string{target.ID, source.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runAbsorb: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAbsorbSameEntity(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
e := seedEntity(t, store, "same entity", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runAbsorb(newCmd(), []string{e.ID, e.ID})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for same entity absorb")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAbsorbCrystallizedTarget(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
target := seedEntity(t, store, "crystallized", db.GlyphNote)
|
||||||
|
source := seedEntity(t, store, "source", db.GlyphNote)
|
||||||
|
store.Promote(context.Background(), target.ID, db.CardSnippet, nil)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
err := runAbsorb(newCmd(), []string{target.ID, source.ID})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for crystallized target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunLs(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
seedEntity(t, store, "recent note", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
lsTag = ""
|
||||||
|
lsDate = ""
|
||||||
|
lsMonth = ""
|
||||||
|
lsFrom = ""
|
||||||
|
lsTo = ""
|
||||||
|
lsLimit = 0
|
||||||
|
lsAll = false
|
||||||
|
|
||||||
|
err := runLs(newCmd(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runLs: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunLsEmpty(t *testing.T) {
|
||||||
|
testStore(t)
|
||||||
|
|
||||||
|
lsTag = ""
|
||||||
|
lsDate = ""
|
||||||
|
lsMonth = ""
|
||||||
|
lsFrom = ""
|
||||||
|
lsTo = ""
|
||||||
|
lsLimit = 0
|
||||||
|
lsAll = false
|
||||||
|
|
||||||
|
err := runLs(newCmd(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runLs empty: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunExport(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
seedEntity(t, store, "export me", db.GlyphNote)
|
||||||
|
seedEntity(t, store, "export me too", db.GlyphTodo)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
outFile := filepath.Join(t.TempDir(), "export.json")
|
||||||
|
exportOutput = outFile
|
||||||
|
|
||||||
|
err := runExport(newCmd(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runExport: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(outFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read export: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities []exportEntity
|
||||||
|
if err := json.Unmarshal(data, &entities); err != nil {
|
||||||
|
t.Fatalf("unmarshal export: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entities) != 2 {
|
||||||
|
t.Fatalf("expected 2 entities, got %d", len(entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBackup(t *testing.T) {
|
||||||
|
store := testStore(t)
|
||||||
|
seedEntity(t, store, "backup me", db.GlyphNote)
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "backup.db")
|
||||||
|
err := runBackup(newCmd(), []string{dst})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runBackup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup file missing: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
t.Fatal("backup file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
backed, err := db.Open(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open backup: %v", err)
|
||||||
|
}
|
||||||
|
defer backed.Close()
|
||||||
|
|
||||||
|
entities, err := backed.List(context.Background(), db.DefaultListParams())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list backup: %v", err)
|
||||||
|
}
|
||||||
|
if len(entities) != 1 {
|
||||||
|
t.Fatalf("expected 1 entity in backup, got %d", len(entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -19,19 +19,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(copyCmd)
|
rootCmd.AddCommand(copyCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCopy(_ *cobra.Command, args []string) error {
|
func runCopy(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(cmd.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func runCopy(_ *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("clipboard: %w", err)
|
return fmt.Errorf("clipboard: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.IncrementUse(id); err != nil {
|
if err := store.IncrementUse(cmd.Context(), id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -19,19 +19,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(deleteCmd)
|
rootCmd.AddCommand(deleteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDelete(_ *cobra.Command, args []string) error {
|
func runDelete(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := store.SoftDelete(id)
|
result, err := store.SoftDelete(cmd.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var demoCmd = &cobra.Command{
|
||||||
|
Use: "demo",
|
||||||
|
Short: "start server with pre-populated demo data",
|
||||||
|
RunE: runDemo,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(demoCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type demoEntity struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Glyph string `json:"glyph"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
TimeAnchor *string `json:"time_anchor,omitempty"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
CardType *string `json:"card_type,omitempty"`
|
||||||
|
CardData *string `json:"card_data,omitempty"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDemo(cmd *cobra.Command, _ []string) error {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "nib-demo-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(tmpDir, "demo.db")
|
||||||
|
fmt.Printf("demo db: %s\n", dbPath)
|
||||||
|
|
||||||
|
store, err := db.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedDemo(cmd.Context(), store); err != nil {
|
||||||
|
store.Close()
|
||||||
|
return fmt.Errorf("seed demo data: %w", err)
|
||||||
|
}
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
os.Setenv("NIB_DB", dbPath)
|
||||||
|
return runServe(nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDemo(ctx context.Context, store *db.Store) error {
|
||||||
|
data, err := findDemoFile()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []demoEntity
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
return fmt.Errorf("parse demo.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for i, entry := range entries {
|
||||||
|
e := &db.Entity{
|
||||||
|
Body: entry.Body,
|
||||||
|
Glyph: db.Glyph(entry.Glyph),
|
||||||
|
Tags: entry.Tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Title != nil {
|
||||||
|
e.Title = entry.Title
|
||||||
|
}
|
||||||
|
if entry.Description != nil {
|
||||||
|
e.Description = entry.Description
|
||||||
|
}
|
||||||
|
if entry.TimeAnchor != nil {
|
||||||
|
e.TimeAnchor = entry.TimeAnchor
|
||||||
|
}
|
||||||
|
if entry.Pinned {
|
||||||
|
e.Pinned = true
|
||||||
|
}
|
||||||
|
if entry.Completed {
|
||||||
|
t := now.Add(-time.Duration(i) * time.Hour)
|
||||||
|
e.CompletedAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Create(ctx, e); err != nil {
|
||||||
|
return fmt.Errorf("entity %d: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.CardType != nil {
|
||||||
|
ct := db.CardType(*entry.CardType)
|
||||||
|
if err := store.Promote(ctx, e.ID, ct, entry.CardData); err != nil {
|
||||||
|
return fmt.Errorf("promote entity %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Deleted {
|
||||||
|
store.SoftDelete(ctx, e.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("seeded %d entities\n", len(entries))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDemoFile() ([]byte, error) {
|
||||||
|
candidates := []string{
|
||||||
|
"testdata/demo.json",
|
||||||
|
filepath.Join(execDir(), "testdata", "demo.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range candidates {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("demo.json not found (looked in: %v)", candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return filepath.Dir(exe)
|
||||||
|
}
|
||||||
+3
-3
@@ -19,19 +19,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(demoteCmd)
|
rootCmd.AddCommand(demoteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDemote(_ *cobra.Command, args []string) error {
|
func runDemote(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Demote(id); err != nil {
|
if err := store.Demote(cmd.Context(), id); err != nil {
|
||||||
if err == db.ErrAlreadyFluid {
|
if err == db.ErrAlreadyFluid {
|
||||||
return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id))
|
return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id))
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -21,19 +21,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(editCmd)
|
rootCmd.AddCommand(editCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runEdit(_ *cobra.Command, args []string) error {
|
func runEdit(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(cmd.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -55,11 +55,11 @@ func runEdit(_ *cobra.Command, args []string) error {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(editor, tmpfile.Name())
|
editorCmd := exec.Command(editor, tmpfile.Name())
|
||||||
cmd.Stdin = os.Stdin
|
editorCmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
editorCmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
editorCmd.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := editorCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("editor: %w", err)
|
return fmt.Errorf("editor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func runEdit(_ *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Update(id, &db.EntityUpdate{Body: &body}); err != nil {
|
if err := store.Update(cmd.Context(), id, &db.EntityUpdate{Body: &body}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/export"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
exportOutput string
|
||||||
|
exportFormat string
|
||||||
|
exportTag string
|
||||||
|
exportTitle string
|
||||||
|
)
|
||||||
|
|
||||||
|
var exportCmd = &cobra.Command{
|
||||||
|
Use: "export",
|
||||||
|
Short: "export entities to JSON or HTML card deck",
|
||||||
|
RunE: runExport,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout")
|
||||||
|
exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "json", "output format: json or html")
|
||||||
|
exportCmd.Flags().StringVarP(&exportTag, "tag", "t", "", "filter by tag (used as deck name for HTML)")
|
||||||
|
exportCmd.Flags().StringVar(&exportTitle, "title", "", "deck title for HTML export")
|
||||||
|
rootCmd.AddCommand(exportCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type exportEntity struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ModifiedAt string `json:"modified_at"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Glyph string `json:"glyph"`
|
||||||
|
TimeAnchor *string `json:"time_anchor,omitempty"`
|
||||||
|
CompletedAt *string `json:"completed_at,omitempty"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
CardType *string `json:"card_type,omitempty"`
|
||||||
|
CardData *string `json:"card_data,omitempty"`
|
||||||
|
UseCount int `json:"use_count"`
|
||||||
|
LastUsedAt *string `json:"last_used_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExport(cmd *cobra.Command, _ []string) error {
|
||||||
|
store, err := openStore()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
p := db.DefaultListParams()
|
||||||
|
p.Limit = 10000
|
||||||
|
|
||||||
|
if exportTag != "" {
|
||||||
|
p.Tag = &exportTag
|
||||||
|
}
|
||||||
|
|
||||||
|
switch exportFormat {
|
||||||
|
case "html":
|
||||||
|
return runHTMLExport(cmd, store, p)
|
||||||
|
case "json":
|
||||||
|
p.IncludeDeleted = true
|
||||||
|
return runJSONExport(cmd, store, p)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown format %q (use json or html)", exportFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHTMLExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
|
||||||
|
p.CardsOnly = true
|
||||||
|
|
||||||
|
entities, err := store.List(cmd.Context(), p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
title := exportTitle
|
||||||
|
if title == "" && exportTag != "" {
|
||||||
|
title = "#" + exportTag
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "nib cards"
|
||||||
|
}
|
||||||
|
|
||||||
|
if exportOutput != "" {
|
||||||
|
f, err := os.Create(exportOutput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := export.RenderHTML(f, entities, title); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d cards to %s\n", len(entities), exportOutput)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return export.RenderHTML(cmd.OutOrStdout(), entities, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runJSONExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
|
||||||
|
entities, err := store.List(cmd.Context(), p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]exportEntity, len(entities))
|
||||||
|
for i, e := range entities {
|
||||||
|
out[i] = exportEntity{
|
||||||
|
ID: e.ID,
|
||||||
|
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
ModifiedAt: e.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
Body: e.Body,
|
||||||
|
Title: e.Title,
|
||||||
|
Glyph: string(e.Glyph),
|
||||||
|
TimeAnchor: e.TimeAnchor,
|
||||||
|
Pinned: e.Pinned,
|
||||||
|
Tags: e.Tags,
|
||||||
|
CardData: e.CardData,
|
||||||
|
UseCount: e.UseCount,
|
||||||
|
}
|
||||||
|
if e.Description != nil {
|
||||||
|
out[i].Description = e.Description
|
||||||
|
}
|
||||||
|
if e.CompletedAt != nil {
|
||||||
|
s := e.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
out[i].CompletedAt = &s
|
||||||
|
}
|
||||||
|
if e.DeletedAt != nil {
|
||||||
|
s := e.DeletedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
out[i].DeletedAt = &s
|
||||||
|
}
|
||||||
|
if e.CardType != nil {
|
||||||
|
s := string(*e.CardType)
|
||||||
|
out[i].CardType = &s
|
||||||
|
}
|
||||||
|
if e.LastUsedAt != nil {
|
||||||
|
s := e.LastUsedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
out[i].LastUsedAt = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exportOutput != "" {
|
||||||
|
if err := os.WriteFile(exportOutput, data, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d entities to %s\n", len(out), exportOutput)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ func init() {
|
|||||||
lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
|
lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLs(_ *cobra.Command, _ []string) error {
|
func runLs(cmd *cobra.Command, _ []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -88,7 +88,7 @@ func runLs(_ *cobra.Command, _ []string) error {
|
|||||||
p.Since = &since
|
p.Since = &since
|
||||||
}
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
entities, err := store.List(cmd.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+6
-79
@@ -1,11 +1,9 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
"github.com/lerko/nib/internal/display"
|
"github.com/lerko/nib/internal/display"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -22,14 +20,14 @@ func init() {
|
|||||||
rootCmd.AddCommand(promoteCmd)
|
rootCmd.AddCommand(promoteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPromote(_ *cobra.Command, args []string) error {
|
func runPromote(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
||||||
}
|
}
|
||||||
@@ -42,14 +40,14 @@ func runPromote(_ *cobra.Command, args []string) error {
|
|||||||
cardType = db.CardType(args[1])
|
cardType = db.CardType(args[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(cmd.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cardData := generateCardData(cardType, e.Body)
|
cd := carddata.GenerateCardData(cardType, e.Body)
|
||||||
|
|
||||||
if err := store.Promote(id, cardType, cardData); err != nil {
|
if err := store.Promote(cmd.Context(), id, cardType, cd); err != nil {
|
||||||
if err == db.ErrAlreadyPromoted {
|
if err == db.ErrAlreadyPromoted {
|
||||||
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
||||||
display.FormatID(id), *e.CardType)
|
display.FormatID(id), *e.CardType)
|
||||||
@@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
|
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
|
|
||||||
|
|
||||||
func generateCardData(ct db.CardType, body string) *string {
|
|
||||||
var data string
|
|
||||||
switch ct {
|
|
||||||
case db.CardTemplate:
|
|
||||||
matches := templateSlotRe.FindAllStringSubmatch(body, -1)
|
|
||||||
type slot struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Default string `json:"default"`
|
|
||||||
}
|
|
||||||
var slots []slot
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range matches {
|
|
||||||
name := m[1]
|
|
||||||
if !seen[name] {
|
|
||||||
slots = append(slots, slot{Name: name, Default: ""})
|
|
||||||
seen[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slots == nil {
|
|
||||||
slots = []slot{}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"slots": slots})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardChecklist:
|
|
||||||
type step struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Done bool `json:"done"`
|
|
||||||
}
|
|
||||||
var steps []step
|
|
||||||
for _, line := range strings.Split(body, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
|
|
||||||
text := strings.TrimSpace(line[3:])
|
|
||||||
done := strings.HasPrefix(line, "[x]")
|
|
||||||
steps = append(steps, step{Text: text, Done: done})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if steps == nil {
|
|
||||||
steps = []step{{Text: body, Done: false}}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"steps": steps})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardDecision:
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"chose": "",
|
|
||||||
"why": "",
|
|
||||||
"rejected": []string{},
|
|
||||||
})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardLink:
|
|
||||||
url := ""
|
|
||||||
for _, word := range strings.Fields(body) {
|
|
||||||
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
|
||||||
url = word
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"url": url})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
default:
|
|
||||||
data = "{}"
|
|
||||||
}
|
|
||||||
return &data
|
|
||||||
}
|
|
||||||
|
|||||||
+55
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ func Execute() error {
|
|||||||
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
||||||
if first != "help" && first != "completion" &&
|
if first != "help" && first != "completion" &&
|
||||||
!isFlag && !isSubcommand(first) {
|
!isFlag && !isSubcommand(first) {
|
||||||
|
if near := nearSubcommand(first); near != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
||||||
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nearSubcommand(name string) string {
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if d := editDist(name, c.Name()); d > 0 && d <= 2 {
|
||||||
|
return c.Name()
|
||||||
|
}
|
||||||
|
for _, alias := range c.Aliases {
|
||||||
|
if d := editDist(name, alias); d > 0 && d <= 2 {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func editDist(a, b string) int {
|
||||||
|
la, lb := len(a), len(b)
|
||||||
|
if la == 0 {
|
||||||
|
return lb
|
||||||
|
}
|
||||||
|
if lb == 0 {
|
||||||
|
return la
|
||||||
|
}
|
||||||
|
prev := make([]int, lb+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= la; i++ {
|
||||||
|
curr := make([]int, lb+1)
|
||||||
|
curr[0] = i
|
||||||
|
for j := 1; j <= lb; j++ {
|
||||||
|
cost := 1
|
||||||
|
if a[i-1] == b[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
ins := curr[j-1] + 1
|
||||||
|
del := prev[j] + 1
|
||||||
|
sub := prev[j-1] + cost
|
||||||
|
curr[j] = ins
|
||||||
|
if del < curr[j] {
|
||||||
|
curr[j] = del
|
||||||
|
}
|
||||||
|
if sub < curr[j] {
|
||||||
|
curr[j] = sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
return prev[lb]
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
rootCmd.AddCommand(lsCmd)
|
rootCmd.AddCommand(lsCmd)
|
||||||
|
|||||||
+29
-5
@@ -19,7 +19,10 @@ var WebFS fs.FS
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
servePort int
|
servePort int
|
||||||
|
serveHost string
|
||||||
serveDev bool
|
serveDev bool
|
||||||
|
tlsCert string
|
||||||
|
tlsKey string
|
||||||
)
|
)
|
||||||
|
|
||||||
var serveCmd = &cobra.Command{
|
var serveCmd = &cobra.Command{
|
||||||
@@ -29,12 +32,20 @@ var serveCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444)")
|
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
|
||||||
|
serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)")
|
||||||
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
|
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
|
||||||
|
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
|
||||||
|
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
|
||||||
rootCmd.AddCommand(serveCmd)
|
rootCmd.AddCommand(serveCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServe(_ *cobra.Command, _ []string) error {
|
func runServe(_ *cobra.Command, _ []string) error {
|
||||||
|
useTLS := tlsCert != "" && tlsKey != ""
|
||||||
|
if (tlsCert != "") != (tlsKey != "") {
|
||||||
|
return fmt.Errorf("both --tls-cert and --tls-key are required for TLS")
|
||||||
|
}
|
||||||
|
|
||||||
port := servePort
|
port := servePort
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
if envPort := os.Getenv("NIB_PORT"); envPort != "" {
|
if envPort := os.Getenv("NIB_PORT"); envPort != "" {
|
||||||
@@ -43,6 +54,8 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
return fmt.Errorf("invalid NIB_PORT: %w", err)
|
return fmt.Errorf("invalid NIB_PORT: %w", err)
|
||||||
}
|
}
|
||||||
port = p
|
port = p
|
||||||
|
} else if useTLS {
|
||||||
|
port = 4443
|
||||||
} else {
|
} else {
|
||||||
port = 4444
|
port = 4444
|
||||||
}
|
}
|
||||||
@@ -59,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
router = api.NewRouter(store, serveDev, WebFS)
|
router = api.NewRouter(store, serveDev, WebFS)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf("%s:%d", serveHost, port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -69,12 +82,23 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Printf("nib serving on %s\n", addr)
|
if useTLS {
|
||||||
|
fmt.Printf("nib serving on https://%s\n", addr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("nib serving on http://%s\n", addr)
|
||||||
|
}
|
||||||
if serveDev {
|
if serveDev {
|
||||||
fmt.Println(" CORS enabled (dev mode)")
|
fmt.Println(" CORS enabled (dev mode)")
|
||||||
}
|
}
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
var listenErr error
|
||||||
|
if useTLS {
|
||||||
|
listenErr = srv.ListenAndServeTLS(tlsCert, tlsKey)
|
||||||
|
} else {
|
||||||
|
listenErr = srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
if listenErr != nil && listenErr != http.ErrServerClosed {
|
||||||
|
fmt.Fprintf(os.Stderr, "server error: %v\n", listenErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lerko/nib/internal/tui"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tuiCmd = &cobra.Command{
|
||||||
|
Use: "tui",
|
||||||
|
Short: "launch the terminal UI",
|
||||||
|
RunE: runTUI,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(tuiCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTUI(_ *cobra.Command, _ []string) error {
|
||||||
|
store, err := openStore()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
return tui.Run(store)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- [air](https://github.com/air-verse/air) (live-reload, install: `go install github.com/air-verse/air@latest`)
|
||||||
|
- OpenSSL (for dev TLS certs)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make cert # one-time: generate self-signed TLS cert
|
||||||
|
make watch # start dev server with live-reload (HTTP, port 4444)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make Targets
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make build` | Compile production binary (`./nib`) |
|
||||||
|
| `make dev` | Run dev server from source (HTTP, port 4444) |
|
||||||
|
| `make watch` | Live-reload dev server via air — auto-rebuilds on save |
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make test` | Run all tests |
|
||||||
|
| `make test-v` | Run all tests with verbose output |
|
||||||
|
| `make test-cover` | Run tests with per-function coverage report |
|
||||||
|
| `make lint` | Run vet + format check |
|
||||||
|
| `make vet` | Static analysis via `go vet` |
|
||||||
|
| `make fmt` | Auto-format all Go files |
|
||||||
|
| `make fmt-check` | Check formatting without modifying (CI-friendly) |
|
||||||
|
|
||||||
|
### Utility
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make cert` | Generate self-signed dev TLS cert in `certs/` (valid 365 days) |
|
||||||
|
| `make clean` | Remove build artifacts |
|
||||||
|
| `make tidy` | Tidy and verify Go module dependencies |
|
||||||
|
| `make run ARGS="..."` | Build then run with custom args |
|
||||||
|
| `make help` | List all targets |
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
nib supports TLS via `--tls-cert` and `--tls-key` flags on the `serve` command.
|
||||||
|
|
||||||
|
### Dev (self-signed)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make cert
|
||||||
|
make run ARGS="serve --tls-cert certs/dev.crt --tls-key certs/dev.key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Serves HTTPS on port 4443. Browser will warn about the self-signed cert — accept once.
|
||||||
|
|
||||||
|
This is needed for features that require a secure context (e.g. clipboard API).
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
For production, use a reverse proxy (Caddy, nginx) with real certificates in front of nib's HTTP server. Alternatively, pass real cert/key paths directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./nib serve --tls-cert /path/to/cert.pem --tls-key /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Defaults
|
||||||
|
|
||||||
|
| Mode | Default Port |
|
||||||
|
|------|-------------|
|
||||||
|
| HTTP | 4444 |
|
||||||
|
| HTTPS | 4443 |
|
||||||
|
|
||||||
|
Override with `--port` or the `NIB_PORT` environment variable.
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
Run the TUI directly from source:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . tui
|
||||||
|
```
|
||||||
|
|
||||||
|
TUI code lives in `internal/tui/`. No live-reload — restart manually after changes.
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. `make cert` — once, generates dev TLS cert
|
||||||
|
2. `make watch` — start coding, air rebuilds on save
|
||||||
|
3. Edit code, save, changes appear automatically
|
||||||
|
4. `make test` — verify nothing broke
|
||||||
|
5. `make lint` — check formatting and vet
|
||||||
|
6. Commit and push
|
||||||
@@ -3,21 +3,57 @@ module github.com/lerko/nib
|
|||||||
go 1.24.4
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
modernc.org/sqlite v1.37.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/glamour v1.0.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.13 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/term v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
modernc.org/libc v1.65.7 // indirect
|
modernc.org/libc v1.65.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.37.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,73 @@
|
|||||||
|
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||||
|
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
@@ -18,23 +75,67 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
|||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||||
|
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
|
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||||
|
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||||
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||||
|
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||||
|
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||||
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
||||||
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
+148
-61
@@ -25,15 +25,36 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
|
|||||||
return srv, store
|
return srv, store
|
||||||
}
|
}
|
||||||
|
|
||||||
func postJSON(srv *httptest.Server, path string, body any) *http.Response {
|
type listEnvelope struct {
|
||||||
b, _ := json.Marshal(body)
|
Data []EntityResponse `json:"data"`
|
||||||
resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
|
Total int `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
|
||||||
|
t.Helper()
|
||||||
|
var env listEnvelope
|
||||||
|
json.NewDecoder(resp.Body).Decode(&env)
|
||||||
|
return env.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
|
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"body": body,
|
"body": body,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
})
|
})
|
||||||
@@ -49,7 +70,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st
|
|||||||
func TestCreateEntity_Note(t *testing.T) {
|
func TestCreateEntity_Note(t *testing.T) {
|
||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"body": "test note",
|
"body": "test note",
|
||||||
"tags": []string{"demo"},
|
"tags": []string{"demo"},
|
||||||
})
|
})
|
||||||
@@ -76,7 +97,7 @@ func TestCreateEntity_Note(t *testing.T) {
|
|||||||
func TestCreateEntity_MissingBody(t *testing.T) {
|
func TestCreateEntity_MissingBody(t *testing.T) {
|
||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{})
|
resp := postJSON(t, srv, "/api/entities", map[string]any{})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
@@ -93,7 +114,7 @@ func TestCreateEntity_MissingBody(t *testing.T) {
|
|||||||
func TestCreateEntity_InvalidGlyph(t *testing.T) {
|
func TestCreateEntity_InvalidGlyph(t *testing.T) {
|
||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"body": "test",
|
"body": "test",
|
||||||
"glyph": "invalid",
|
"glyph": "invalid",
|
||||||
})
|
})
|
||||||
@@ -108,7 +129,10 @@ func TestGetEntity_Success(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "test", nil)
|
created := createTestEntity(t, srv, "test", nil)
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID)
|
resp, err := http.Get(srv.URL + "/api/entities/" + created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -125,7 +149,10 @@ func TestGetEntity_Success(t *testing.T) {
|
|||||||
func TestGetEntity_NotFound(t *testing.T) {
|
func TestGetEntity_NotFound(t *testing.T) {
|
||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT")
|
resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
@@ -138,11 +165,13 @@ func TestListEntities_Default(t *testing.T) {
|
|||||||
createTestEntity(t, srv, "one", nil)
|
createTestEntity(t, srv, "one", nil)
|
||||||
createTestEntity(t, srv, "two", nil)
|
createTestEntity(t, srv, "two", nil)
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities")
|
resp, err := http.Get(srv.URL + "/api/entities")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 2 {
|
if len(entities) != 2 {
|
||||||
t.Fatalf("expected 2, got %d", len(entities))
|
t.Fatalf("expected 2, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -153,11 +182,13 @@ func TestListEntities_FilterTag(t *testing.T) {
|
|||||||
createTestEntity(t, srv, "a", []string{"ops"})
|
createTestEntity(t, srv, "a", []string{"ops"})
|
||||||
createTestEntity(t, srv, "b", []string{"home"})
|
createTestEntity(t, srv, "b", []string{"home"})
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities?tag=ops")
|
resp, err := http.Get(srv.URL + "/api/entities?tag=ops")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1, got %d", len(entities))
|
t.Fatalf("expected 1, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -167,17 +198,19 @@ func TestListEntities_CardsOnly(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
createTestEntity(t, srv, "fluid", nil)
|
createTestEntity(t, srv, "fluid", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"body": "card",
|
"body": "card",
|
||||||
"card_type": "snippet",
|
"card_type": "snippet",
|
||||||
})
|
})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true")
|
resp, err := http.Get(srv.URL + "/api/entities?cards_only=true")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1 card, got %d", len(entities))
|
t.Fatalf("expected 1 card, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -189,14 +222,18 @@ func TestListEntities_Pagination(t *testing.T) {
|
|||||||
createTestEntity(t, srv, "note", nil)
|
createTestEntity(t, srv, "note", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
|
resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
|
||||||
var page1 []EntityResponse
|
if err != nil {
|
||||||
json.NewDecoder(resp.Body).Decode(&page1)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page1 := decodeList(t, resp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
||||||
var page2 []EntityResponse
|
if err != nil {
|
||||||
json.NewDecoder(resp.Body).Decode(&page2)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page2 := decodeList(t, resp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(page1) != 2 || len(page2) != 2 {
|
if len(page1) != 2 || len(page2) != 2 {
|
||||||
@@ -211,10 +248,16 @@ func TestUpdateEntity_Body(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "old", nil)
|
created := createTestEntity(t, srv, "old", nil)
|
||||||
|
|
||||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||||
mustJSON(map[string]any{"body": "new"})))
|
mustJSON(map[string]any{"body": "new"})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -233,8 +276,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
|||||||
created := createTestEntity(t, srv, "doomed", nil)
|
created := createTestEntity(t, srv, "doomed", nil)
|
||||||
|
|
||||||
// Soft delete
|
// Soft delete
|
||||||
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
var delResp DeleteResponse
|
var delResp DeleteResponse
|
||||||
json.NewDecoder(resp.Body).Decode(&delResp)
|
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -246,7 +295,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hard delete
|
// Hard delete
|
||||||
resp, _ = http.DefaultClient.Do(req)
|
req, err = http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&delResp)
|
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -257,7 +313,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gone
|
// Gone
|
||||||
resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID)
|
resp, err = http.Get(srv.URL + "/api/entities/" + created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
|
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
|
||||||
@@ -268,7 +327,7 @@ func TestPromoteEntity_Success(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "trick", nil)
|
created := createTestEntity(t, srv, "trick", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||||
"card_type": "snippet",
|
"card_type": "snippet",
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -288,11 +347,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "trick", nil)
|
created := createTestEntity(t, srv, "trick", nil)
|
||||||
|
|
||||||
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||||
"card_type": "snippet",
|
"card_type": "snippet",
|
||||||
}).Body.Close()
|
}).Body.Close()
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||||
"card_type": "template",
|
"card_type": "template",
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -312,7 +371,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "trick", nil)
|
created := createTestEntity(t, srv, "trick", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||||
"card_type": "bogus",
|
"card_type": "bogus",
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -326,11 +385,11 @@ func TestDemoteEntity_Success(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "trick", nil)
|
created := createTestEntity(t, srv, "trick", nil)
|
||||||
|
|
||||||
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||||
"card_type": "snippet",
|
"card_type": "snippet",
|
||||||
}).Body.Close()
|
}).Body.Close()
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
|
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -348,7 +407,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "trick", nil)
|
created := createTestEntity(t, srv, "trick", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
|
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
@@ -360,7 +419,7 @@ func TestUseEntity_Success(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
created := createTestEntity(t, srv, "trick", nil)
|
created := createTestEntity(t, srv, "trick", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil)
|
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -379,7 +438,10 @@ func TestListTags_WithCounts(t *testing.T) {
|
|||||||
createTestEntity(t, srv, "a", []string{"ops"})
|
createTestEntity(t, srv, "a", []string{"ops"})
|
||||||
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
|
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/tags")
|
resp, err := http.Get(srv.URL + "/api/tags")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var tags []TagResponse
|
var tags []TagResponse
|
||||||
@@ -391,14 +453,23 @@ func TestListTags_WithCounts(t *testing.T) {
|
|||||||
|
|
||||||
func TestCORS_DevMode(t *testing.T) {
|
func TestCORS_DevMode(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "test.db")
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
store, _ := db.Open(path)
|
store, err := db.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
router := NewRouter(store, true)
|
router := NewRouter(store, true)
|
||||||
srv := httptest.NewServer(router)
|
srv := httptest.NewServer(router)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||||
@@ -412,8 +483,14 @@ func TestCORS_DevMode(t *testing.T) {
|
|||||||
func TestCORS_ProdMode(t *testing.T) {
|
func TestCORS_ProdMode(t *testing.T) {
|
||||||
srv, _ := testServer(t) // devMode=false
|
srv, _ := testServer(t) // devMode=false
|
||||||
|
|
||||||
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
|
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
|
||||||
@@ -426,7 +503,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
|||||||
target := createTestEntity(t, srv, "target body", []string{"ops"})
|
target := createTestEntity(t, srv, "target body", []string{"ops"})
|
||||||
source := createTestEntity(t, srv, "source body", []string{"ops", "infra"})
|
source := createTestEntity(t, srv, "source body", []string{"ops", "infra"})
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
||||||
"source_id": source.ID,
|
"source_id": source.ID,
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -445,9 +522,11 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source should be soft-deleted (not in default list)
|
// Source should be soft-deleted (not in default list)
|
||||||
listResp, _ := http.Get(srv.URL + "/api/entities")
|
listResp, err := http.Get(srv.URL + "/api/entities")
|
||||||
var entities []EntityResponse
|
if err != nil {
|
||||||
json.NewDecoder(listResp.Body).Decode(&entities)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entities := decodeList(t, listResp)
|
||||||
listResp.Body.Close()
|
listResp.Body.Close()
|
||||||
for _, ent := range entities {
|
for _, ent := range entities {
|
||||||
if ent.ID == source.ID {
|
if ent.ID == source.ID {
|
||||||
@@ -461,11 +540,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) {
|
|||||||
target := createTestEntity(t, srv, "target", nil)
|
target := createTestEntity(t, srv, "target", nil)
|
||||||
source := createTestEntity(t, srv, "source", nil)
|
source := createTestEntity(t, srv, "source", nil)
|
||||||
|
|
||||||
postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{
|
postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{
|
||||||
"card_type": "snippet",
|
"card_type": "snippet",
|
||||||
}).Body.Close()
|
}).Body.Close()
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
||||||
"source_id": source.ID,
|
"source_id": source.ID,
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -485,7 +564,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
e := createTestEntity(t, srv, "self", nil)
|
e := createTestEntity(t, srv, "self", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
|
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
|
||||||
"source_id": e.ID,
|
"source_id": e.ID,
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -499,7 +578,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
e := createTestEntity(t, srv, "target", nil)
|
e := createTestEntity(t, srv, "target", nil)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
|
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
@@ -510,7 +589,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
|
|||||||
func TestCreateEntity_WithTitle(t *testing.T) {
|
func TestCreateEntity_WithTitle(t *testing.T) {
|
||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"body": "body text",
|
"body": "body text",
|
||||||
"title": "nginx trick",
|
"title": "nginx trick",
|
||||||
"description": "always forget this",
|
"description": "always forget this",
|
||||||
@@ -536,7 +615,7 @@ func TestCreateEntity_TitleOnly(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
title := "title only"
|
title := "title only"
|
||||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"title": title,
|
"title": title,
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -559,7 +638,10 @@ func TestUpdateEntity_Title(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||||
mustJSON(map[string]any{"title": "new title"})))
|
mustJSON(map[string]any{"title": "new title"})))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -580,7 +662,10 @@ func TestUpdateEntity_Description(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||||
mustJSON(map[string]any{"description": "new desc"})))
|
mustJSON(map[string]any{"description": "new desc"})))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -598,16 +683,18 @@ func TestListEntities_TitleInResponse(t *testing.T) {
|
|||||||
srv, _ := testServer(t)
|
srv, _ := testServer(t)
|
||||||
|
|
||||||
title := "list title"
|
title := "list title"
|
||||||
postJSON(srv, "/api/entities", map[string]any{
|
postJSON(t, srv, "/api/entities", map[string]any{
|
||||||
"body": "body",
|
"body": "body",
|
||||||
"title": title,
|
"title": title,
|
||||||
}).Body.Close()
|
}).Body.Close()
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities")
|
resp, err := http.Get(srv.URL + "/api/entities")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1, got %d", len(entities))
|
t.Fatalf("expected 1, got %d", len(entities))
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-32
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -91,6 +92,9 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
|
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
p.Limit = limit
|
p.Limit = limit
|
||||||
}
|
}
|
||||||
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
||||||
@@ -101,18 +105,32 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
p.Offset = offset
|
p.Offset = offset
|
||||||
}
|
}
|
||||||
|
if p.Limit <= 0 {
|
||||||
|
p.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
total, err := store.Count(r.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := make([]EntityResponse, len(entities))
|
entities, err := store.List(r.Context(), p)
|
||||||
for i, e := range entities {
|
if err != nil {
|
||||||
resp[i] = entityToResponse(e)
|
writeInternalError(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
|
items := make([]EntityResponse, len(entities))
|
||||||
|
for i, e := range entities {
|
||||||
|
items[i] = entityToResponse(e)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": p.Limit,
|
||||||
|
"offset": p.Offset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +163,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) {
|
||||||
@@ -156,8 +177,12 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
|||||||
e.CardData = req.CardData
|
e.CardData = req.CardData
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Create(e); err != nil {
|
if err := store.Create(r.Context(), e); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,13 +193,13 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
|||||||
func getEntity(store *db.Store) http.HandlerFunc {
|
func getEntity(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||||
@@ -218,18 +243,22 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
|||||||
u.CardType = &ct
|
u.CardType = &ct
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Update(id, u); err != nil {
|
if err := store.Update(r.Context(), id, u); err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||||
@@ -243,13 +272,13 @@ type DeleteResponse struct {
|
|||||||
func deleteEntity(store *db.Store) http.HandlerFunc {
|
func deleteEntity(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
result, err := store.SoftDelete(id)
|
result, err := store.SoftDelete(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
label := "soft"
|
label := "soft"
|
||||||
@@ -278,7 +307,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil {
|
if err := store.Promote(r.Context(), id, db.CardType(req.CardType), req.CardData); err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
@@ -287,13 +316,17 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||||
@@ -304,7 +337,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
if err := store.Demote(id); err != nil {
|
if err := store.Demote(r.Context(), id); err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
@@ -313,13 +346,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
|
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||||
@@ -348,7 +381,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Absorb(id, req.SourceID); err != nil {
|
if err := store.Absorb(r.Context(), id, req.SourceID); err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
|
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
|
||||||
return
|
return
|
||||||
@@ -357,13 +390,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
|
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||||
@@ -374,18 +407,18 @@ func useEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
if err := store.IncrementUse(id); err != nil {
|
if err := store.IncrementUse(r.Context(), id); err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxBodySize = 1 << 20 // 1 MB
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -43,6 +46,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||||
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
|
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
|
||||||
return false
|
return false
|
||||||
@@ -50,6 +54,11 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeInternalError(w http.ResponseWriter, err error) {
|
||||||
|
log.Printf("internal error: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||||
|
}
|
||||||
|
|
||||||
func entityToResponse(e *db.Entity) EntityResponse {
|
func entityToResponse(e *db.Entity) EntityResponse {
|
||||||
resp := EntityResponse{
|
resp := EntityResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ 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(r.Context(), cardsOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package carddata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
|
||||||
|
|
||||||
|
func GenerateCardData(ct db.CardType, body string) *string {
|
||||||
|
var data string
|
||||||
|
switch ct {
|
||||||
|
case db.CardTemplate:
|
||||||
|
matches := TemplateSlotRe.FindAllStringSubmatch(body, -1)
|
||||||
|
type slot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
var slots []slot
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range matches {
|
||||||
|
name := m[1]
|
||||||
|
if !seen[name] {
|
||||||
|
slots = append(slots, slot{Name: name, Default: ""})
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slots == nil {
|
||||||
|
slots = []slot{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"slots": slots})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardChecklist:
|
||||||
|
type step struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
var steps []step
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
|
||||||
|
text := strings.TrimSpace(line[3:])
|
||||||
|
done := strings.HasPrefix(line, "[x]")
|
||||||
|
steps = append(steps, step{Text: text, Done: done})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if steps == nil {
|
||||||
|
steps = []step{{Text: body, Done: false}}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"steps": steps})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardDecision:
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"chose": "",
|
||||||
|
"why": "",
|
||||||
|
"rejected": []string{},
|
||||||
|
})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardLink:
|
||||||
|
url := ""
|
||||||
|
for _, word := range strings.Fields(body) {
|
||||||
|
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
||||||
|
url = word
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"url": url})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
default:
|
||||||
|
data = "{}"
|
||||||
|
}
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectCardType(body string) *db.CardType {
|
||||||
|
if TemplateSlotRe.MatchString(body) {
|
||||||
|
ct := db.CardTemplate
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "chose:") || strings.Contains(body, "why:") {
|
||||||
|
ct := db.CardDecision
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") {
|
||||||
|
ct := db.CardChecklist
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
for _, word := range strings.Fields(body) {
|
||||||
|
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
||||||
|
ct := db.CardLink
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package carddata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateCardData_Snippet(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardSnippet, "some snippet")
|
||||||
|
if data == nil || *data != "{}" {
|
||||||
|
t.Errorf("snippet should produce {}, got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_Template(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
|
||||||
|
if data == nil {
|
||||||
|
t.Fatal("expected non-nil data")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Slots []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
} `json:"slots"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parsed.Slots) != 2 {
|
||||||
|
t.Fatalf("expected 2 slots, got %d", len(parsed.Slots))
|
||||||
|
}
|
||||||
|
if parsed.Slots[0].Name != "host" {
|
||||||
|
t.Errorf("first slot: %q", parsed.Slots[0].Name)
|
||||||
|
}
|
||||||
|
if parsed.Slots[1].Name != "env" {
|
||||||
|
t.Errorf("second slot: %q", parsed.Slots[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
|
||||||
|
var parsed struct {
|
||||||
|
Slots []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"slots"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(*data), &parsed)
|
||||||
|
if len(parsed.Slots) != 1 {
|
||||||
|
t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardTemplate, "no placeholders here")
|
||||||
|
var parsed struct {
|
||||||
|
Slots []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"slots"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(*data), &parsed)
|
||||||
|
if len(parsed.Slots) != 0 {
|
||||||
|
t.Errorf("expected empty slots, got %d", len(parsed.Slots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_Checklist(t *testing.T) {
|
||||||
|
body := "[ ] step one\n[x] step two\n[ ] step three"
|
||||||
|
data := GenerateCardData(db.CardChecklist, body)
|
||||||
|
if data == nil {
|
||||||
|
t.Fatal("expected non-nil data")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Steps []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
} `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parsed.Steps) != 3 {
|
||||||
|
t.Fatalf("expected 3 steps, got %d", len(parsed.Steps))
|
||||||
|
}
|
||||||
|
if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done {
|
||||||
|
t.Errorf("step 0: %+v", parsed.Steps[0])
|
||||||
|
}
|
||||||
|
if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done {
|
||||||
|
t.Errorf("step 1: %+v", parsed.Steps[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
|
||||||
|
var parsed struct {
|
||||||
|
Steps []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
} `json:"steps"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(*data), &parsed)
|
||||||
|
if len(parsed.Steps) != 1 {
|
||||||
|
t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps))
|
||||||
|
}
|
||||||
|
if parsed.Steps[0].Text != "no checkbox syntax" {
|
||||||
|
t.Errorf("fallback step text: %q", parsed.Steps[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_Decision(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardDecision, "which db?")
|
||||||
|
var parsed struct {
|
||||||
|
Chose string `json:"chose"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Rejected []string `json:"rejected"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parsed.Chose != "" || parsed.Why != "" {
|
||||||
|
t.Error("decision fields should start empty")
|
||||||
|
}
|
||||||
|
if len(parsed.Rejected) != 0 {
|
||||||
|
t.Error("rejected should start empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_Link(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
|
||||||
|
var parsed struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(*data), &parsed)
|
||||||
|
if parsed.URL != "https://example.com/path" {
|
||||||
|
t.Errorf("url: %q", parsed.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCardData_LinkNoURL(t *testing.T) {
|
||||||
|
data := GenerateCardData(db.CardLink, "no url here")
|
||||||
|
var parsed struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(*data), &parsed)
|
||||||
|
if parsed.URL != "" {
|
||||||
|
t.Errorf("expected empty url, got %q", parsed.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
-10
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ var (
|
|||||||
ErrAlreadyPromoted = errors.New("invalid_promote")
|
ErrAlreadyPromoted = errors.New("invalid_promote")
|
||||||
ErrAlreadyFluid = errors.New("invalid_demote")
|
ErrAlreadyFluid = errors.New("invalid_demote")
|
||||||
ErrTargetCrystallized = errors.New("invalid_absorb")
|
ErrTargetCrystallized = errors.New("invalid_absorb")
|
||||||
|
ErrInvalidCardData = errors.New("invalid_card_data")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -49,22 +51,28 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) Backup(dst string) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec("VACUUM INTO ?", dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSchema = 5
|
||||||
|
|
||||||
|
var migrations = []func(db *sql.DB) error{
|
||||||
|
// v1: initial schema
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS entities (
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
modified_at TEXT NOT NULL,
|
modified_at TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
glyph TEXT NOT NULL
|
glyph TEXT NOT NULL,
|
||||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
|
||||||
time_anchor TEXT,
|
time_anchor TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
card_type TEXT
|
card_type TEXT,
|
||||||
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link')
|
|
||||||
OR card_type IS NULL),
|
|
||||||
card_data TEXT,
|
card_data TEXT,
|
||||||
use_count INTEGER NOT NULL DEFAULT 0,
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_used_at TEXT
|
last_used_at TEXT
|
||||||
@@ -84,14 +92,139 @@ func (s *Store) migrate() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
||||||
ON entity_tags(tag);
|
ON entity_tags(tag);
|
||||||
`)
|
`)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
|
||||||
|
// v2: add title and description columns
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil {
|
||||||
|
return fmt.Errorf("add title column: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil {
|
||||||
|
return fmt.Errorf("add description column: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
|
||||||
|
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
|
// Disable FK checks during rebuild to avoid dangling references
|
||||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
|
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk off: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate rename: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`CREATE TABLE entities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
modified_at TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
glyph TEXT NOT NULL
|
||||||
|
CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
|
||||||
|
time_anchor TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TEXT,
|
||||||
|
card_type TEXT
|
||||||
|
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note')
|
||||||
|
OR card_type IS NULL),
|
||||||
|
card_data TEXT,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("migrate create: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO entities SELECT * FROM _entities_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate copy: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate drop: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild entity_tags to point FK at new entities table
|
||||||
|
if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags rename: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`CREATE TABLE entity_tags (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (entity_id, tag)
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags create: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags copy: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags drop: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk on: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
},
|
||||||
|
|
||||||
|
// v4: add indexes for common query filters
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
for _, idx := range []string{
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_entities_deleted ON entities(deleted_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_entities_modified ON entities(modified_at DESC) WHERE deleted_at IS NULL`,
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(idx); err != nil {
|
||||||
|
return fmt.Errorf("create index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
|
},
|
||||||
|
|
||||||
|
// v5: add entity_links table for wiki-links
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE entity_links (
|
||||||
|
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
|
||||||
|
link_text TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (from_id, link_text)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`)
|
||||||
|
|
||||||
|
var version int
|
||||||
|
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
version = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := version; i < len(migrations); i++ {
|
||||||
|
if err := migrations[i](s.db); err != nil {
|
||||||
|
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if version == 0 {
|
||||||
|
_, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations))
|
||||||
|
} else if len(migrations) > version {
|
||||||
|
_, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations))
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
@@ -103,7 +236,7 @@ func DefaultPath() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(home, ".nib")
|
dir := filepath.Join(home, ".nib")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(dir, "nib.db"), nil
|
return filepath.Join(dir, "nib.db"), nil
|
||||||
|
|||||||
+180
-105
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,6 +17,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
|
||||||
@@ -26,11 +28,12 @@ const (
|
|||||||
CardChecklist CardType = "checklist"
|
CardChecklist CardType = "checklist"
|
||||||
CardDecision CardType = "decision"
|
CardDecision CardType = "decision"
|
||||||
CardLink CardType = "link"
|
CardLink CardType = "link"
|
||||||
|
CardNote CardType = "note"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
@@ -38,7 +41,7 @@ func ValidGlyph(s string) bool {
|
|||||||
|
|
||||||
func ValidCardType(s string) bool {
|
func ValidCardType(s string) bool {
|
||||||
switch CardType(s) {
|
switch CardType(s) {
|
||||||
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink:
|
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -69,6 +72,7 @@ type ListParams struct {
|
|||||||
From *string
|
From *string
|
||||||
To *string
|
To *string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
|
ModifiedBefore *time.Time
|
||||||
CardsOnly bool
|
CardsOnly bool
|
||||||
IncludeDeleted bool
|
IncludeDeleted bool
|
||||||
CardTypeFilter *CardType
|
CardTypeFilter *CardType
|
||||||
@@ -93,13 +97,18 @@ type EntityUpdate struct {
|
|||||||
Glyph *Glyph
|
Glyph *Glyph
|
||||||
TimeAnchor *string
|
TimeAnchor *string
|
||||||
ClearTime bool
|
ClearTime bool
|
||||||
|
CompletedAt *time.Time
|
||||||
|
ClearCompleted bool
|
||||||
Pinned *bool
|
Pinned *bool
|
||||||
CardType *CardType
|
CardType *CardType
|
||||||
CardData *string
|
CardData *string
|
||||||
Tags *[]string
|
Tags *[]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Create(e *Entity) error {
|
func (s *Store) Create(ctx context.Context, e *Entity) error {
|
||||||
|
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
e.ID = nibulid.New()
|
e.ID = nibulid.New()
|
||||||
e.CreatedAt = now
|
e.CreatedAt = now
|
||||||
@@ -108,13 +117,13 @@ func (s *Store) Create(e *Entity) error {
|
|||||||
e.Tags = []string{}
|
e.Tags = []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
_, err = tx.Exec(`
|
_, err = tx.ExecContext(ctx, `
|
||||||
INSERT INTO entities (id, created_at, modified_at, body, title, description,
|
INSERT INTO entities (id, created_at, modified_at, body, title, description,
|
||||||
glyph, time_anchor, completed_at, pinned, deleted_at,
|
glyph, time_anchor, completed_at, pinned, deleted_at,
|
||||||
card_type, card_data, use_count, last_used_at)
|
card_type, card_data, use_count, last_used_at)
|
||||||
@@ -139,30 +148,26 @@ func (s *Store) Create(e *Entity) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := insertTags(tx, e.ID, e.Tags); err != nil {
|
if err := insertTags(ctx, tx, e.ID, e.Tags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncLinks(ctx, tx, s, e.ID, e.Body); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Get(id string) (*Entity, error) {
|
func (s *Store) Get(ctx context.Context, id string) (*Entity, error) {
|
||||||
e := &Entity{}
|
e := &Entity{}
|
||||||
var createdAt, modifiedAt string
|
row := newEntityRow()
|
||||||
var completedAt, deletedAt, lastUsedAt sql.NullString
|
|
||||||
var timeAnchor, cardType, cardData sql.NullString
|
|
||||||
var title, description sql.NullString
|
|
||||||
var pinned int
|
|
||||||
|
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, created_at, modified_at, body, title, description,
|
SELECT id, created_at, modified_at, body, title, description,
|
||||||
glyph, time_anchor, completed_at, pinned, deleted_at,
|
glyph, time_anchor, completed_at, pinned, deleted_at,
|
||||||
card_type, card_data, use_count, last_used_at
|
card_type, card_data, use_count, last_used_at
|
||||||
FROM entities WHERE id = ?`, id).Scan(
|
FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...)
|
||||||
&e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
|
|
||||||
&e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
|
|
||||||
&cardType, &cardData, &e.UseCount, &lastUsedAt,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -170,19 +175,11 @@ func (s *Store) Get(id string) (*Entity, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
if err := row.apply(e); err != nil {
|
||||||
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
|
return nil, fmt.Errorf("scan entity %s: %w", id, err)
|
||||||
e.Title = nullToPtr(title)
|
}
|
||||||
e.Description = nullToPtr(description)
|
|
||||||
e.TimeAnchor = nullToPtr(timeAnchor)
|
|
||||||
e.CompletedAt = parseTimePtr(completedAt)
|
|
||||||
e.Pinned = pinned != 0
|
|
||||||
e.DeletedAt = parseTimePtr(deletedAt)
|
|
||||||
e.CardType = nullToCardType(cardType)
|
|
||||||
e.CardData = nullToPtr(cardData)
|
|
||||||
e.LastUsedAt = parseTimePtr(lastUsedAt)
|
|
||||||
|
|
||||||
tags, err := s.loadTags(id)
|
tags, err := s.loadTags(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -191,7 +188,7 @@ func (s *Store) Get(id string) (*Entity, error) {
|
|||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
func listWhere(params ListParams) (string, []any) {
|
||||||
var where []string
|
var where []string
|
||||||
var args []any
|
var args []any
|
||||||
|
|
||||||
@@ -225,19 +222,46 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
where = append(where, "e.card_type = ?")
|
where = append(where, "e.card_type = ?")
|
||||||
args = append(args, string(*params.CardTypeFilter))
|
args = append(args, string(*params.CardTypeFilter))
|
||||||
}
|
}
|
||||||
|
if params.ModifiedBefore != nil {
|
||||||
whereClause := ""
|
where = append(where, "e.modified_at < ?")
|
||||||
if len(where) > 0 {
|
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
|
||||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clause := ""
|
||||||
|
if len(where) > 0 {
|
||||||
|
clause = "WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
return clause, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Count(ctx context.Context, params ListParams) (int, error) {
|
||||||
|
whereClause, args := listWhere(params)
|
||||||
|
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(ctx context.Context, params ListParams) ([]*Entity, error) {
|
||||||
|
whereClause, args := listWhere(params)
|
||||||
|
|
||||||
orderCol := "e.created_at"
|
orderCol := "e.created_at"
|
||||||
if params.Sort == "use_count" {
|
switch params.Sort {
|
||||||
|
case "use_count":
|
||||||
orderCol = "e.use_count"
|
orderCol = "e.use_count"
|
||||||
|
case "modified_at":
|
||||||
|
orderCol = "e.modified_at"
|
||||||
|
case "created_at", "":
|
||||||
|
orderCol = "e.created_at"
|
||||||
|
default:
|
||||||
|
orderCol = "e.created_at"
|
||||||
}
|
}
|
||||||
orderDir := "DESC"
|
orderDir := "DESC"
|
||||||
if strings.EqualFold(params.Order, "asc") {
|
switch strings.ToLower(params.Order) {
|
||||||
|
case "asc":
|
||||||
orderDir = "ASC"
|
orderDir = "ASC"
|
||||||
|
default:
|
||||||
|
orderDir = "DESC"
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := params.Limit
|
limit := params.Limit
|
||||||
@@ -256,7 +280,7 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
|
|
||||||
args = append(args, limit, params.Offset)
|
args = append(args, limit, params.Offset)
|
||||||
|
|
||||||
rows, err := s.db.Query(query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -265,49 +289,33 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
var entities []*Entity
|
var entities []*Entity
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
e := &Entity{}
|
e := &Entity{}
|
||||||
var createdAt, modifiedAt string
|
row := newEntityRow()
|
||||||
var completedAt, deletedAt, lastUsedAt sql.NullString
|
if err := rows.Scan(row.ptrs(e)...); err != nil {
|
||||||
var timeAnchor, cardType, cardData sql.NullString
|
return nil, err
|
||||||
var title, description sql.NullString
|
}
|
||||||
var pinned int
|
if err := row.apply(e); err != nil {
|
||||||
|
return nil, err
|
||||||
if err := rows.Scan(
|
}
|
||||||
&e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
|
entities = append(entities, e)
|
||||||
&e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
|
}
|
||||||
&cardType, &cardData, &e.UseCount, &lastUsedAt,
|
if err := rows.Err(); err != nil {
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
if err := s.batchLoadTags(ctx, entities); err != nil {
|
||||||
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
|
|
||||||
e.Title = nullToPtr(title)
|
|
||||||
e.Description = nullToPtr(description)
|
|
||||||
e.TimeAnchor = nullToPtr(timeAnchor)
|
|
||||||
e.CompletedAt = parseTimePtr(completedAt)
|
|
||||||
e.Pinned = pinned != 0
|
|
||||||
e.DeletedAt = parseTimePtr(deletedAt)
|
|
||||||
e.CardType = nullToCardType(cardType)
|
|
||||||
e.CardData = nullToPtr(cardData)
|
|
||||||
e.LastUsedAt = parseTimePtr(lastUsedAt)
|
|
||||||
|
|
||||||
entities = append(entities, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.batchLoadTags(entities); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return entities, nil
|
return entities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Update(id string, u *EntityUpdate) error {
|
func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error {
|
||||||
existing, err := s.Get(id)
|
existing, err := s.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -341,6 +349,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
sets = append(sets, "time_anchor = ?")
|
sets = append(sets, "time_anchor = ?")
|
||||||
args = append(args, *u.TimeAnchor)
|
args = append(args, *u.TimeAnchor)
|
||||||
}
|
}
|
||||||
|
if u.ClearCompleted {
|
||||||
|
sets = append(sets, "completed_at = NULL")
|
||||||
|
} else if u.CompletedAt != nil {
|
||||||
|
sets = append(sets, "completed_at = ?")
|
||||||
|
args = append(args, u.CompletedAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
if u.Pinned != nil {
|
if u.Pinned != nil {
|
||||||
sets = append(sets, "pinned = ?")
|
sets = append(sets, "pinned = ?")
|
||||||
args = append(args, boolToInt(*u.Pinned))
|
args = append(args, boolToInt(*u.Pinned))
|
||||||
@@ -350,6 +364,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
args = append(args, string(*u.CardType))
|
args = append(args, string(*u.CardType))
|
||||||
}
|
}
|
||||||
if u.CardData != nil {
|
if u.CardData != nil {
|
||||||
|
if !json.Valid([]byte(*u.CardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
sets = append(sets, "card_data = ?")
|
sets = append(sets, "card_data = ?")
|
||||||
args = append(args, *u.CardData)
|
args = append(args, *u.CardData)
|
||||||
}
|
}
|
||||||
@@ -357,15 +374,21 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
args = append(args, existing.ID)
|
args = append(args, existing.ID)
|
||||||
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
|
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||||
|
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Tags != nil {
|
if u.Tags != nil {
|
||||||
if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
|
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := insertTags(tx, existing.ID, *u.Tags); err != nil {
|
if err := insertTags(ctx, tx, existing.ID, *u.Tags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Body != nil {
|
||||||
|
if err := syncLinks(ctx, tx, s, existing.ID, *u.Body); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,8 +396,8 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
func (s *Store) Promote(ctx context.Context, id string, cardType CardType, cardData *string) error {
|
||||||
e, err := s.Get(id)
|
e, err := s.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -384,18 +407,21 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
|||||||
|
|
||||||
dataVal := "{}"
|
dataVal := "{}"
|
||||||
if cardData != nil {
|
if cardData != nil {
|
||||||
|
if !json.Valid([]byte(*cardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
dataVal = *cardData
|
dataVal = *cardData
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`
|
_, err = s.db.ExecContext(ctx, `
|
||||||
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
|
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
|
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Demote(id string) error {
|
func (s *Store) Demote(ctx context.Context, id string) error {
|
||||||
e, err := s.Get(id)
|
e, err := s.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -403,7 +429,7 @@ func (s *Store) Demote(id string) error {
|
|||||||
return ErrAlreadyFluid
|
return ErrAlreadyFluid
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`
|
_, err = s.db.ExecContext(ctx, `
|
||||||
UPDATE entities SET card_type = NULL, card_data = NULL,
|
UPDATE entities SET card_type = NULL, card_data = NULL,
|
||||||
use_count = 0, last_used_at = NULL, modified_at = ?
|
use_count = 0, last_used_at = NULL, modified_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
@@ -418,9 +444,9 @@ const (
|
|||||||
DeletedHard
|
DeletedHard
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) SoftDelete(id string) (DeleteResult, error) {
|
func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error) {
|
||||||
var deletedAt sql.NullString
|
var deletedAt sql.NullString
|
||||||
err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
|
err := s.db.QueryRowContext(ctx, "SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return 0, ErrNotFound
|
return 0, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -429,21 +455,21 @@ func (s *Store) SoftDelete(id string) (DeleteResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if deletedAt.Valid {
|
if deletedAt.Valid {
|
||||||
_, err = s.db.Exec("DELETE FROM entities WHERE id = ?", id)
|
_, err = s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
|
||||||
return DeletedHard, err
|
return DeletedHard, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
_, err = s.db.ExecContext(ctx, "UPDATE entities SET deleted_at = ? WHERE id = ?",
|
||||||
time.Now().UTC().Format(time.RFC3339), id)
|
time.Now().UTC().Format(time.RFC3339), id)
|
||||||
return DeletedSoft, err
|
return DeletedSoft, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Absorb(targetID, sourceID string) error {
|
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
|
||||||
target, err := s.Get(targetID)
|
target, err := s.Get(ctx, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
source, err := s.Get(sourceID)
|
source, err := s.Get(ctx, sourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -452,7 +478,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
return ErrTargetCrystallized
|
return ErrTargetCrystallized
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -461,7 +487,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
merged := target.Body + "\n" + source.Body
|
merged := target.Body + "\n" + source.Body
|
||||||
|
|
||||||
if _, err := tx.Exec("UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
|
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
|
||||||
merged, now, targetID); err != nil {
|
merged, now, targetID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -472,31 +498,36 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
}
|
}
|
||||||
for _, t := range source.Tags {
|
for _, t := range source.Tags {
|
||||||
if !seen[t] {
|
if !seen[t] {
|
||||||
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
|
if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
|
||||||
targetID, t); err != nil {
|
targetID, t); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if source.CardType != nil {
|
if source.CardType != nil {
|
||||||
if _, err := tx.Exec(`UPDATE entities SET card_type = NULL, card_data = NULL,
|
if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL,
|
||||||
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
|
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
|
||||||
now, sourceID); err != nil {
|
now, sourceID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
|
||||||
now, sourceID); err != nil {
|
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
|
||||||
|
absorbNote, now, now, sourceID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) IncrementUse(id string) error {
|
func (s *Store) IncrementUse(ctx context.Context, id string) error {
|
||||||
res, err := s.db.Exec(`
|
res, err := s.db.ExecContext(ctx, `
|
||||||
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
|
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
time.Now().UTC().Format(time.RFC3339), id)
|
time.Now().UTC().Format(time.RFC3339), id)
|
||||||
@@ -510,8 +541,8 @@ func (s *Store) IncrementUse(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Resolve(prefix string) (string, error) {
|
func (s *Store) Resolve(ctx context.Context, prefix string) (string, error) {
|
||||||
rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
|
rows, err := s.db.QueryContext(ctx, "SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -525,6 +556,9 @@ func (s *Store) Resolve(prefix string) (string, error) {
|
|||||||
}
|
}
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
switch len(ids) {
|
switch len(ids) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -536,9 +570,45 @@ func (s *Store) Resolve(prefix string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// helpers
|
type entityRow struct {
|
||||||
|
createdAt, modifiedAt string
|
||||||
|
completedAt, deletedAt, lastUsedAt sql.NullString
|
||||||
|
timeAnchor, cardType, cardData sql.NullString
|
||||||
|
title, description sql.NullString
|
||||||
|
pinned int
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) batchLoadTags(entities []*Entity) error {
|
func newEntityRow() *entityRow { return &entityRow{} }
|
||||||
|
|
||||||
|
func (r *entityRow) ptrs(e *Entity) []any {
|
||||||
|
return []any{
|
||||||
|
&e.ID, &r.createdAt, &r.modifiedAt, &e.Body, &r.title, &r.description,
|
||||||
|
&e.Glyph, &r.timeAnchor, &r.completedAt, &r.pinned, &r.deletedAt,
|
||||||
|
&r.cardType, &r.cardData, &e.UseCount, &r.lastUsedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *entityRow) apply(e *Entity) error {
|
||||||
|
var err error
|
||||||
|
if e.CreatedAt, err = time.Parse(time.RFC3339, r.createdAt); err != nil {
|
||||||
|
return fmt.Errorf("created_at: %w", err)
|
||||||
|
}
|
||||||
|
if e.ModifiedAt, err = time.Parse(time.RFC3339, r.modifiedAt); err != nil {
|
||||||
|
return fmt.Errorf("modified_at: %w", err)
|
||||||
|
}
|
||||||
|
e.Title = nullToPtr(r.title)
|
||||||
|
e.Description = nullToPtr(r.description)
|
||||||
|
e.TimeAnchor = nullToPtr(r.timeAnchor)
|
||||||
|
e.CompletedAt = parseTimePtr(r.completedAt)
|
||||||
|
e.Pinned = r.pinned != 0
|
||||||
|
e.DeletedAt = parseTimePtr(r.deletedAt)
|
||||||
|
e.CardType = nullToCardType(r.cardType)
|
||||||
|
e.CardData = nullToPtr(r.cardData)
|
||||||
|
e.LastUsedAt = parseTimePtr(r.lastUsedAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) batchLoadTags(ctx context.Context, entities []*Entity) error {
|
||||||
if len(entities) == 0 {
|
if len(entities) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -558,7 +628,7 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
|
|||||||
strings.Join(placeholders, ","),
|
strings.Join(placeholders, ","),
|
||||||
)
|
)
|
||||||
|
|
||||||
rows, err := s.db.Query(query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -576,8 +646,8 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) loadTags(entityID string) ([]string, error) {
|
func (s *Store) loadTags(ctx context.Context, entityID string) ([]string, error) {
|
||||||
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
rows, err := s.db.QueryContext(ctx, "SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -591,15 +661,18 @@ func (s *Store) loadTags(entityID string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
tags = append(tags, tag)
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if tags == nil {
|
if tags == nil {
|
||||||
tags = []string{}
|
tags = []string{}
|
||||||
}
|
}
|
||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertTags(tx *sql.Tx, entityID string, tags []string) error {
|
func insertTags(ctx context.Context, tx *sql.Tx, entityID string, tags []string) error {
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
|
if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
|
||||||
entityID, tag); err != nil {
|
entityID, tag); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -654,11 +727,13 @@ func boolToInt(b bool) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Entity) CardDataJSON() map[string]interface{} {
|
func (e *Entity) CardDataJSON() (map[string]interface{}, error) {
|
||||||
if e.CardData == nil {
|
if e.CardData == nil {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var m map[string]interface{}
|
var m map[string]interface{}
|
||||||
json.Unmarshal([]byte(*e.CardData), &m)
|
if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil {
|
||||||
return m
|
return nil, fmt.Errorf("card_data: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-80
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -11,15 +12,16 @@ func ptr[T any](v T) *T {
|
|||||||
|
|
||||||
func TestCreate_Note(t *testing.T) {
|
func TestCreate_Note(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "hello world", Glyph: GlyphNote}
|
e := &Entity{Body: "hello world", Glyph: GlyphNote}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if e.ID == "" {
|
if e.ID == "" {
|
||||||
t.Fatal("ID not set")
|
t.Fatal("ID not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -33,12 +35,13 @@ func TestCreate_Note(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
|
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -49,12 +52,13 @@ func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_WithTags(t *testing.T) {
|
func TestCreate_WithTags(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
|
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -65,13 +69,14 @@ func TestCreate_WithTags(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_WithCardType(t *testing.T) {
|
func TestCreate_WithCardType(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +87,7 @@ func TestCreate_WithCardType(t *testing.T) {
|
|||||||
|
|
||||||
func TestGet_NotFound(t *testing.T) {
|
func TestGet_NotFound(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
_, err := s.Get("01NONEXISTENT0000000000000")
|
_, err := s.Get(context.Background(), "01NONEXISTENT0000000000000")
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -90,11 +95,12 @@ func TestGet_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_DefaultParams(t *testing.T) {
|
func TestList_DefaultParams(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
|
||||||
}
|
}
|
||||||
|
|
||||||
entities, err := s.List(DefaultListParams())
|
entities, err := s.List(ctx, DefaultListParams())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -109,15 +115,16 @@ func TestList_DefaultParams(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_FilterByTag(t *testing.T) {
|
func TestList_FilterByTag(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
|
ctx := context.Background()
|
||||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
|
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
|
||||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
|
s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
|
||||||
|
s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
tag := "ops"
|
tag := "ops"
|
||||||
p.Tag = &tag
|
p.Tag = &tag
|
||||||
|
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -128,13 +135,14 @@ func TestList_FilterByTag(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_FilterByDate(t *testing.T) {
|
func TestList_FilterByDate(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "today", Glyph: GlyphNote})
|
ctx := context.Background()
|
||||||
|
s.Create(ctx, &Entity{Body: "today", Glyph: GlyphNote})
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
p.Date = &date
|
p.Date = &date
|
||||||
|
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -144,7 +152,7 @@ func TestList_FilterByDate(t *testing.T) {
|
|||||||
|
|
||||||
otherDate := "2020-01-01"
|
otherDate := "2020-01-01"
|
||||||
p.Date = &otherDate
|
p.Date = &otherDate
|
||||||
entities, err = s.List(p)
|
entities, err = s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -155,13 +163,14 @@ func TestList_FilterByDate(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_CardsOnly(t *testing.T) {
|
func TestList_CardsOnly(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote})
|
ctx := context.Background()
|
||||||
|
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote})
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
|
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
p.CardsOnly = true
|
p.CardsOnly = true
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -175,12 +184,13 @@ func TestList_CardsOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_IncludeDeleted(t *testing.T) {
|
func TestList_IncludeDeleted(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
s.SoftDelete(e.ID)
|
s.SoftDelete(ctx, e.ID)
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -189,7 +199,7 @@ func TestList_IncludeDeleted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.IncludeDeleted = true
|
p.IncludeDeleted = true
|
||||||
entities, err = s.List(p)
|
entities, err = s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -200,17 +210,18 @@ func TestList_IncludeDeleted(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_SortByUseCount(t *testing.T) {
|
func TestList_SortByUseCount(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
|
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
|
||||||
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
|
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
|
||||||
s.Create(e1)
|
s.Create(ctx, e1)
|
||||||
s.Create(e2)
|
s.Create(ctx, e2)
|
||||||
s.IncrementUse(e2.ID)
|
s.IncrementUse(ctx, e2.ID)
|
||||||
s.IncrementUse(e2.ID)
|
s.IncrementUse(ctx, e2.ID)
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
p.Sort = "use_count"
|
p.Sort = "use_count"
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -221,14 +232,15 @@ func TestList_SortByUseCount(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_Pagination(t *testing.T) {
|
func TestList_Pagination(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
|
||||||
}
|
}
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
p.Limit = 3
|
p.Limit = 3
|
||||||
p.Offset = 0
|
p.Offset = 0
|
||||||
page1, err := s.List(p)
|
page1, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +249,7 @@ func TestList_Pagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.Offset = 3
|
p.Offset = 3
|
||||||
page2, err := s.List(p)
|
page2, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -251,16 +263,17 @@ func TestList_Pagination(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Body(t *testing.T) {
|
func TestUpdate_Body(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "old", Glyph: GlyphNote}
|
e := &Entity{Body: "old", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
time.Sleep(1100 * time.Millisecond)
|
||||||
newBody := "new"
|
newBody := "new"
|
||||||
if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil {
|
if err := s.Update(ctx, e.ID, &EntityUpdate{Body: &newBody}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Body != "new" {
|
if got.Body != "new" {
|
||||||
t.Errorf("body not updated: %q", got.Body)
|
t.Errorf("body not updated: %q", got.Body)
|
||||||
}
|
}
|
||||||
@@ -271,15 +284,16 @@ func TestUpdate_Body(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Tags(t *testing.T) {
|
func TestUpdate_Tags(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
|
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
newTags := []string{"new1", "new2"}
|
newTags := []string{"new1", "new2"}
|
||||||
if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
|
if err := s.Update(ctx, e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if len(got.Tags) != 2 {
|
if len(got.Tags) != 2 {
|
||||||
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
|
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
|
||||||
}
|
}
|
||||||
@@ -287,14 +301,15 @@ func TestUpdate_Tags(t *testing.T) {
|
|||||||
|
|
||||||
func TestPromote_Success(t *testing.T) {
|
func TestPromote_Success(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
if err := s.Promote(e.ID, CardSnippet, nil); err != nil {
|
if err := s.Promote(ctx, e.ID, CardSnippet, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.CardType == nil || *got.CardType != CardSnippet {
|
if got.CardType == nil || *got.CardType != CardSnippet {
|
||||||
t.Errorf("expected snippet, got %v", got.CardType)
|
t.Errorf("expected snippet, got %v", got.CardType)
|
||||||
}
|
}
|
||||||
@@ -302,26 +317,28 @@ func TestPromote_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestPromote_AlreadyPromoted(t *testing.T) {
|
func TestPromote_AlreadyPromoted(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
|
if err := s.Promote(ctx, e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
|
||||||
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
|
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDemote_Success(t *testing.T) {
|
func TestDemote_Success(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
s.Promote(e.ID, CardSnippet, nil)
|
s.Promote(ctx, e.ID, CardSnippet, nil)
|
||||||
|
|
||||||
if err := s.Demote(e.ID); err != nil {
|
if err := s.Demote(ctx, e.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.CardType != nil {
|
if got.CardType != nil {
|
||||||
t.Errorf("expected nil card_type, got %v", got.CardType)
|
t.Errorf("expected nil card_type, got %v", got.CardType)
|
||||||
}
|
}
|
||||||
@@ -332,20 +349,22 @@ func TestDemote_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestDemote_AlreadyFluid(t *testing.T) {
|
func TestDemote_AlreadyFluid(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
if err := s.Demote(e.ID); err != ErrAlreadyFluid {
|
if err := s.Demote(ctx, e.ID); err != ErrAlreadyFluid {
|
||||||
t.Errorf("expected ErrAlreadyFluid, got %v", err)
|
t.Errorf("expected ErrAlreadyFluid, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSoftDelete_First(t *testing.T) {
|
func TestSoftDelete_First(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
result, err := s.SoftDelete(e.ID)
|
result, err := s.SoftDelete(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -353,7 +372,7 @@ func TestSoftDelete_First(t *testing.T) {
|
|||||||
t.Errorf("expected DeletedSoft, got %d", result)
|
t.Errorf("expected DeletedSoft, got %d", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.DeletedAt == nil {
|
if got.DeletedAt == nil {
|
||||||
t.Error("expected deleted_at to be set")
|
t.Error("expected deleted_at to be set")
|
||||||
}
|
}
|
||||||
@@ -361,11 +380,12 @@ func TestSoftDelete_First(t *testing.T) {
|
|||||||
|
|
||||||
func TestSoftDelete_Second(t *testing.T) {
|
func TestSoftDelete_Second(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
s.SoftDelete(e.ID)
|
s.SoftDelete(ctx, e.ID)
|
||||||
result, err := s.SoftDelete(e.ID)
|
result, err := s.SoftDelete(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -373,7 +393,7 @@ func TestSoftDelete_Second(t *testing.T) {
|
|||||||
t.Errorf("expected DeletedHard, got %d", result)
|
t.Errorf("expected DeletedHard, got %d", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.Get(e.ID)
|
_, err = s.Get(ctx, e.ID)
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
|
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -381,7 +401,7 @@ func TestSoftDelete_Second(t *testing.T) {
|
|||||||
|
|
||||||
func TestSoftDelete_NotFound(t *testing.T) {
|
func TestSoftDelete_NotFound(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
_, err := s.SoftDelete("01NONEXISTENT0000000000000")
|
_, err := s.SoftDelete(context.Background(), "01NONEXISTENT0000000000000")
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -389,15 +409,16 @@ func TestSoftDelete_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestIncrementUse(t *testing.T) {
|
func TestIncrementUse(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
if err := s.IncrementUse(e.ID); err != nil {
|
if err := s.IncrementUse(ctx, e.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.UseCount != 1 {
|
if got.UseCount != 1 {
|
||||||
t.Errorf("expected use_count=1, got %d", got.UseCount)
|
t.Errorf("expected use_count=1, got %d", got.UseCount)
|
||||||
}
|
}
|
||||||
@@ -408,10 +429,11 @@ func TestIncrementUse(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolve_FullID(t *testing.T) {
|
func TestResolve_FullID(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "test", Glyph: GlyphNote}
|
e := &Entity{Body: "test", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
got, err := s.Resolve(e.ID)
|
got, err := s.Resolve(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -422,10 +444,11 @@ func TestResolve_FullID(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolve_Prefix(t *testing.T) {
|
func TestResolve_Prefix(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "test", Glyph: GlyphNote}
|
e := &Entity{Body: "test", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
got, err := s.Resolve(e.ID[:6])
|
got, err := s.Resolve(ctx, e.ID[:6])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -436,14 +459,47 @@ func TestResolve_Prefix(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolve_NotFound(t *testing.T) {
|
func TestResolve_NotFound(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
_, err := s.Resolve("ZZZZZZZZZ")
|
_, err := s.Resolve(context.Background(), "ZZZZZZZZZ")
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAbsorb_SourceIsCard(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
|
||||||
|
s.Create(ctx, target)
|
||||||
|
|
||||||
|
source := &Entity{Body: "source", Glyph: GlyphNote}
|
||||||
|
s.Create(ctx, source)
|
||||||
|
s.Promote(ctx, source.ID, CardSnippet, nil)
|
||||||
|
s.IncrementUse(ctx, source.ID)
|
||||||
|
|
||||||
|
if err := s.Absorb(ctx, target.ID, source.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := s.Get(ctx, target.ID)
|
||||||
|
if got.Body != "target\nsource" {
|
||||||
|
t.Errorf("merged body: %q", got.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, _ := s.Get(ctx, source.ID)
|
||||||
|
if src.CardType != nil {
|
||||||
|
t.Error("source card_type should be cleared after absorb")
|
||||||
|
}
|
||||||
|
if src.UseCount != 0 {
|
||||||
|
t.Errorf("source use_count should be reset, got %d", src.UseCount)
|
||||||
|
}
|
||||||
|
if src.DeletedAt == nil {
|
||||||
|
t.Error("source should be soft-deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreate_WithTitleAndDescription(t *testing.T) {
|
func TestCreate_WithTitleAndDescription(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{
|
e := &Entity{
|
||||||
Body: "body text",
|
Body: "body text",
|
||||||
Title: ptr("nginx trick"),
|
Title: ptr("nginx trick"),
|
||||||
@@ -451,11 +507,11 @@ func TestCreate_WithTitleAndDescription(t *testing.T) {
|
|||||||
Glyph: GlyphNote,
|
Glyph: GlyphNote,
|
||||||
Tags: []string{"ops"},
|
Tags: []string{"ops"},
|
||||||
}
|
}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -472,12 +528,13 @@ func TestCreate_WithTitleAndDescription(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_WithoutTitle(t *testing.T) {
|
func TestCreate_WithoutTitle(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "just body", Glyph: GlyphNote}
|
e := &Entity{Body: "just body", Glyph: GlyphNote}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Title != nil {
|
if got.Title != nil {
|
||||||
t.Errorf("expected nil title, got %v", got.Title)
|
t.Errorf("expected nil title, got %v", got.Title)
|
||||||
}
|
}
|
||||||
@@ -488,15 +545,16 @@ func TestCreate_WithoutTitle(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Title(t *testing.T) {
|
func TestUpdate_Title(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "body", Glyph: GlyphNote}
|
e := &Entity{Body: "body", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
newTitle := "new title"
|
newTitle := "new title"
|
||||||
if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
|
if err := s.Update(ctx, e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Title == nil || *got.Title != "new title" {
|
if got.Title == nil || *got.Title != "new title" {
|
||||||
t.Errorf("title: got %v", got.Title)
|
t.Errorf("title: got %v", got.Title)
|
||||||
}
|
}
|
||||||
@@ -504,15 +562,16 @@ func TestUpdate_Title(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Description(t *testing.T) {
|
func TestUpdate_Description(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "body", Glyph: GlyphNote}
|
e := &Entity{Body: "body", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
newDesc := "new desc"
|
newDesc := "new desc"
|
||||||
if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
|
if err := s.Update(ctx, e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Description == nil || *got.Description != "new desc" {
|
if got.Description == nil || *got.Description != "new desc" {
|
||||||
t.Errorf("description: got %v", got.Description)
|
t.Errorf("description: got %v", got.Description)
|
||||||
}
|
}
|
||||||
@@ -520,16 +579,17 @@ func TestUpdate_Description(t *testing.T) {
|
|||||||
|
|
||||||
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
|
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
|
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
|
||||||
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
|
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
|
||||||
s.Create(target)
|
s.Create(ctx, target)
|
||||||
s.Create(source)
|
s.Create(ctx, source)
|
||||||
|
|
||||||
if err := s.Absorb(target.ID, source.ID); err != nil {
|
if err := s.Absorb(ctx, target.ID, source.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(target.ID)
|
got, _ := s.Get(ctx, target.ID)
|
||||||
if got.Title == nil || *got.Title != "target title" {
|
if got.Title == nil || *got.Title != "target title" {
|
||||||
t.Errorf("target title should be preserved, got %v", got.Title)
|
t.Errorf("target title should be preserved, got %v", got.Title)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backlink struct {
|
||||||
|
EntityID string
|
||||||
|
Title *string
|
||||||
|
Body string
|
||||||
|
LinkText string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
|
||||||
|
lower := strings.ToLower(linkText)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM entities
|
||||||
|
WHERE LOWER(title) = ? AND id != ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, lower, excludeID).Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM entities
|
||||||
|
WHERE LOWER(body) LIKE ? AND id != ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%", excludeID).Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body string) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_links WHERE from_id = ?", entityID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
linkTexts := link.ExtractLinks(body)
|
||||||
|
for _, lt := range linkTexts {
|
||||||
|
toID := s.resolveLink(ctx, tx, lt, entityID)
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT OR IGNORE INTO entity_links (from_id, to_id, link_text) VALUES (?, ?, ?)",
|
||||||
|
entityID, toID, lt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT e.id, e.title, e.body, el.link_text
|
||||||
|
FROM entity_links el
|
||||||
|
JOIN entities e ON e.id = el.from_id
|
||||||
|
WHERE el.to_id = ? AND e.deleted_at IS NULL
|
||||||
|
ORDER BY e.created_at DESC`, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var backlinks []Backlink
|
||||||
|
for rows.Next() {
|
||||||
|
var bl Backlink
|
||||||
|
var title sql.NullString
|
||||||
|
if err := rows.Scan(&bl.EntityID, &title, &bl.Body, &bl.LinkText); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if title.Valid {
|
||||||
|
bl.Title = &title.String
|
||||||
|
}
|
||||||
|
backlinks = append(backlinks, bl)
|
||||||
|
}
|
||||||
|
return backlinks, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncLinks_OnCreate(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &Entity{Body: "nginx proxy config", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := &Entity{Body: "see [[nginx proxy config]] for setup", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backlinks) != 1 {
|
||||||
|
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
|
||||||
|
}
|
||||||
|
if backlinks[0].EntityID != source.ID {
|
||||||
|
t.Errorf("backlink entity = %s, want %s", backlinks[0].EntityID, source.ID)
|
||||||
|
}
|
||||||
|
if backlinks[0].LinkText != "nginx proxy config" {
|
||||||
|
t.Errorf("link text = %q, want %q", backlinks[0].LinkText, "nginx proxy config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_TitleMatch(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
title := "deploy checklist"
|
||||||
|
target := &Entity{Body: "steps to deploy", Title: &title, Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := &Entity{Body: "follow [[deploy checklist]]", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backlinks) != 1 {
|
||||||
|
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_TitlePriority(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
title := "nginx config"
|
||||||
|
titled := &Entity{Body: "some body", Title: &title, Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, titled); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyMatch := &Entity{Body: "nginx config details", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, bodyMatch); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := &Entity{Body: "see [[nginx config]]", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backlinks, err := s.LoadBacklinks(ctx, titled.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backlinks) != 1 {
|
||||||
|
t.Fatalf("title match should win, got %d backlinks on titled entity", len(backlinks))
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBacklinks, err := s.LoadBacklinks(ctx, bodyMatch.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(bodyBacklinks) != 0 {
|
||||||
|
t.Fatalf("body match entity should have 0 backlinks, got %d", len(bodyBacklinks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_Unresolved(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
source := &Entity{Body: "see [[nonexistent entry]]", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", source.ID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1 link row (unresolved), got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var toID *string
|
||||||
|
err = s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", source.ID).Scan(&toID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if toID != nil {
|
||||||
|
t.Errorf("expected NULL to_id for unresolved link, got %v", *toID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_OnUpdate(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &Entity{Body: "original target", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := &Entity{Body: "no links yet", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newBody := "now has [[original target]]"
|
||||||
|
if err := s.Update(ctx, source.ID, &EntityUpdate{Body: &newBody}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backlinks) != 1 {
|
||||||
|
t.Fatalf("expected 1 backlink after update, got %d", len(backlinks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_SelfLinkUnresolved(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
e := &Entity{Body: "I reference [[I reference]]", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var toID *string
|
||||||
|
err := s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", e.ID).Scan(&toID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if toID != nil {
|
||||||
|
t.Fatalf("self-matching link should be unresolved (NULL to_id), got %v", *toID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_NoLinks(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
e := &Entity{Body: "plain text no links", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", e.ID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("expected 0 links, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &Entity{Body: "target entry", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := &Entity{Body: "see [[target entry]]", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backlinks) != 1 {
|
||||||
|
t.Fatalf("expected 1 backlink before delete, got %d", len(backlinks))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.SoftDelete(ctx, source.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backlinks, err = s.LoadBacklinks(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backlinks) != 0 {
|
||||||
|
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-3
@@ -1,16 +1,22 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
type TagCount struct {
|
type TagCount struct {
|
||||||
Tag string
|
Tag string
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListTags() ([]TagCount, error) {
|
func (s *Store) ListTags(ctx context.Context, cardsOnly bool) ([]TagCount, error) {
|
||||||
rows, err := s.db.Query(`
|
where := "WHERE e.deleted_at IS NULL"
|
||||||
|
if cardsOnly {
|
||||||
|
where += " AND e.card_type IS NOT NULL"
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT t.tag, COUNT(*) as cnt
|
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 {
|
||||||
@@ -26,6 +32,9 @@ func (s *Store) ListTags() ([]TagCount, error) {
|
|||||||
}
|
}
|
||||||
tags = append(tags, tc)
|
tags = append(tags, tc)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if tags == nil {
|
if tags == nil {
|
||||||
tags = []TagCount{}
|
tags = []TagCount{}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-10
@@ -1,10 +1,13 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"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(context.Background(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -15,11 +18,12 @@ func TestListTags_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTags_Counts(t *testing.T) {
|
func TestListTags_Counts(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
|
ctx := context.Background()
|
||||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
|
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
|
||||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
|
s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
|
||||||
|
s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
|
||||||
|
|
||||||
tags, err := s.ListTags()
|
tags, err := s.ListTags(ctx, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -44,13 +48,14 @@ func TestListTags_Counts(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTags_ExcludesDeleted(t *testing.T) {
|
func TestListTags_ExcludesDeleted(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
s.SoftDelete(e.ID)
|
s.SoftDelete(ctx, e.ID)
|
||||||
|
|
||||||
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
|
s.Create(ctx, &Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
|
||||||
|
|
||||||
tags, err := s.ListTags()
|
tags, err := s.ListTags(ctx, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,3 +66,38 @@ 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)
|
||||||
|
ctx := context.Background()
|
||||||
|
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}})
|
||||||
|
|
||||||
|
ct := CardSnippet
|
||||||
|
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct})
|
||||||
|
|
||||||
|
all, err := s.ListTags(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("all tags: expected 3, got %d", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
cards, err := s.ListTags(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cards) != 2 {
|
||||||
|
t.Fatalf("card tags: expected 2, got %d", len(cards))
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, tc := range cards {
|
||||||
|
counts[tc.Tag] = tc.Count
|
||||||
|
}
|
||||||
|
if counts["ops"] != 1 {
|
||||||
|
t.Errorf("ops count: expected 1 (card only), got %d", counts["ops"])
|
||||||
|
}
|
||||||
|
if counts["code"] != 1 {
|
||||||
|
t.Errorf("code count: expected 1, got %d", counts["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
@@ -14,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{
|
|||||||
db.CardChecklist: "☐",
|
db.CardChecklist: "☐",
|
||||||
db.CardDecision: "⚖",
|
db.CardDecision: "⚖",
|
||||||
db.CardLink: "↗",
|
db.CardLink: "↗",
|
||||||
|
db.CardNote: "¶",
|
||||||
}
|
}
|
||||||
|
|
||||||
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayGlyph_Fluid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
glyph db.Glyph
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{db.GlyphNote, "—"},
|
||||||
|
{db.GlyphTodo, "○"},
|
||||||
|
{db.GlyphEvent, "◇"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := DisplayGlyph(tt.glyph, nil)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("DisplayGlyph(%q, nil) = %q, want %q", tt.glyph, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayGlyph_Card(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
cardType db.CardType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{db.CardSnippet, "◆"},
|
||||||
|
{db.CardTemplate, "◈"},
|
||||||
|
{db.CardChecklist, "☐"},
|
||||||
|
{db.CardDecision, "⚖"},
|
||||||
|
{db.CardLink, "↗"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
ct := tt.cardType
|
||||||
|
got := DisplayGlyph(db.GlyphNote, &ct)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("DisplayGlyph(note, %q) = %q, want %q", tt.cardType, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayGlyph_CardOverridesGlyph(t *testing.T) {
|
||||||
|
ct := db.CardSnippet
|
||||||
|
got := DisplayGlyph(db.GlyphTodo, &ct)
|
||||||
|
if got != "◆" {
|
||||||
|
t.Errorf("card_type should override glyph, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayGlyph_UnknownFallback(t *testing.T) {
|
||||||
|
got := DisplayGlyph(db.Glyph("unknown"), nil)
|
||||||
|
if got != "—" {
|
||||||
|
t.Errorf("unknown glyph should fall back to —, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatID_Long(t *testing.T) {
|
||||||
|
got := FormatID("01HXYZ1234567890ABCDEFGH")
|
||||||
|
if got != "01HXYZ123456" {
|
||||||
|
t.Errorf("expected 12-char truncation, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatID_Short(t *testing.T) {
|
||||||
|
got := FormatID("ABC")
|
||||||
|
if got != "ABC" {
|
||||||
|
t.Errorf("short ID should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatID_Exact12(t *testing.T) {
|
||||||
|
got := FormatID("123456789012")
|
||||||
|
if got != "123456789012" {
|
||||||
|
t.Errorf("exact 12-char should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed template.html
|
||||||
|
var templateHTML string
|
||||||
|
|
||||||
|
type TemplateSlot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckStep struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecisionData struct {
|
||||||
|
Chose string `json:"chose"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Rejected []string `json:"rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardView struct {
|
||||||
|
ID string
|
||||||
|
Glyph string
|
||||||
|
CardType string
|
||||||
|
Title string
|
||||||
|
Body template.HTML
|
||||||
|
Description template.HTML
|
||||||
|
SearchText string
|
||||||
|
Pinned bool
|
||||||
|
Tags []string
|
||||||
|
UseCount int
|
||||||
|
LinkURL string
|
||||||
|
Slots []TemplateSlot
|
||||||
|
TemplateBody string
|
||||||
|
Steps []CheckStep
|
||||||
|
Progress int
|
||||||
|
Decision DecisionData
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeckView struct {
|
||||||
|
Title string
|
||||||
|
Count int
|
||||||
|
ExportedAt string
|
||||||
|
Types []string
|
||||||
|
Cards []CardView
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderHTML(w io.Writer, entities []*db.Entity, title string) error {
|
||||||
|
tmpl, err := template.New("deck").Parse(templateHTML)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var types []string
|
||||||
|
var cards []CardView
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.CardType == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ct := string(*e.CardType)
|
||||||
|
if !seen[ct] {
|
||||||
|
seen[ct] = true
|
||||||
|
types = append(types, ct)
|
||||||
|
}
|
||||||
|
cards = append(cards, buildCardView(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := DeckView{
|
||||||
|
Title: title,
|
||||||
|
Count: len(cards),
|
||||||
|
ExportedAt: time.Now().Format("Jan 2, 2006"),
|
||||||
|
Types: types,
|
||||||
|
Cards: cards,
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl.Execute(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardView(e *db.Entity) CardView {
|
||||||
|
ct := ""
|
||||||
|
if e.CardType != nil {
|
||||||
|
ct = string(*e.CardType)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ""
|
||||||
|
if e.Title != nil {
|
||||||
|
title = *e.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
desc := ""
|
||||||
|
if e.Description != nil {
|
||||||
|
desc = *e.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
search := strings.ToLower(title + " " + body + " " + desc + " " + strings.Join(e.Tags, " "))
|
||||||
|
|
||||||
|
cv := CardView{
|
||||||
|
ID: e.ID,
|
||||||
|
Glyph: display.DisplayGlyph(e.Glyph, e.CardType),
|
||||||
|
CardType: ct,
|
||||||
|
Title: title,
|
||||||
|
Body: template.HTML(template.HTMLEscapeString(body)),
|
||||||
|
SearchText: search,
|
||||||
|
Pinned: e.Pinned,
|
||||||
|
Tags: e.Tags,
|
||||||
|
UseCount: e.UseCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc != "" {
|
||||||
|
cv.Description = template.HTML(template.HTMLEscapeString(desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.CardData != nil {
|
||||||
|
parseCardData(&cv, ct, *e.CardData, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cv
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCardData(cv *CardView, ct string, raw string, body string) {
|
||||||
|
switch ct {
|
||||||
|
case "snippet":
|
||||||
|
// body is the snippet content, already set
|
||||||
|
|
||||||
|
case "template":
|
||||||
|
var data struct {
|
||||||
|
Slots []TemplateSlot `json:"slots"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||||
|
cv.Slots = data.Slots
|
||||||
|
}
|
||||||
|
cv.TemplateBody = body
|
||||||
|
|
||||||
|
case "checklist":
|
||||||
|
var data struct {
|
||||||
|
Steps []CheckStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||||
|
cv.Steps = data.Steps
|
||||||
|
done := 0
|
||||||
|
for _, s := range cv.Steps {
|
||||||
|
if s.Done {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cv.Steps) > 0 {
|
||||||
|
cv.Progress = (done * 100) / len(cv.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "decision":
|
||||||
|
var dec DecisionData
|
||||||
|
if json.Unmarshal([]byte(raw), &dec) == nil {
|
||||||
|
cv.Decision = dec
|
||||||
|
}
|
||||||
|
|
||||||
|
case "link":
|
||||||
|
var data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||||
|
cv.LinkURL = data.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
|
||||||
|
--border:#2e2e2c;--border-focus:#4a4a47;
|
||||||
|
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
|
||||||
|
--accent:#c4a46c;--accent2:#a68a52;
|
||||||
|
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
|
||||||
|
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
|
||||||
|
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
|
||||||
|
--card-radius:12px;
|
||||||
|
--safe-bottom:env(safe-area-inset-bottom,0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||||
|
body{
|
||||||
|
font-family:var(--sans);color:var(--text);background:var(--bg);
|
||||||
|
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-header{
|
||||||
|
position:sticky;top:0;z-index:10;
|
||||||
|
background:var(--bg);border-bottom:1px solid var(--border);
|
||||||
|
padding:16px 20px 12px;
|
||||||
|
}
|
||||||
|
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
|
||||||
|
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
|
||||||
|
|
||||||
|
.filter-bar{
|
||||||
|
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
|
||||||
|
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||||
|
}
|
||||||
|
.filter-bar::-webkit-scrollbar{display:none}
|
||||||
|
.filter-chip{
|
||||||
|
flex-shrink:0;padding:6px 14px;border-radius:20px;
|
||||||
|
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
|
||||||
|
background:var(--surface);color:var(--text2);cursor:pointer;
|
||||||
|
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
|
||||||
|
}
|
||||||
|
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||||
|
|
||||||
|
.search-wrap{padding:8px 20px 4px}
|
||||||
|
.search-input{
|
||||||
|
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
|
||||||
|
background:var(--surface);color:var(--text);font-size:0.9rem;
|
||||||
|
font-family:var(--sans);outline:none;transition:border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-input:focus{border-color:var(--accent)}
|
||||||
|
.search-input::placeholder{color:var(--text3)}
|
||||||
|
|
||||||
|
.card-list{padding:8px 16px}
|
||||||
|
|
||||||
|
.card{
|
||||||
|
background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
|
||||||
|
transition:border-color 0.15s;position:relative;
|
||||||
|
}
|
||||||
|
.card:active{border-color:var(--border-focus)}
|
||||||
|
.card.pinned{border-left:3px solid var(--accent)}
|
||||||
|
|
||||||
|
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||||
|
.card-glyph{
|
||||||
|
font-size:1.1rem;width:28px;height:28px;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
background:var(--surface2);border-radius:6px;flex-shrink:0;
|
||||||
|
}
|
||||||
|
.card-type{
|
||||||
|
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
|
||||||
|
color:var(--text3);
|
||||||
|
}
|
||||||
|
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
|
||||||
|
|
||||||
|
.card-title{
|
||||||
|
font-size:0.95rem;font-weight:600;color:var(--text);
|
||||||
|
line-height:1.4;margin-bottom:6px;
|
||||||
|
}
|
||||||
|
.card-body{
|
||||||
|
font-size:0.85rem;color:var(--text2);line-height:1.55;
|
||||||
|
white-space:pre-wrap;word-break:break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body code{
|
||||||
|
font-family:var(--mono);font-size:0.8rem;
|
||||||
|
background:var(--surface2);padding:2px 5px;border-radius:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
||||||
|
.tag{
|
||||||
|
font-size:0.7rem;color:var(--accent);background:var(--surface2);
|
||||||
|
padding:3px 8px;border-radius:6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* snippet */
|
||||||
|
.snippet-block{
|
||||||
|
background:var(--bg);border:1px solid var(--border);border-radius:8px;
|
||||||
|
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
|
||||||
|
}
|
||||||
|
.snippet-block pre{
|
||||||
|
font-family:var(--mono);font-size:0.8rem;color:var(--text);
|
||||||
|
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
|
||||||
|
}
|
||||||
|
.copy-btn{
|
||||||
|
position:absolute;top:8px;right:8px;
|
||||||
|
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
|
||||||
|
background:var(--surface);color:var(--text2);font-size:0.7rem;
|
||||||
|
cursor:pointer;transition:all 0.15s;
|
||||||
|
}
|
||||||
|
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||||
|
|
||||||
|
/* checklist */
|
||||||
|
.checklist{margin-top:8px;list-style:none}
|
||||||
|
.checklist li{
|
||||||
|
display:flex;align-items:flex-start;gap:10px;
|
||||||
|
padding:8px 0;border-bottom:1px solid var(--border);
|
||||||
|
font-size:0.85rem;color:var(--text2);
|
||||||
|
}
|
||||||
|
.checklist li:last-child{border-bottom:none}
|
||||||
|
.check-box{
|
||||||
|
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
|
||||||
|
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||||
|
transition:all 0.15s;margin-top:1px;
|
||||||
|
}
|
||||||
|
.check-box.checked{background:var(--green);border-color:var(--green)}
|
||||||
|
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
|
||||||
|
.check-text.done{text-decoration:line-through;color:var(--text3)}
|
||||||
|
.progress-bar{
|
||||||
|
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
|
||||||
|
}
|
||||||
|
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
|
||||||
|
|
||||||
|
/* template */
|
||||||
|
.template-field{margin-top:8px}
|
||||||
|
.template-field label{
|
||||||
|
display:block;font-size:0.7rem;font-weight:600;
|
||||||
|
text-transform:uppercase;letter-spacing:0.05em;
|
||||||
|
color:var(--text3);margin-bottom:4px;
|
||||||
|
}
|
||||||
|
.template-field input{
|
||||||
|
width:100%;padding:8px 12px;border-radius:8px;
|
||||||
|
border:1px solid var(--border);background:var(--surface2);
|
||||||
|
color:var(--text);font-size:0.85rem;font-family:var(--sans);
|
||||||
|
outline:none;transition:border-color 0.15s;
|
||||||
|
}
|
||||||
|
.template-field input:focus{border-color:var(--accent)}
|
||||||
|
.template-output{margin-top:10px}
|
||||||
|
.template-copy-btn{
|
||||||
|
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
|
||||||
|
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
|
||||||
|
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
|
||||||
|
}
|
||||||
|
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
|
||||||
|
|
||||||
|
/* decision */
|
||||||
|
.decision-grid{margin-top:8px}
|
||||||
|
.decision-row{
|
||||||
|
padding:8px 0;border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.decision-row:last-child{border-bottom:none}
|
||||||
|
.decision-label{
|
||||||
|
font-size:0.7rem;font-weight:600;text-transform:uppercase;
|
||||||
|
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
|
||||||
|
}
|
||||||
|
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
|
||||||
|
.decision-rejected{color:var(--red);font-style:italic}
|
||||||
|
|
||||||
|
/* link */
|
||||||
|
.link-target{
|
||||||
|
display:flex;align-items:center;gap:10px;margin-top:8px;
|
||||||
|
padding:12px;background:var(--bg);border:1px solid var(--border);
|
||||||
|
border-radius:8px;text-decoration:none;color:var(--text);
|
||||||
|
transition:border-color 0.15s;
|
||||||
|
}
|
||||||
|
.link-target:active{border-color:var(--accent)}
|
||||||
|
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
|
||||||
|
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
|
||||||
|
|
||||||
|
.empty-state{
|
||||||
|
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(min-width:640px){
|
||||||
|
.card-list{
|
||||||
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
|
||||||
|
gap:10px;padding:8px 20px;
|
||||||
|
}
|
||||||
|
.card{margin-bottom:0}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="deck-header">
|
||||||
|
<div class="deck-title">{{.Title}}</div>
|
||||||
|
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input type="text" class="search-input" placeholder="search cards…" id="search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar" id="filters">
|
||||||
|
<button class="filter-chip active" data-type="all">All</button>
|
||||||
|
{{range .Types}}
|
||||||
|
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-list" id="cards">
|
||||||
|
{{range .Cards}}
|
||||||
|
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="card-glyph">{{.Glyph}}</span>
|
||||||
|
<span class="card-type">{{.CardType}}</span>
|
||||||
|
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if eq .CardType "snippet"}}
|
||||||
|
<div class="snippet-block">
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">copy</button>
|
||||||
|
<pre>{{.Body}}</pre>
|
||||||
|
</div>
|
||||||
|
{{else if eq .CardType "checklist"}}
|
||||||
|
<div class="card-body">{{.Description}}</div>
|
||||||
|
<ul class="checklist" data-card-id="{{.ID}}">
|
||||||
|
{{range $i, $step := .Steps}}
|
||||||
|
<li>
|
||||||
|
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
|
||||||
|
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
|
||||||
|
{{else if eq .CardType "template"}}
|
||||||
|
<div class="card-body">{{.Description}}</div>
|
||||||
|
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
|
||||||
|
{{range .Slots}}
|
||||||
|
<div class="template-field">
|
||||||
|
<label>{{.Name}}</label>
|
||||||
|
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="template-output">
|
||||||
|
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else if eq .CardType "decision"}}
|
||||||
|
<div class="decision-grid">
|
||||||
|
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
|
||||||
|
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
|
||||||
|
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
|
||||||
|
{{else if eq .CardType "link"}}
|
||||||
|
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
|
||||||
|
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
|
||||||
|
<span class="link-url">{{.LinkURL}}</span>
|
||||||
|
<span class="link-arrow">↗</span>
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<div class="card-body">{{.Body}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Tags}}
|
||||||
|
<div class="card-tags">
|
||||||
|
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const cards=document.querySelectorAll('.card');
|
||||||
|
const chips=document.querySelectorAll('.filter-chip');
|
||||||
|
const search=document.getElementById('search');
|
||||||
|
const empty=document.getElementById('empty');
|
||||||
|
let activeType='all';
|
||||||
|
|
||||||
|
function applyFilters(){
|
||||||
|
const q=search.value.toLowerCase().trim();
|
||||||
|
let visible=0;
|
||||||
|
cards.forEach(c=>{
|
||||||
|
const typeMatch=activeType==='all'||c.dataset.type===activeType;
|
||||||
|
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
|
||||||
|
c.style.display=(typeMatch&&searchMatch)?'':'none';
|
||||||
|
if(typeMatch&&searchMatch)visible++;
|
||||||
|
});
|
||||||
|
empty.style.display=visible?'none':'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.forEach(chip=>{
|
||||||
|
chip.addEventListener('click',()=>{
|
||||||
|
chips.forEach(c=>c.classList.remove('active'));
|
||||||
|
chip.classList.add('active');
|
||||||
|
activeType=chip.dataset.type;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
search.addEventListener('input',applyFilters);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function copyText(btn){
|
||||||
|
const pre=btn.parentElement.querySelector('pre');
|
||||||
|
navigator.clipboard.writeText(pre.textContent).then(()=>{
|
||||||
|
btn.textContent='copied!';
|
||||||
|
setTimeout(()=>btn.textContent='copy',1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheck(box){
|
||||||
|
box.classList.toggle('checked');
|
||||||
|
const span=box.nextElementSibling;
|
||||||
|
span.classList.toggle('done');
|
||||||
|
const list=box.closest('.checklist');
|
||||||
|
const boxes=list.querySelectorAll('.check-box');
|
||||||
|
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
|
||||||
|
const bar=list.nextElementSibling.querySelector('.progress-fill');
|
||||||
|
bar.style.width=Math.round((done/boxes.length)*100)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemplate(input){
|
||||||
|
const form=input.closest('.template-form');
|
||||||
|
const tmpl=form.dataset.template;
|
||||||
|
let filled=tmpl;
|
||||||
|
form.querySelectorAll('input[data-slot]').forEach(inp=>{
|
||||||
|
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
|
||||||
|
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
|
||||||
|
});
|
||||||
|
form._filled=filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTemplate(btn){
|
||||||
|
const form=btn.closest('.template-form');
|
||||||
|
const text=form._filled||form.dataset.template;
|
||||||
|
navigator.clipboard.writeText(text).then(()=>{
|
||||||
|
btn.textContent='copied!';
|
||||||
|
setTimeout(()=>btn.textContent='copy filled template',1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var linkRe = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
||||||
|
|
||||||
|
func ExtractLinks(body string) []string {
|
||||||
|
matches := linkRe.FindAllStringSubmatch(body, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var result []string
|
||||||
|
for _, m := range matches {
|
||||||
|
text := strings.TrimSpace(m[1])
|
||||||
|
if text == "" || seen[text] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[text] = true
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"no links", "plain text with no links", nil},
|
||||||
|
{"single link", "see [[nginx config]] for details", []string{"nginx config"}},
|
||||||
|
{"multiple links", "see [[nginx config]] and [[deploy steps]]", []string{"nginx config", "deploy steps"}},
|
||||||
|
{"duplicate deduped", "[[foo]] then [[foo]] again", []string{"foo"}},
|
||||||
|
{"empty brackets", "empty [[ ]] ignored", nil},
|
||||||
|
{"just brackets no content", "[[]] empty", nil},
|
||||||
|
{"link with special chars", "see [[deploy: staging (v2)]]", []string{"deploy: staging (v2)"}},
|
||||||
|
{"link in markdown", "# heading\n\nsee [[my note]] for info", []string{"my note"}},
|
||||||
|
{"adjacent links", "[[one]][[two]]", []string{"one", "two"}},
|
||||||
|
{"partial brackets ignored", "not a [link] or [[incomplete", nil},
|
||||||
|
{"link with hash", "see [[#ops channel]]", []string{"#ops channel"}},
|
||||||
|
{"multiline body", "line one [[link one]]\nline two [[link two]]", []string{"link one", "link two"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ExtractLinks(tt.body)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
-53
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -13,7 +14,13 @@ 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
|
||||||
|
QueryDateFrom *string
|
||||||
|
QueryDateTo *string
|
||||||
|
QueryCardType *string
|
||||||
}
|
}
|
||||||
|
|
||||||
var validCardTypes = map[string]string{
|
var validCardTypes = map[string]string{
|
||||||
@@ -24,6 +31,8 @@ var validCardTypes = map[string]string{
|
|||||||
"checklist": "checklist",
|
"checklist": "checklist",
|
||||||
"decision": "decision",
|
"decision": "decision",
|
||||||
"link": "link",
|
"link": "link",
|
||||||
|
"note": "note",
|
||||||
|
"n": "note",
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(input string) (*Result, error) {
|
func Parse(input string) (*Result, error) {
|
||||||
@@ -39,26 +48,107 @@ 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
|
||||||
|
now := time.Now()
|
||||||
|
for _, tok := range tokens {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"):
|
||||||
|
tag := strings.ToLower(tok[1:])
|
||||||
|
r.FilterTags = append(r.FilterTags, tag)
|
||||||
|
case tok == "@today":
|
||||||
|
d := now.Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
r.QueryDateTo = &d
|
||||||
|
case tok == "@yesterday":
|
||||||
|
d := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
r.QueryDateTo = &d
|
||||||
|
case tok == "@week":
|
||||||
|
d := now.AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
case tok == "@month":
|
||||||
|
d := now.AddDate(0, -1, 0).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"):
|
||||||
|
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||||
|
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||||
|
r.QueryDateTo = &d
|
||||||
} else {
|
} else {
|
||||||
switch remaining {
|
bodyParts = append(bodyParts, tok)
|
||||||
case "-", "▸":
|
}
|
||||||
|
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
|
||||||
|
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||||
|
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||||
|
suffix := tok[1:]
|
||||||
|
if ct, ok := validCardTypes[suffix]; ok {
|
||||||
|
r.QueryCardType = &ct
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Body = strings.Join(bodyParts, " ")
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Kind prefix — `-`, `@time`, `!time`
|
||||||
|
// `@` and `!` are kind prefixes ONLY if followed by a valid time token.
|
||||||
|
// Otherwise the input is treated as a plain note.
|
||||||
|
if strings.HasPrefix(remaining, "- ") {
|
||||||
|
r.Glyph = "todo"
|
||||||
|
remaining = strings.TrimSpace(remaining[2:])
|
||||||
|
} else if remaining == "-" {
|
||||||
r.Glyph = "todo"
|
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 +196,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 +207,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 +216,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 +229,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 {
|
||||||
|
|||||||
+143
-37
@@ -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,76 @@ 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseQueryComposition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantBody string
|
||||||
|
wantTags []string
|
||||||
|
wantDateFrom bool
|
||||||
|
wantDateTo bool
|
||||||
|
wantCardType *string
|
||||||
|
}{
|
||||||
|
{"today", "?@today", "", nil, true, true, nil},
|
||||||
|
{"yesterday", "?@yesterday", "", nil, true, true, nil},
|
||||||
|
{"week", "?@week", "", nil, true, false, nil},
|
||||||
|
{"month", "?@month", "", nil, true, false, nil},
|
||||||
|
{"newer than", "?<7d", "", nil, true, false, nil},
|
||||||
|
{"older than", "?>30d", "", nil, false, true, nil},
|
||||||
|
{"card type snippet", "?^snippet", "", nil, false, false, sp("snippet")},
|
||||||
|
{"card type shorthand", "?^c", "", nil, false, false, sp("snippet")},
|
||||||
|
{"card type checklist", "?^checklist", "", nil, false, false, sp("checklist")},
|
||||||
|
{"invalid card type stays as body", "?^bogus", "^bogus", nil, false, false, nil},
|
||||||
|
{"combined text and date", "?deploy @today", "deploy", nil, true, true, nil},
|
||||||
|
{"combined tags and date", "?#ops @week", "", []string{"ops"}, true, false, nil},
|
||||||
|
{"combined all", "?deploy #ops @week ^snippet", "deploy", []string{"ops"}, true, false, sp("snippet")},
|
||||||
|
{"invalid age stays as body", "?>abcd", ">abcd", nil, false, false, nil},
|
||||||
|
{"zero days stays as body", "?>0d", ">0d", nil, false, false, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Parse(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Query {
|
||||||
|
t.Fatal("expected Query=true")
|
||||||
|
}
|
||||||
|
if got.Body != tt.wantBody {
|
||||||
|
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
|
||||||
|
}
|
||||||
|
if !tagsEq(got.FilterTags, tt.wantTags) {
|
||||||
|
t.Errorf("tags: got %v, want %v", got.FilterTags, tt.wantTags)
|
||||||
|
}
|
||||||
|
if tt.wantDateFrom && got.QueryDateFrom == nil {
|
||||||
|
t.Error("expected QueryDateFrom to be set")
|
||||||
|
}
|
||||||
|
if !tt.wantDateFrom && got.QueryDateFrom != nil {
|
||||||
|
t.Errorf("expected QueryDateFrom nil, got %v", *got.QueryDateFrom)
|
||||||
|
}
|
||||||
|
if tt.wantDateTo && got.QueryDateTo == nil {
|
||||||
|
t.Error("expected QueryDateTo to be set")
|
||||||
|
}
|
||||||
|
if !tt.wantDateTo && got.QueryDateTo != nil {
|
||||||
|
t.Errorf("expected QueryDateTo nil, got %v", *got.QueryDateTo)
|
||||||
|
}
|
||||||
|
if !ptrEq(got.QueryCardType, tt.wantCardType) {
|
||||||
|
t.Errorf("card type: got %v, want %v", strPtr(got.QueryCardType), strPtr(tt.wantCardType))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type absorbModel struct {
|
||||||
|
targetID string
|
||||||
|
sources []*db.Entity
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAbsorbModel(targetID string) absorbModel {
|
||||||
|
return absorbModel{targetID: targetID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *absorbModel) setSources(entities []*db.Entity) {
|
||||||
|
a.sources = nil
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.ID != a.targetID {
|
||||||
|
a.sources = append(a.sources, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cursor = 0
|
||||||
|
a.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *absorbModel) setHeight(h int) {
|
||||||
|
a.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) selectedSource() *db.Entity {
|
||||||
|
if len(a.sources) == 0 || a.cursor >= len(a.sources) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.sources[a.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) update(msg tea.KeyMsg) absorbModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if a.cursor > 0 {
|
||||||
|
a.cursor--
|
||||||
|
if a.cursor < a.offset {
|
||||||
|
a.offset = a.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if a.cursor < len(a.sources)-1 {
|
||||||
|
a.cursor++
|
||||||
|
visible := a.visibleCount()
|
||||||
|
if a.cursor >= a.offset+visible {
|
||||||
|
a.offset = a.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) view(width int) string {
|
||||||
|
if len(a.sources) == 0 {
|
||||||
|
return statusStyle.Render("no other entities")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("select source to merge"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
visible := a.visibleCount() - 4
|
||||||
|
if visible <= 0 {
|
||||||
|
visible = 10
|
||||||
|
}
|
||||||
|
end := min(a.offset+visible, len(a.sources))
|
||||||
|
|
||||||
|
for i := a.offset; i < end; i++ {
|
||||||
|
e := a.sources[i]
|
||||||
|
line := renderAbsorbSource(e, width-4)
|
||||||
|
|
||||||
|
if i == a.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) visibleCount() int {
|
||||||
|
if a.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return a.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||||
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags string
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
tagParts := make([]string, limit)
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
tagParts[i] = tagStyle.Render("#" + e.Tags[i])
|
||||||
|
}
|
||||||
|
tags = " " + strings.Join(tagParts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxSuggestions = 5
|
||||||
|
|
||||||
|
type autocompleteModel struct {
|
||||||
|
suggestions []string
|
||||||
|
cursor int
|
||||||
|
active bool
|
||||||
|
prefix string
|
||||||
|
tokenStart int
|
||||||
|
tokenEnd int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *autocompleteModel) moveUp() {
|
||||||
|
if a.cursor > 0 {
|
||||||
|
a.cursor--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *autocompleteModel) moveDown() {
|
||||||
|
if a.cursor < len(a.suggestions)-1 {
|
||||||
|
a.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) selected() string {
|
||||||
|
if len(a.suggestions) == 0 || a.cursor >= len(a.suggestions) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.suggestions[a.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) visibleCount() int {
|
||||||
|
if len(a.suggestions) > maxSuggestions {
|
||||||
|
return maxSuggestions
|
||||||
|
}
|
||||||
|
return len(a.suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) view(width int) string {
|
||||||
|
if !a.active || len(a.suggestions) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
n := a.visibleCount()
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
tag := "#" + a.suggestions[i]
|
||||||
|
if i == a.cursor {
|
||||||
|
b.WriteString(acSelectedStyle.Render(" › " + tag))
|
||||||
|
} else {
|
||||||
|
b.WriteString(acItemStyle.Render(" " + tag))
|
||||||
|
}
|
||||||
|
if i < n-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.suggestions) > maxSuggestions {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(acItemStyle.Render(" …"))
|
||||||
|
}
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Width(min(30, width)).
|
||||||
|
Render(b.String())
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagTokenAtCursor(val string, cursorPos int) (tokenStart, tokenEnd int, prefix string, ok bool) {
|
||||||
|
if cursorPos > len(val) {
|
||||||
|
cursorPos = len(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := cursorPos
|
||||||
|
for start > 0 && val[start-1] != ' ' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
|
||||||
|
if start >= len(val) || val[start] != '#' {
|
||||||
|
return 0, 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
end := cursorPos
|
||||||
|
for end < len(val) && val[end] != ' ' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = strings.ToLower(val[start+1 : cursorPos])
|
||||||
|
return start, end, prefix, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterTagSuggestions(tags []db.TagCount, prefix string) []string {
|
||||||
|
if prefix == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefix = strings.ToLower(prefix)
|
||||||
|
var result []string
|
||||||
|
for _, tc := range tags {
|
||||||
|
lower := strings.ToLower(tc.Tag)
|
||||||
|
if strings.HasPrefix(lower, prefix) && lower != prefix {
|
||||||
|
result = append(result, tc.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagTokenAtCursor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val string
|
||||||
|
cursor int
|
||||||
|
wantStart int
|
||||||
|
wantEnd int
|
||||||
|
wantPfx string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
|
||||||
|
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
|
||||||
|
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
|
||||||
|
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
|
||||||
|
{"not in tag", "hello world", 5, 0, 0, "", false},
|
||||||
|
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
|
||||||
|
{"cursor at hash", "#ops", 1, 0, 4, "", true},
|
||||||
|
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
|
||||||
|
{"empty string", "", 0, 0, 0, "", false},
|
||||||
|
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
|
||||||
|
if ok != tt.wantOk {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if start != tt.wantStart || end != tt.wantEnd {
|
||||||
|
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
|
||||||
|
}
|
||||||
|
if pfx != tt.wantPfx {
|
||||||
|
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterTagSuggestions(t *testing.T) {
|
||||||
|
tags := []db.TagCount{
|
||||||
|
{Tag: "ops", Count: 5},
|
||||||
|
{Tag: "ops-deploy", Count: 3},
|
||||||
|
{Tag: "infra", Count: 2},
|
||||||
|
{Tag: "ops-team", Count: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"empty prefix", "", nil},
|
||||||
|
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
|
||||||
|
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
|
||||||
|
{"no match", "zzz", nil},
|
||||||
|
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
|
||||||
|
{"single match", "inf", []string{"infra"}},
|
||||||
|
{"full match excluded", "infra", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterTagSuggestions(tags, tt.prefix)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type intent int
|
||||||
|
|
||||||
|
const (
|
||||||
|
intentAll intent = iota
|
||||||
|
intentGrab
|
||||||
|
intentRead
|
||||||
|
intentFill
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i intent) String() string {
|
||||||
|
switch i {
|
||||||
|
case intentGrab:
|
||||||
|
return "grab"
|
||||||
|
case intentRead:
|
||||||
|
return "read"
|
||||||
|
case intentFill:
|
||||||
|
return "fill"
|
||||||
|
default:
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i intent) next() intent {
|
||||||
|
switch i {
|
||||||
|
case intentAll:
|
||||||
|
return intentGrab
|
||||||
|
case intentGrab:
|
||||||
|
return intentRead
|
||||||
|
case intentRead:
|
||||||
|
return intentFill
|
||||||
|
default:
|
||||||
|
return intentAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesIntent(e *db.Entity, i intent) bool {
|
||||||
|
if i == intentAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ct := e.CardType
|
||||||
|
if ct == nil {
|
||||||
|
return i == intentGrab
|
||||||
|
}
|
||||||
|
switch i {
|
||||||
|
case intentGrab:
|
||||||
|
return *ct == db.CardSnippet
|
||||||
|
case intentRead:
|
||||||
|
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
|
||||||
|
case intentFill:
|
||||||
|
return *ct == db.CardTemplate || *ct == db.CardChecklist
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardGroup struct {
|
||||||
|
label string
|
||||||
|
start int
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardsModel struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
filtered []*db.Entity
|
||||||
|
groups []cardGroup
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
intent intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCardsModel() cardsModel {
|
||||||
|
return cardsModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setEntities(entities []*db.Entity) {
|
||||||
|
c.entities = entities
|
||||||
|
c.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setIntent(i intent) {
|
||||||
|
c.intent = i
|
||||||
|
c.cursor = 0
|
||||||
|
c.offset = 0
|
||||||
|
c.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) applyFilter() {
|
||||||
|
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
|
||||||
|
if c.cursor >= len(c.filtered) {
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
|
||||||
|
if intentFilter != intentAll {
|
||||||
|
var pinned, rest []*db.Entity
|
||||||
|
for _, e := range entities {
|
||||||
|
if !matchesIntent(e, intentFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Pinned {
|
||||||
|
pinned = append(pinned, e)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(pinned, rest...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinned, grab, read, fill []*db.Entity
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Pinned {
|
||||||
|
pinned = append(pinned, e)
|
||||||
|
} else {
|
||||||
|
switch {
|
||||||
|
case matchesIntent(e, intentGrab):
|
||||||
|
grab = append(grab, e)
|
||||||
|
case matchesIntent(e, intentRead):
|
||||||
|
read = append(read, e)
|
||||||
|
case matchesIntent(e, intentFill):
|
||||||
|
fill = append(fill, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []*db.Entity
|
||||||
|
var groups []cardGroup
|
||||||
|
for _, bucket := range []struct {
|
||||||
|
label string
|
||||||
|
entities []*db.Entity
|
||||||
|
}{
|
||||||
|
{"pinned", pinned},
|
||||||
|
{"grab", grab},
|
||||||
|
{"read", read},
|
||||||
|
{"fill", fill},
|
||||||
|
} {
|
||||||
|
if len(bucket.entities) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups = append(groups, cardGroup{
|
||||||
|
label: bucket.label,
|
||||||
|
start: len(filtered),
|
||||||
|
count: len(bucket.entities),
|
||||||
|
})
|
||||||
|
filtered = append(filtered, bucket.entities...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setSize(width, height int) {
|
||||||
|
c.width = width
|
||||||
|
c.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) selected() *db.Entity {
|
||||||
|
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.filtered[c.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if c.cursor > 0 {
|
||||||
|
c.cursor--
|
||||||
|
if c.cursor < c.offset {
|
||||||
|
c.offset = c.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if c.cursor < len(c.filtered)-1 {
|
||||||
|
c.cursor++
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= c.offset+visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
c.cursor = 0
|
||||||
|
c.offset = 0
|
||||||
|
case "end", "G":
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
c.cursor = max(0, c.cursor-c.visibleCount())
|
||||||
|
if c.cursor < c.offset {
|
||||||
|
c.offset = c.cursor
|
||||||
|
}
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= c.offset+visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) view(width int) string {
|
||||||
|
if len(c.filtered) == 0 {
|
||||||
|
return statusStyle.Render("no cards")
|
||||||
|
}
|
||||||
|
if len(c.groups) > 0 {
|
||||||
|
return c.groupedView(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
visible := c.visibleCount()
|
||||||
|
end := min(c.offset+visible, len(c.filtered))
|
||||||
|
|
||||||
|
for i := c.offset; i < end; i++ {
|
||||||
|
e := c.filtered[i]
|
||||||
|
line := renderCard(e, width-4)
|
||||||
|
|
||||||
|
if i == c.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) groupedView(width int) string {
|
||||||
|
entityWidth := width - 4 - dateGutterWidth
|
||||||
|
|
||||||
|
type displayLine struct {
|
||||||
|
text string
|
||||||
|
entityIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []displayLine
|
||||||
|
for _, g := range c.groups {
|
||||||
|
for i := 0; i < g.count; i++ {
|
||||||
|
eIdx := g.start + i
|
||||||
|
var gutter string
|
||||||
|
if i == 0 {
|
||||||
|
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||||
|
} else {
|
||||||
|
gutter = gutterStyle.Render(" │ ")
|
||||||
|
}
|
||||||
|
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
|
||||||
|
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := c.visibleCount()
|
||||||
|
offset := c.offset
|
||||||
|
if c.cursor < offset {
|
||||||
|
offset = c.cursor
|
||||||
|
}
|
||||||
|
if c.cursor >= offset+visible {
|
||||||
|
offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
end := min(offset+visible, len(lines))
|
||||||
|
for i := offset; i < end; i++ {
|
||||||
|
dl := lines[i]
|
||||||
|
if dl.entityIdx == c.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(dl.text))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) visibleCount() int {
|
||||||
|
if c.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return c.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCard(e *db.Entity, maxWidth int) string {
|
||||||
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
affordance := detectAffordance(e)
|
||||||
|
affordStr := ""
|
||||||
|
if affordance != "" {
|
||||||
|
affordStr = " " + affordanceStyle.Render(affordance)
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras []string
|
||||||
|
if e.Pinned {
|
||||||
|
extras = append(extras, pinnedStyle.Render("•"))
|
||||||
|
}
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
for _, t := range e.Tags[:limit] {
|
||||||
|
extras = append(extras, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraStr := ""
|
||||||
|
if len(extras) > 0 {
|
||||||
|
extraStr = " " + strings.Join(extras, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
useStr := ""
|
||||||
|
if e.UseCount > 0 {
|
||||||
|
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectAffordance(e *db.Entity) string {
|
||||||
|
if e.CardType == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch *e.CardType {
|
||||||
|
case db.CardSnippet:
|
||||||
|
return "code"
|
||||||
|
case db.CardTemplate:
|
||||||
|
return "fill"
|
||||||
|
case db.CardChecklist:
|
||||||
|
return "steps"
|
||||||
|
case db.CardDecision:
|
||||||
|
return "decide"
|
||||||
|
case db.CardLink:
|
||||||
|
return "link"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchesIntent(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
template := db.CardTemplate
|
||||||
|
checklist := db.CardChecklist
|
||||||
|
note := db.CardNote
|
||||||
|
link := db.CardLink
|
||||||
|
decision := db.CardDecision
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardType *db.CardType
|
||||||
|
intent intent
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"all matches nil", nil, intentAll, true},
|
||||||
|
{"all matches snippet", &snippet, intentAll, true},
|
||||||
|
{"all matches template", &template, intentAll, true},
|
||||||
|
|
||||||
|
{"grab matches nil", nil, intentGrab, true},
|
||||||
|
{"grab matches snippet", &snippet, intentGrab, true},
|
||||||
|
{"grab rejects template", &template, intentGrab, false},
|
||||||
|
{"grab rejects note", ¬e, intentGrab, false},
|
||||||
|
|
||||||
|
{"read matches note", ¬e, intentRead, true},
|
||||||
|
{"read matches link", &link, intentRead, true},
|
||||||
|
{"read matches decision", &decision, intentRead, true},
|
||||||
|
{"read rejects snippet", &snippet, intentRead, false},
|
||||||
|
{"read rejects nil", nil, intentRead, false},
|
||||||
|
|
||||||
|
{"fill matches template", &template, intentFill, true},
|
||||||
|
{"fill matches checklist", &checklist, intentFill, true},
|
||||||
|
{"fill rejects snippet", &snippet, intentFill, false},
|
||||||
|
{"fill rejects nil", nil, intentFill, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &db.Entity{CardType: tt.cardType}
|
||||||
|
if got := matchesIntent(e, tt.intent); got != tt.want {
|
||||||
|
t.Fatalf("matchesIntent(%v, %v) = %v, want %v", tt.cardType, tt.intent, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAffordance(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
template := db.CardTemplate
|
||||||
|
checklist := db.CardChecklist
|
||||||
|
decision := db.CardDecision
|
||||||
|
link := db.CardLink
|
||||||
|
note := db.CardNote
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardType *db.CardType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil", nil, ""},
|
||||||
|
{"snippet", &snippet, "code"},
|
||||||
|
{"template", &template, "fill"},
|
||||||
|
{"checklist", &checklist, "steps"},
|
||||||
|
{"decision", &decision, "decide"},
|
||||||
|
{"link", &link, "link"},
|
||||||
|
{"note", ¬e, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &db.Entity{CardType: tt.cardType}
|
||||||
|
if got := detectAffordance(e); got != tt.want {
|
||||||
|
t.Fatalf("detectAffordance(%v) = %q, want %q", tt.cardType, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentCycle(t *testing.T) {
|
||||||
|
order := []intent{intentAll, intentGrab, intentRead, intentFill, intentAll}
|
||||||
|
for i := 0; i < len(order)-1; i++ {
|
||||||
|
got := order[i].next()
|
||||||
|
if got != order[i+1] {
|
||||||
|
t.Fatalf("%v.next() = %v, want %v", order[i], got, order[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyFilter_PinnedFirst(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
c := newCardsModel()
|
||||||
|
c.setEntities([]*db.Entity{
|
||||||
|
{ID: "1", Body: "a", CardType: &snippet},
|
||||||
|
{ID: "2", Body: "b", Pinned: true, CardType: &snippet},
|
||||||
|
{ID: "3", Body: "c", CardType: &snippet},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(c.filtered) != 3 {
|
||||||
|
t.Fatalf("expected 3 filtered, got %d", len(c.filtered))
|
||||||
|
}
|
||||||
|
if c.filtered[0].ID != "2" {
|
||||||
|
t.Fatalf("pinned entity should be first, got %s", c.filtered[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyFilter_CursorClamps(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
template := db.CardTemplate
|
||||||
|
c := newCardsModel()
|
||||||
|
c.setEntities([]*db.Entity{
|
||||||
|
{ID: "1", Body: "a", CardType: &snippet},
|
||||||
|
{ID: "2", Body: "b", CardType: &snippet},
|
||||||
|
{ID: "3", Body: "c", CardType: &template},
|
||||||
|
})
|
||||||
|
c.cursor = 2
|
||||||
|
|
||||||
|
c.setIntent(intentFill)
|
||||||
|
if len(c.filtered) != 1 {
|
||||||
|
t.Fatalf("expected 1 fill entity, got %d", len(c.filtered))
|
||||||
|
}
|
||||||
|
if c.cursor != 0 {
|
||||||
|
t.Fatalf("cursor should clamp to 0, got %d", c.cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entitiesLoadedMsg struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityCreatedMsg struct {
|
||||||
|
entity *db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityDeletedMsg struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityUpdatedMsg struct {
|
||||||
|
entity *db.Entity
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityPromotedMsg struct {
|
||||||
|
id string
|
||||||
|
cardType db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityDemotedMsg struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityCopiedMsg struct{}
|
||||||
|
|
||||||
|
type entityAbsorbedMsg struct {
|
||||||
|
targetID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type absorbSourcesLoadedMsg struct {
|
||||||
|
targetID string
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepsPersistedMsg struct{}
|
||||||
|
|
||||||
|
type templateCopiedMsg struct{}
|
||||||
|
|
||||||
|
type backlinksLoadedMsg struct {
|
||||||
|
backlinks []db.Backlink
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagsLoadedMsg struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type railTagsLoadedMsg struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type staleEntitiesLoadedMsg struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type stumbleActionMsg struct {
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusClearMsg struct{ seq int }
|
||||||
|
|
||||||
|
type editorFinishedMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type errMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(context.Background(), params)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entitiesLoadedMsg{entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Create(context.Background(), e); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityCreatedMsg{e}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntity(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityDeletedMsg{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
var update db.EntityUpdate
|
||||||
|
if e.CompletedAt == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
update = db.EntityUpdate{CompletedAt: &now}
|
||||||
|
} else {
|
||||||
|
update = db.EntityUpdate{ClearCompleted: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Update(context.Background(), e.ID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
updated, err := store.Get(context.Background(), e.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
action := "completed"
|
||||||
|
if e.CompletedAt != nil {
|
||||||
|
action = "reopened"
|
||||||
|
}
|
||||||
|
return entityUpdatedMsg{updated, action}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
newPinned := !e.Pinned
|
||||||
|
update := db.EntityUpdate{Pinned: &newPinned}
|
||||||
|
if err := store.Update(context.Background(), e.ID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
updated, err := store.Get(context.Background(), e.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
action := "pinned"
|
||||||
|
if !newPinned {
|
||||||
|
action = "unpinned"
|
||||||
|
}
|
||||||
|
return entityUpdatedMsg{updated, action}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
cd := carddata.GenerateCardData(ct, body)
|
||||||
|
if err := store.Promote(context.Background(), id, ct, cd); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityPromotedMsg{id, ct}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func demoteEntity(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Demote(context.Background(), id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityDemotedMsg{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := clipboard.WriteAll(e.Body); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if err := store.IncrementUse(context.Background(), e.ID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityCopiedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTags(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return tagsLoadedMsg{tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
backlinks, err := store.LoadBacklinks(context.Background(), entityID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return backlinksLoadedMsg{backlinks}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRailTags(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return railTagsLoadedMsg{tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
editorEnv := os.Getenv("EDITOR")
|
||||||
|
if editorEnv == "" {
|
||||||
|
editorEnv = os.Getenv("VISUAL")
|
||||||
|
}
|
||||||
|
if editorEnv == "" {
|
||||||
|
editorEnv = "vi"
|
||||||
|
}
|
||||||
|
parts := strings.Fields(editorEnv)
|
||||||
|
editor, editorArgs := parts[0], parts[1:]
|
||||||
|
|
||||||
|
f, err := os.CreateTemp("", "nib-edit-*.md")
|
||||||
|
if err != nil {
|
||||||
|
return func() tea.Msg { return errMsg{err} }
|
||||||
|
}
|
||||||
|
if _, err := f.WriteString(e.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(f.Name())
|
||||||
|
return func() tea.Msg { return errMsg{err} }
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
c := exec.Command(editor, append(editorArgs, f.Name())...)
|
||||||
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
return editorFinishedMsg{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, readErr := os.ReadFile(f.Name())
|
||||||
|
if readErr != nil {
|
||||||
|
return editorFinishedMsg{readErr}
|
||||||
|
}
|
||||||
|
|
||||||
|
newBody := string(content)
|
||||||
|
if newBody == e.Body {
|
||||||
|
return editorFinishedMsg{nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
update := db.EntityUpdate{Body: &newBody}
|
||||||
|
if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
|
||||||
|
return editorFinishedMsg{updateErr}
|
||||||
|
}
|
||||||
|
|
||||||
|
return editorFinishedMsg{nil}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(context.Background(), db.DefaultListParams())
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return absorbSourcesLoadedMsg{targetID, entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityAbsorbedMsg{targetID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
update := db.EntityUpdate{CardData: &stepsJSON}
|
||||||
|
if err := store.Update(context.Background(), entityID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stepsPersistedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := clipboard.WriteAll(resolved); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if err := store.IncrementUse(context.Background(), entityID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return templateCopiedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
|
||||||
|
return tea.Tick(d, func(time.Time) tea.Msg {
|
||||||
|
return statusClearMsg{seq: seq}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStaleEntities(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(context.Background(), staleParams())
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return staleEntitiesLoadedMsg{entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stumbleActionMsg{"dismissed"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stumblePin(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
pinned := true
|
||||||
|
update := db.EntityUpdate{Pinned: &pinned}
|
||||||
|
if err := store.Update(context.Background(), id, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stumbleActionMsg{"pinned"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type confirmTimeoutMsg struct{}
|
||||||
|
|
||||||
|
func confirmTimeout() tea.Cmd {
|
||||||
|
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
||||||
|
return confirmTimeoutMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderConfirm(entityID string) string {
|
||||||
|
short := display.FormatID(entityID)
|
||||||
|
return errorStyle.Render(fmt.Sprintf("delete %s? y to confirm, any key to cancel", short))
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type detailMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailPreview detailMode = iota
|
||||||
|
detailRun
|
||||||
|
detailFill
|
||||||
|
)
|
||||||
|
|
||||||
|
type detailModel struct {
|
||||||
|
entity *db.Entity
|
||||||
|
backlinks []db.Backlink
|
||||||
|
scroll int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
mode detailMode
|
||||||
|
run runModel
|
||||||
|
fill fillModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDetailModel() detailModel {
|
||||||
|
return detailModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailModel) setEntity(e *db.Entity) {
|
||||||
|
d.entity = e
|
||||||
|
d.backlinks = nil
|
||||||
|
d.scroll = 0
|
||||||
|
d.mode = detailPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailModel) setSize(width, height int) {
|
||||||
|
d.width = width
|
||||||
|
d.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
||||||
|
switch d.mode {
|
||||||
|
case detailRun:
|
||||||
|
d.run = d.run.update(msg.String())
|
||||||
|
return d, nil
|
||||||
|
case detailFill:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
d.fill, cmd = d.fill.update(msg)
|
||||||
|
return d, cmd
|
||||||
|
default:
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if d.scroll > 0 {
|
||||||
|
d.scroll--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
d.scroll++
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
d.scroll += d.height
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
d.scroll -= d.height
|
||||||
|
if d.scroll < 0 {
|
||||||
|
d.scroll = 0
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
d.scroll = 0
|
||||||
|
case "end", "G":
|
||||||
|
d.scroll = 1<<31 - 1
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) view(width int) string {
|
||||||
|
switch d.mode {
|
||||||
|
case detailRun:
|
||||||
|
return d.run.view(width)
|
||||||
|
case detailFill:
|
||||||
|
return d.fill.view(width)
|
||||||
|
default:
|
||||||
|
return d.previewView(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) previewView(width int) string {
|
||||||
|
if d.entity == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
e := d.entity
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
|
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
|
||||||
|
if e.CardType != nil {
|
||||||
|
header += " " + affordanceStyle.Render(string(*e.CardType))
|
||||||
|
}
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if e.Title != nil {
|
||||||
|
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyWidth := width - 4
|
||||||
|
if bodyWidth < 20 {
|
||||||
|
bodyWidth = 20
|
||||||
|
}
|
||||||
|
r, _ := glamour.NewTermRenderer(
|
||||||
|
glamour.WithStylePath(glamourStyle()),
|
||||||
|
glamour.WithWordWrap(bodyWidth),
|
||||||
|
)
|
||||||
|
rendered, err := r.Render(e.Body)
|
||||||
|
if err != nil {
|
||||||
|
rendered = e.Body
|
||||||
|
}
|
||||||
|
rendered = strings.TrimRight(rendered, "\n")
|
||||||
|
b.WriteString(detailBodyStyle.Render(rendered))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if e.CardType != nil {
|
||||||
|
cardSection := renderCardData(e)
|
||||||
|
if cardSection != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(cardSection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
tagParts := make([]string, len(e.Tags))
|
||||||
|
for i, t := range e.Tags {
|
||||||
|
tagParts[i] = tagStyle.Render("#" + t)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(d.backlinks) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, bl := range d.backlinks {
|
||||||
|
label := bl.Body
|
||||||
|
if bl.Title != nil {
|
||||||
|
label = *bl.Title
|
||||||
|
} else if len(label) > 40 {
|
||||||
|
label = label[:40] + "…"
|
||||||
|
}
|
||||||
|
line := " " + backlinkStyle.Render(label)
|
||||||
|
if bl.LinkText != "" {
|
||||||
|
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
||||||
|
if e.ModifiedAt != e.CreatedAt {
|
||||||
|
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
if e.TimeAnchor != nil {
|
||||||
|
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
|
||||||
|
}
|
||||||
|
if e.Pinned {
|
||||||
|
meta += "\n" + pinnedStyle.Render("pinned")
|
||||||
|
}
|
||||||
|
if e.CardType != nil {
|
||||||
|
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
||||||
|
}
|
||||||
|
if e.UseCount > 0 {
|
||||||
|
meta += fmt.Sprintf("\nused %d×", e.UseCount)
|
||||||
|
}
|
||||||
|
if e.CompletedAt != nil {
|
||||||
|
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
b.WriteString(idStyle.Render(meta))
|
||||||
|
|
||||||
|
lines := strings.Split(b.String(), "\n")
|
||||||
|
totalLines := len(lines)
|
||||||
|
|
||||||
|
maxScroll := totalLines - d.height
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
scroll := d.scroll
|
||||||
|
if scroll > maxScroll {
|
||||||
|
scroll = maxScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
|
||||||
|
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
|
||||||
|
lines[0] = lines[0] + indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
if scroll > 0 && scroll < totalLines {
|
||||||
|
lines = lines[scroll:]
|
||||||
|
}
|
||||||
|
if d.height > 0 && len(lines) > d.height {
|
||||||
|
lines = lines[:d.height]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCardData(e *db.Entity) string {
|
||||||
|
if e.CardData == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := e.CardDataJSON()
|
||||||
|
if err != nil || data == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
switch *e.CardType {
|
||||||
|
case db.CardChecklist:
|
||||||
|
steps, ok := data["steps"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
done := 0
|
||||||
|
for _, s := range steps {
|
||||||
|
step, ok := s.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, _ := step["text"].(string)
|
||||||
|
isDone, _ := step["done"].(bool)
|
||||||
|
if isDone {
|
||||||
|
done++
|
||||||
|
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
|
||||||
|
b.WriteString(detailLabelStyle.Render(progress))
|
||||||
|
b.WriteString(" " + helpStyle.Render("r:run"))
|
||||||
|
|
||||||
|
case db.CardTemplate:
|
||||||
|
slots, ok := data["slots"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
|
||||||
|
for _, s := range slots {
|
||||||
|
slot, ok := s.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := slot["name"].(string)
|
||||||
|
def, _ := slot["default"].(string)
|
||||||
|
line := " ${" + name + "}"
|
||||||
|
if def != "" {
|
||||||
|
line += " " + detailValueStyle.Render("default: "+def)
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" " + helpStyle.Render("f:fill"))
|
||||||
|
|
||||||
|
case db.CardDecision:
|
||||||
|
if chose, ok := data["chose"].(string); ok && chose != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
|
||||||
|
}
|
||||||
|
if why, ok := data["why"].(string); ok && why != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
|
||||||
|
}
|
||||||
|
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
|
||||||
|
items := make([]string, 0, len(rejected))
|
||||||
|
for _, r := range rejected {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case db.CardLink:
|
||||||
|
if url, ok := data["url"].(string); ok && url != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fillSlot struct {
|
||||||
|
Name string
|
||||||
|
Default string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fillModel struct {
|
||||||
|
slots []fillSlot
|
||||||
|
active int
|
||||||
|
body string
|
||||||
|
entityID string
|
||||||
|
ti textinput.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFillModel(entityID, body string) fillModel {
|
||||||
|
slots := discoverSlots(body)
|
||||||
|
m := fillModel{
|
||||||
|
slots: slots,
|
||||||
|
body: body,
|
||||||
|
entityID: entityID,
|
||||||
|
}
|
||||||
|
m.ti = textinput.New()
|
||||||
|
m.ti.CharLimit = 200
|
||||||
|
if len(slots) > 0 {
|
||||||
|
m.ti.Placeholder = slots[0].Name
|
||||||
|
m.ti.Focus()
|
||||||
|
if slots[0].Default != "" {
|
||||||
|
m.ti.SetValue(slots[0].Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverSlots(body string) []fillSlot {
|
||||||
|
matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var slots []fillSlot
|
||||||
|
for _, m := range matches {
|
||||||
|
name := m[1]
|
||||||
|
if !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
slots = append(slots, fillSlot{Name: name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fillModel) resolve() string {
|
||||||
|
result := f.body
|
||||||
|
for _, s := range f.slots {
|
||||||
|
val := s.Value
|
||||||
|
if val == "" {
|
||||||
|
val = "${" + s.Name + "}"
|
||||||
|
}
|
||||||
|
result = strings.ReplaceAll(result, "${"+s.Name+"}", val)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "tab":
|
||||||
|
f.commitActive()
|
||||||
|
if f.active < len(f.slots)-1 {
|
||||||
|
f.active++
|
||||||
|
} else {
|
||||||
|
f.active = 0
|
||||||
|
}
|
||||||
|
f.loadActive()
|
||||||
|
return f, nil
|
||||||
|
case "shift+tab":
|
||||||
|
f.commitActive()
|
||||||
|
if f.active > 0 {
|
||||||
|
f.active--
|
||||||
|
} else {
|
||||||
|
f.active = len(f.slots) - 1
|
||||||
|
}
|
||||||
|
f.loadActive()
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f.ti, _ = f.ti.Update(msg)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fillModel) commitActive() {
|
||||||
|
if f.active < len(f.slots) {
|
||||||
|
f.slots[f.active].Value = f.ti.Value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fillModel) loadActive() {
|
||||||
|
if f.active < len(f.slots) {
|
||||||
|
s := f.slots[f.active]
|
||||||
|
f.ti.SetValue(s.Value)
|
||||||
|
f.ti.Placeholder = s.Name
|
||||||
|
f.ti.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fillModel) view(width int) string {
|
||||||
|
if len(f.slots) == 0 {
|
||||||
|
return statusStyle.Render("no slots")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots))
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, slot := range f.slots {
|
||||||
|
name := detailLabelStyle.Render(slot.Name)
|
||||||
|
var val string
|
||||||
|
if i == f.active {
|
||||||
|
val = f.ti.View()
|
||||||
|
} else if slot.Value != "" {
|
||||||
|
val = detailValueStyle.Render(slot.Value)
|
||||||
|
} else {
|
||||||
|
val = idStyle.Render("(empty)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == f.active {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + name + " " + val))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(name + " " + val))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
preview := f.resolve()
|
||||||
|
if len(preview) > width-4 {
|
||||||
|
preview = preview[:width-7] + "…"
|
||||||
|
}
|
||||||
|
b.WriteString(detailBodyStyle.Render(preview))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoverSlots(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantNames []string
|
||||||
|
}{
|
||||||
|
{"no slots", "plain text", nil},
|
||||||
|
{"single slot", "Hello ${name}", []string{"name"}},
|
||||||
|
{"multiple slots", "${greeting} ${name}, welcome to ${place}", []string{"greeting", "name", "place"}},
|
||||||
|
{"duplicate slot deduped", "${x} and ${x} again", []string{"x"}},
|
||||||
|
{"adjacent slots", "${a}${b}", []string{"a", "b"}},
|
||||||
|
{"nested braces ignored", "${{bad}}", nil},
|
||||||
|
{"empty body", "", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := discoverSlots(tt.body)
|
||||||
|
if len(tt.wantNames) == 0 && len(got) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.wantNames) {
|
||||||
|
t.Fatalf("got %d slots, want %d", len(got), len(tt.wantNames))
|
||||||
|
}
|
||||||
|
for i, s := range got {
|
||||||
|
if s.Name != tt.wantNames[i] {
|
||||||
|
t.Fatalf("slot[%d].Name = %q, want %q", i, s.Name, tt.wantNames[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
slots []fillSlot
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"all filled",
|
||||||
|
"Hello ${name}, welcome to ${place}",
|
||||||
|
[]fillSlot{{Name: "name", Value: "Alice"}, {Name: "place", Value: "Nib"}},
|
||||||
|
"Hello Alice, welcome to Nib",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unfilled stays as placeholder",
|
||||||
|
"${greeting} ${name}",
|
||||||
|
[]fillSlot{{Name: "greeting", Value: "Hi"}, {Name: "name"}},
|
||||||
|
"Hi ${name}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no slots",
|
||||||
|
"plain text",
|
||||||
|
nil,
|
||||||
|
"plain text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"repeated slot filled everywhere",
|
||||||
|
"${x} and ${x}",
|
||||||
|
[]fillSlot{{Name: "x", Value: "Y"}},
|
||||||
|
"Y and Y",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
f := fillModel{body: tt.body, slots: tt.slots}
|
||||||
|
if got := f.resolve(); got != tt.want {
|
||||||
|
t.Fatalf("resolve() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type filterModel struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
cursor int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFilterModel() filterModel {
|
||||||
|
return filterModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterModel) setTags(tags []db.TagCount) {
|
||||||
|
f.tags = tags
|
||||||
|
f.cursor = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterModel) setHeight(h int) {
|
||||||
|
f.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f filterModel) selectedTag() string {
|
||||||
|
if len(f.tags) == 0 || f.cursor >= len(f.tags) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return f.tags[f.cursor].Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f filterModel) update(key string) filterModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if f.cursor > 0 {
|
||||||
|
f.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if f.cursor < len(f.tags)-1 {
|
||||||
|
f.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f filterModel) view(width int) string {
|
||||||
|
if len(f.tags) == 0 {
|
||||||
|
return statusStyle.Render("no tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("filter by tag"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
visible := f.height - 4
|
||||||
|
if visible <= 0 {
|
||||||
|
visible = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if f.cursor >= visible {
|
||||||
|
offset = f.cursor - visible + 1
|
||||||
|
}
|
||||||
|
end := min(offset+visible, len(f.tags))
|
||||||
|
|
||||||
|
for i := offset; i < end; i++ {
|
||||||
|
tc := f.tags[i]
|
||||||
|
tag := fmt.Sprintf("#%-20s %d", tc.Tag, tc.Count)
|
||||||
|
if i == f.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + tagStyle.Render(tag)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(tagStyle.Render(tag)))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func renderHelp(width, height int) string {
|
||||||
|
sections := []struct {
|
||||||
|
title string
|
||||||
|
binds [][2]string
|
||||||
|
}{
|
||||||
|
{"Focus", [][2]string{
|
||||||
|
{"tab", "toggle capture ↔ list"},
|
||||||
|
{"esc", "back / clear filter / to capture"},
|
||||||
|
{"a", "focus capture bar"},
|
||||||
|
{"h", "focus tag rail (from list)"},
|
||||||
|
{"l", "focus detail (split view)"},
|
||||||
|
{"ctrl+b", "toggle tag rail"},
|
||||||
|
}},
|
||||||
|
{"Capture Bar", [][2]string{
|
||||||
|
{"enter", "submit (or browse if empty)"},
|
||||||
|
{"?…", "search (type ?query)"},
|
||||||
|
{"#…", "tag (autocomplete with tab)"},
|
||||||
|
{"-", "todo prefix"},
|
||||||
|
{"@", "event prefix"},
|
||||||
|
{"!", "reminder prefix"},
|
||||||
|
}},
|
||||||
|
{"Query Operators", [][2]string{
|
||||||
|
{"?text", "substring search"},
|
||||||
|
{"?#tag1 #tag2", "filter by tags (AND)"},
|
||||||
|
{"?@today @week", "date filter (@yesterday @month)"},
|
||||||
|
{"?<7d >30d", "newer/older than N days"},
|
||||||
|
{"?^snippet", "card type filter"},
|
||||||
|
}},
|
||||||
|
{"Navigation", [][2]string{
|
||||||
|
{"j/k ↑/↓", "move cursor"},
|
||||||
|
{"g/G home/end", "top / bottom"},
|
||||||
|
{"pgup/pgdn", "page up / down"},
|
||||||
|
{"enter", "view detail"},
|
||||||
|
}},
|
||||||
|
{"Views", [][2]string{
|
||||||
|
{"1", "stream view"},
|
||||||
|
{"2", "cards view"},
|
||||||
|
{"s", "cycle sort (cards)"},
|
||||||
|
{"i", "cycle intent (cards)"},
|
||||||
|
{"T", "cycle theme"},
|
||||||
|
}},
|
||||||
|
{"Actions", [][2]string{
|
||||||
|
{"d", "delete (with confirm)"},
|
||||||
|
{"x", "toggle todo completion"},
|
||||||
|
{"!", "toggle pin"},
|
||||||
|
{"#", "filter by tag"},
|
||||||
|
{"m", "absorb (merge into target)"},
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"S", "stumble (resurface stale entries)"},
|
||||||
|
}},
|
||||||
|
{"Detail View", [][2]string{
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"D", "demote to fluid"},
|
||||||
|
{"c", "copy to clipboard"},
|
||||||
|
{"e", "edit in $EDITOR"},
|
||||||
|
{"!", "toggle pin"},
|
||||||
|
{"r", "run checklist"},
|
||||||
|
{"f", "fill template"},
|
||||||
|
}},
|
||||||
|
{"Stumble", [][2]string{
|
||||||
|
{"n / →", "skip to next"},
|
||||||
|
{"d", "dismiss (soft delete)"},
|
||||||
|
{"!", "pin"},
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"m", "absorb"},
|
||||||
|
{"esc", "exit"},
|
||||||
|
}},
|
||||||
|
{"Run Mode", [][2]string{
|
||||||
|
{"j/k", "move between steps"},
|
||||||
|
{"space", "toggle step"},
|
||||||
|
{"r", "reset all steps"},
|
||||||
|
{"esc", "save + exit"},
|
||||||
|
}},
|
||||||
|
{"Fill Mode", [][2]string{
|
||||||
|
{"tab", "next slot"},
|
||||||
|
{"shift+tab", "prev slot"},
|
||||||
|
{"enter", "copy resolved"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}},
|
||||||
|
{"Split View", [][2]string{
|
||||||
|
{"l", "focus detail pane"},
|
||||||
|
{"h", "focus list pane"},
|
||||||
|
{"esc", "close detail / back"},
|
||||||
|
}},
|
||||||
|
{"Global", [][2]string{
|
||||||
|
{"?", "toggle help"},
|
||||||
|
{"q / ctrl+c", "quit"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(detailHeaderStyle.Render("keybindings"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for _, s := range sections {
|
||||||
|
b.WriteString(titleStyle.Render(s.title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, bind := range s.binds {
|
||||||
|
key := helpKeyStyle.Render(bind[0])
|
||||||
|
desc := helpDescStyle.Render(bind[1])
|
||||||
|
b.WriteString(" " + key + " " + desc + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("press ? or esc to close"))
|
||||||
|
|
||||||
|
lines := strings.Split(b.String(), "\n")
|
||||||
|
if len(lines) > height {
|
||||||
|
lines = lines[:height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/parse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inputResult struct {
|
||||||
|
entity *db.Entity
|
||||||
|
query bool
|
||||||
|
body string
|
||||||
|
tags []string
|
||||||
|
dateFrom *string
|
||||||
|
dateTo *string
|
||||||
|
cardType *db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputModel struct {
|
||||||
|
ti textinput.Model
|
||||||
|
preview *parse.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInputModel() inputModel {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "capture a thought…"
|
||||||
|
ti.Prompt = inputPromptStyle.Render("› ")
|
||||||
|
ti.CharLimit = 500
|
||||||
|
return inputModel{ti: ti}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *inputModel) clearText() {
|
||||||
|
i.ti.SetValue("")
|
||||||
|
i.preview = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) submit() *inputResult {
|
||||||
|
val := i.ti.Value()
|
||||||
|
if val == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parse.Parse(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Query {
|
||||||
|
r := &inputResult{
|
||||||
|
query: true,
|
||||||
|
body: parsed.Body,
|
||||||
|
tags: parsed.FilterTags,
|
||||||
|
dateFrom: parsed.QueryDateFrom,
|
||||||
|
dateTo: parsed.QueryDateTo,
|
||||||
|
}
|
||||||
|
if parsed.QueryCardType != nil {
|
||||||
|
ct := db.CardType(*parsed.QueryCardType)
|
||||||
|
r.cardType = &ct
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &db.Entity{
|
||||||
|
Body: parsed.Body,
|
||||||
|
Title: parsed.Title,
|
||||||
|
Glyph: db.Glyph(parsed.Glyph),
|
||||||
|
Tags: parsed.Tags,
|
||||||
|
}
|
||||||
|
if parsed.TimeAnchor != nil {
|
||||||
|
e.TimeAnchor = parsed.TimeAnchor
|
||||||
|
}
|
||||||
|
if parsed.CardSuffix != nil {
|
||||||
|
ct := db.CardType(*parsed.CardSuffix)
|
||||||
|
e.CardType = &ct
|
||||||
|
}
|
||||||
|
if parsed.Pin {
|
||||||
|
e.Pinned = true
|
||||||
|
}
|
||||||
|
if parsed.Description != nil {
|
||||||
|
e.Description = parsed.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
return &inputResult{entity: e}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||||
|
i.ti, _ = i.ti.Update(msg)
|
||||||
|
val := i.ti.Value()
|
||||||
|
if val != "" {
|
||||||
|
parsed, err := parse.Parse(val)
|
||||||
|
if err == nil {
|
||||||
|
i.preview = parsed
|
||||||
|
} else {
|
||||||
|
i.preview = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i.preview = nil
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) viewBar(width int, focused bool) string {
|
||||||
|
tiView := i.ti.View()
|
||||||
|
if focused {
|
||||||
|
return tiView
|
||||||
|
}
|
||||||
|
val := i.ti.Value()
|
||||||
|
if val != "" {
|
||||||
|
return hintDescStyle.Render("› " + val)
|
||||||
|
}
|
||||||
|
return hintDescStyle.Render("› capture a thought…")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) previewText() string {
|
||||||
|
if i.preview == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
p := i.preview
|
||||||
|
|
||||||
|
if p.Query {
|
||||||
|
q := "?"
|
||||||
|
if p.Body != "" {
|
||||||
|
q += p.Body
|
||||||
|
}
|
||||||
|
for _, t := range p.FilterTags {
|
||||||
|
q += " #" + t
|
||||||
|
}
|
||||||
|
if p.QueryDateFrom != nil {
|
||||||
|
q += " from:" + *p.QueryDateFrom
|
||||||
|
}
|
||||||
|
if p.QueryDateTo != nil {
|
||||||
|
q += " to:" + *p.QueryDateTo
|
||||||
|
}
|
||||||
|
if p.QueryCardType != nil {
|
||||||
|
q += " ^" + *p.QueryCardType
|
||||||
|
}
|
||||||
|
return "search: " + q
|
||||||
|
}
|
||||||
|
|
||||||
|
glyph := glyphForParsed(p.Glyph)
|
||||||
|
body := p.Body
|
||||||
|
if p.Title != nil {
|
||||||
|
body = *p.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
parts = append(parts, glyph, body)
|
||||||
|
for _, t := range p.Tags {
|
||||||
|
parts = append(parts, "#"+t)
|
||||||
|
}
|
||||||
|
if p.Pin {
|
||||||
|
parts = append(parts, "•")
|
||||||
|
}
|
||||||
|
if p.CardSuffix != nil {
|
||||||
|
parts = append(parts, *p.CardSuffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func glyphForParsed(glyph string) string {
|
||||||
|
switch glyph {
|
||||||
|
case "todo":
|
||||||
|
return "○"
|
||||||
|
case "event":
|
||||||
|
return "◇"
|
||||||
|
case "reminder":
|
||||||
|
return "△"
|
||||||
|
default:
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
type keyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
Capture key.Binding
|
||||||
|
Delete key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
Help key.Binding
|
||||||
|
PageUp key.Binding
|
||||||
|
PageDn key.Binding
|
||||||
|
Top key.Binding
|
||||||
|
Bottom key.Binding
|
||||||
|
Todo key.Binding
|
||||||
|
Pin key.Binding
|
||||||
|
Filter key.Binding
|
||||||
|
Promote key.Binding
|
||||||
|
Demote key.Binding
|
||||||
|
Copy key.Binding
|
||||||
|
Edit key.Binding
|
||||||
|
Stream key.Binding
|
||||||
|
Cards key.Binding
|
||||||
|
Sort key.Binding
|
||||||
|
Intent key.Binding
|
||||||
|
Absorb key.Binding
|
||||||
|
Run key.Binding
|
||||||
|
Fill key.Binding
|
||||||
|
FocusLeft key.Binding
|
||||||
|
FocusRight key.Binding
|
||||||
|
Tab key.Binding
|
||||||
|
ToggleRail key.Binding
|
||||||
|
Stumble key.Binding
|
||||||
|
Theme key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = keyMap{
|
||||||
|
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||||
|
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||||
|
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
|
||||||
|
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
||||||
|
Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
|
||||||
|
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
|
||||||
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
|
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")),
|
||||||
|
PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
|
||||||
|
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
|
||||||
|
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")),
|
||||||
|
Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")),
|
||||||
|
Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")),
|
||||||
|
Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")),
|
||||||
|
Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")),
|
||||||
|
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
|
||||||
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
|
||||||
|
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
|
||||||
|
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
|
||||||
|
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||||
|
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||||
|
Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
|
||||||
|
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||||
|
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
||||||
|
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
||||||
|
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
|
||||||
|
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
||||||
|
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
|
||||||
|
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
|
||||||
|
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
|
||||||
|
Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")),
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listModel struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
filtered []*db.Entity
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListModel() listModel {
|
||||||
|
return listModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listModel) setEntities(entities []*db.Entity) {
|
||||||
|
l.entities = entities
|
||||||
|
l.filtered = nil
|
||||||
|
if l.cursor >= len(entities) {
|
||||||
|
l.cursor = max(0, len(entities)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listModel) setSize(width, height int) {
|
||||||
|
l.width = width
|
||||||
|
l.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) displayEntities() []*db.Entity {
|
||||||
|
if l.filtered != nil {
|
||||||
|
return l.filtered
|
||||||
|
}
|
||||||
|
return l.entities
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) selected() *db.Entity {
|
||||||
|
ents := l.displayEntities()
|
||||||
|
if len(ents) == 0 || l.cursor >= len(ents) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ents[l.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if l.cursor > 0 {
|
||||||
|
l.cursor--
|
||||||
|
if l.cursor < l.offset {
|
||||||
|
l.offset = l.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if l.cursor < len(l.displayEntities())-1 {
|
||||||
|
l.cursor++
|
||||||
|
visible := l.visibleCount()
|
||||||
|
if l.cursor >= l.offset+visible {
|
||||||
|
l.offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
l.cursor = 0
|
||||||
|
l.offset = 0
|
||||||
|
case "end", "G":
|
||||||
|
l.cursor = max(0, len(l.displayEntities())-1)
|
||||||
|
visible := l.visibleCount()
|
||||||
|
if l.cursor >= visible {
|
||||||
|
l.offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
l.cursor = max(0, l.cursor-l.visibleCount())
|
||||||
|
if l.cursor < l.offset {
|
||||||
|
l.offset = l.cursor
|
||||||
|
}
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
|
||||||
|
visible := l.visibleCount()
|
||||||
|
if l.cursor >= l.offset+visible {
|
||||||
|
l.offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateGutterWidth = 9
|
||||||
|
|
||||||
|
func (l listModel) view(width int) string {
|
||||||
|
ents := l.displayEntities()
|
||||||
|
if len(ents) == 0 {
|
||||||
|
return statusStyle.Render("no entities")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := groupByDate(ents)
|
||||||
|
entityWidth := width - 4 - dateGutterWidth
|
||||||
|
|
||||||
|
type displayLine struct {
|
||||||
|
text string
|
||||||
|
entityIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []displayLine
|
||||||
|
entityIdx := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
for i, e := range g.entities {
|
||||||
|
var gutter string
|
||||||
|
if i == 0 {
|
||||||
|
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||||
|
} else {
|
||||||
|
gutter = gutterStyle.Render(" │ ")
|
||||||
|
}
|
||||||
|
line := gutter + renderEntity(e, entityWidth)
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: line,
|
||||||
|
entityIdx: entityIdx,
|
||||||
|
})
|
||||||
|
entityIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := l.visibleCount()
|
||||||
|
offset := 0
|
||||||
|
if l.cursor >= visible {
|
||||||
|
offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
end := min(offset+visible, len(lines))
|
||||||
|
for i := offset; i < end; i++ {
|
||||||
|
dl := lines[i]
|
||||||
|
if dl.entityIdx == l.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(dl.text))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) visibleCount() int {
|
||||||
|
if l.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return l.height
|
||||||
|
}
|
||||||
|
|
||||||
|
type dateGroup struct {
|
||||||
|
label string
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupByDate(entities []*db.Entity) []dateGroup {
|
||||||
|
var groups []dateGroup
|
||||||
|
var current *dateGroup
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
label := formatDateLabel(e.CreatedAt)
|
||||||
|
if current == nil || current.label != label {
|
||||||
|
if current != nil {
|
||||||
|
groups = append(groups, *current)
|
||||||
|
}
|
||||||
|
current = &dateGroup{label: label}
|
||||||
|
}
|
||||||
|
current.entities = append(current.entities, e)
|
||||||
|
}
|
||||||
|
if current != nil {
|
||||||
|
groups = append(groups, *current)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDateLabel(t time.Time) string {
|
||||||
|
return strings.ToLower(t.Format("Jan 2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRight(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) >= n {
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", n-len(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderEntity(e *db.Entity, maxWidth int) string {
|
||||||
|
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
|
style := glyphStyle
|
||||||
|
if e.Glyph == db.GlyphTodo && e.CompletedAt != nil {
|
||||||
|
glyphStr = "●"
|
||||||
|
style = completedGlyphStyle
|
||||||
|
}
|
||||||
|
glyph := style.Render(glyphStr)
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras []string
|
||||||
|
if e.Pinned {
|
||||||
|
extras = append(extras, pinnedStyle.Render("•"))
|
||||||
|
}
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
for _, t := range e.Tags[:limit] {
|
||||||
|
extras = append(extras, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraStr := ""
|
||||||
|
if len(extras) > 0 {
|
||||||
|
extraStr = " " + strings.Join(extras, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if maxLen <= 3 {
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripAnsi(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inEsc := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\x1b' {
|
||||||
|
inEsc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEsc {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||||
|
inEsc = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroupByDate(t *testing.T) {
|
||||||
|
may19 := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC)
|
||||||
|
may19b := time.Date(2026, 5, 19, 14, 0, 0, 0, time.UTC)
|
||||||
|
may18 := time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
entities := []*db.Entity{
|
||||||
|
{ID: "1", CreatedAt: may19, Body: "a"},
|
||||||
|
{ID: "2", CreatedAt: may19b, Body: "b"},
|
||||||
|
{ID: "3", CreatedAt: may18, Body: "c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := groupByDate(entities)
|
||||||
|
if len(groups) != 2 {
|
||||||
|
t.Fatalf("expected 2 groups, got %d", len(groups))
|
||||||
|
}
|
||||||
|
if len(groups[0].entities) != 2 {
|
||||||
|
t.Fatalf("first group should have 2 entities, got %d", len(groups[0].entities))
|
||||||
|
}
|
||||||
|
if len(groups[1].entities) != 1 {
|
||||||
|
t.Fatalf("second group should have 1 entity, got %d", len(groups[1].entities))
|
||||||
|
}
|
||||||
|
if groups[0].label != "may 19" {
|
||||||
|
t.Fatalf("first group label = %q, want %q", groups[0].label, "may 19")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupByDate_Empty(t *testing.T) {
|
||||||
|
groups := groupByDate(nil)
|
||||||
|
if len(groups) != 0 {
|
||||||
|
t.Fatalf("expected 0 groups, got %d", len(groups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupByDate_SingleEntity(t *testing.T) {
|
||||||
|
e := []*db.Entity{{ID: "1", CreatedAt: time.Now(), Body: "solo"}}
|
||||||
|
groups := groupByDate(e)
|
||||||
|
if len(groups) != 1 || len(groups[0].entities) != 1 {
|
||||||
|
t.Fatal("single entity should produce 1 group with 1 entity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
maxLen int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"short enough", "hello", 10, "hello"},
|
||||||
|
{"exact length", "hello", 5, "hello"},
|
||||||
|
{"truncated", "hello world", 6, "hello…"},
|
||||||
|
{"very short max", "hello", 3, "…"},
|
||||||
|
{"unicode", "héllo wörld", 7, "héllo …"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := truncate(tt.input, tt.maxLen)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnsi(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no ansi", "hello", "hello"},
|
||||||
|
{"with color", "\x1b[31mred\x1b[0m", "red"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := stripAnsi(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type promoteOption struct {
|
||||||
|
cardType db.CardType
|
||||||
|
label string
|
||||||
|
group string
|
||||||
|
}
|
||||||
|
|
||||||
|
var promoteOptions = []promoteOption{
|
||||||
|
{db.CardSnippet, "snippet", "grab"},
|
||||||
|
{db.CardNote, "note", "read"},
|
||||||
|
{db.CardLink, "link", "read"},
|
||||||
|
{db.CardDecision, "decision", "read"},
|
||||||
|
{db.CardTemplate, "template", "fill"},
|
||||||
|
{db.CardChecklist, "checklist", "fill"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type promoteModel struct {
|
||||||
|
cursor int
|
||||||
|
entityID string
|
||||||
|
body string
|
||||||
|
suggested *db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPromoteModel(entityID, body string) promoteModel {
|
||||||
|
return promoteModel{
|
||||||
|
entityID: entityID,
|
||||||
|
body: body,
|
||||||
|
suggested: carddata.DetectCardType(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) selectedType() db.CardType {
|
||||||
|
return promoteOptions[p.cursor].cardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) update(key string) promoteModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if p.cursor > 0 {
|
||||||
|
p.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if p.cursor < len(promoteOptions)-1 {
|
||||||
|
p.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) view(width int) string {
|
||||||
|
var b string
|
||||||
|
b += titleStyle.Render("promote to card") + "\n\n"
|
||||||
|
|
||||||
|
currentGroup := ""
|
||||||
|
for i, opt := range promoteOptions {
|
||||||
|
if opt.group != currentGroup {
|
||||||
|
currentGroup = opt.group
|
||||||
|
b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType)
|
||||||
|
label := glyph + " " + opt.label
|
||||||
|
|
||||||
|
if p.suggested != nil && *p.suggested == opt.cardType {
|
||||||
|
label += " " + affordanceStyle.Render("*")
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == p.cursor {
|
||||||
|
b += selectedItemStyle.Render(" "+label) + "\n"
|
||||||
|
} else {
|
||||||
|
b += listItemStyle.Render(label) + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b += "\n" + helpStyle.Render("enter:select esc:cancel")
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runStep struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runModel struct {
|
||||||
|
steps []runStep
|
||||||
|
cursor int
|
||||||
|
entityID string
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunModel(entityID string, cardData *string) runModel {
|
||||||
|
m := runModel{entityID: entityID}
|
||||||
|
m.steps = parseChecklist(cardData)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChecklist(cardData *string) []runStep {
|
||||||
|
if cardData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Steps []runStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*cardData), &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data.Steps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) stepsJSON() string {
|
||||||
|
b, _ := json.Marshal(map[string]any{"steps": r.steps})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) doneCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, s := range r.steps {
|
||||||
|
if s.Done {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) update(key string) runModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if r.cursor > 0 {
|
||||||
|
r.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if r.cursor < len(r.steps)-1 {
|
||||||
|
r.cursor++
|
||||||
|
}
|
||||||
|
case " ":
|
||||||
|
if r.cursor < len(r.steps) {
|
||||||
|
r.steps[r.cursor].Done = !r.steps[r.cursor].Done
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
for i := range r.steps {
|
||||||
|
r.steps[i].Done = false
|
||||||
|
}
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) view(width int) string {
|
||||||
|
if len(r.steps) == 0 {
|
||||||
|
return statusStyle.Render("no steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
done := r.doneCount()
|
||||||
|
total := len(r.steps)
|
||||||
|
pct := 0
|
||||||
|
if total > 0 {
|
||||||
|
pct = done * 100 / total
|
||||||
|
}
|
||||||
|
|
||||||
|
header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct)
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, step := range r.steps {
|
||||||
|
var line string
|
||||||
|
if step.Done {
|
||||||
|
line = checkDoneStyle.Render("[✓] " + step.Text)
|
||||||
|
} else {
|
||||||
|
line = checkPendingStyle.Render("[ ] " + step.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == r.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseChecklist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardData *string
|
||||||
|
wantLen int
|
||||||
|
}{
|
||||||
|
{"nil data", nil, 0},
|
||||||
|
{"empty JSON", ptr("{}"), 0},
|
||||||
|
{"malformed JSON", ptr("{bad"), 0},
|
||||||
|
{"valid steps", ptr(`{"steps":[{"text":"step 1","done":false},{"text":"step 2","done":true}]}`), 2},
|
||||||
|
{"empty steps array", ptr(`{"steps":[]}`), 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseChecklist(tt.cardData)
|
||||||
|
if len(got) != tt.wantLen {
|
||||||
|
t.Fatalf("parseChecklist() returned %d steps, want %d", len(got), tt.wantLen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseChecklist_PreservesDoneState(t *testing.T) {
|
||||||
|
data := `{"steps":[{"text":"first","done":false},{"text":"second","done":true}]}`
|
||||||
|
steps := parseChecklist(&data)
|
||||||
|
if steps[0].Done {
|
||||||
|
t.Fatal("step 0 should not be done")
|
||||||
|
}
|
||||||
|
if !steps[1].Done {
|
||||||
|
t.Fatal("step 1 should be done")
|
||||||
|
}
|
||||||
|
if steps[0].Text != "first" || steps[1].Text != "second" {
|
||||||
|
t.Fatalf("texts wrong: %q, %q", steps[0].Text, steps[1].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoneCount(t *testing.T) {
|
||||||
|
r := newRunModel("id", ptr(`{"steps":[{"label":"a","done":true},{"label":"b","done":false},{"label":"c","done":true}]}`))
|
||||||
|
if got := r.doneCount(); got != 2 {
|
||||||
|
t.Fatalf("doneCount() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepsJSON_Roundtrip(t *testing.T) {
|
||||||
|
r := newRunModel("id", ptr(`{"steps":[{"text":"test","done":false}]}`))
|
||||||
|
out := r.stepsJSON()
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Steps []runStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||||
|
t.Fatalf("stepsJSON() produced invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed.Steps) != 1 || parsed.Steps[0].Text != "test" {
|
||||||
|
t.Fatalf("roundtrip failed: %+v", parsed.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity {
|
||||||
|
if query == "" && len(tags) == 0 {
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
lowerTags := make([]string, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
lowerTags[i] = strings.ToLower(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*db.Entity
|
||||||
|
for _, e := range entities {
|
||||||
|
if !matchesSearch(e, query, lowerTags) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesSearch(e *db.Entity, query string, tags []string) bool {
|
||||||
|
if len(tags) > 0 {
|
||||||
|
eTags := make(map[string]bool, len(e.Tags))
|
||||||
|
for _, t := range e.Tags {
|
||||||
|
eTags[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
if !eTags[t] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
haystack := strings.ToLower(e.Body)
|
||||||
|
if e.Title != nil {
|
||||||
|
haystack += " " + strings.ToLower(*e.Title)
|
||||||
|
}
|
||||||
|
if e.Description != nil {
|
||||||
|
haystack += " " + strings.ToLower(*e.Description)
|
||||||
|
}
|
||||||
|
return strings.Contains(haystack, query)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestFilterEntities(t *testing.T) {
|
||||||
|
entities := []*db.Entity{
|
||||||
|
{ID: "1", Body: "buy groceries", Tags: []string{"errand", "food"}},
|
||||||
|
{ID: "2", Body: "read chapter 5", Title: ptr("Go Book"), Tags: []string{"study"}},
|
||||||
|
{ID: "3", Body: "fix login bug", Description: ptr("auth middleware broken"), Tags: []string{"work", "urgent"}},
|
||||||
|
{ID: "4", Body: "empty tags"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
tags []string
|
||||||
|
wantIDs []string
|
||||||
|
}{
|
||||||
|
{"no filter returns all", "", nil, []string{"1", "2", "3", "4"}},
|
||||||
|
{"query matches body", "groceries", nil, []string{"1"}},
|
||||||
|
{"query case insensitive", "GROCERIES", nil, []string{"1"}},
|
||||||
|
{"query matches title", "go book", nil, []string{"2"}},
|
||||||
|
{"query matches description", "middleware", nil, []string{"3"}},
|
||||||
|
{"query no match", "nonexistent", nil, nil},
|
||||||
|
{"single tag filter", "", []string{"study"}, []string{"2"}},
|
||||||
|
{"multi tag filter all present", "", []string{"work", "urgent"}, []string{"3"}},
|
||||||
|
{"multi tag filter partial miss", "", []string{"work", "food"}, nil},
|
||||||
|
{"tag filter no match", "", []string{"missing"}, nil},
|
||||||
|
{"query plus tag", "fix", []string{"work"}, []string{"3"}},
|
||||||
|
{"query plus tag mismatch", "groceries", []string{"work"}, nil},
|
||||||
|
{"entity with nil title and description", "empty", nil, []string{"4"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterEntities(entities, tt.query, tt.tags)
|
||||||
|
gotIDs := make([]string, len(got))
|
||||||
|
for i, e := range got {
|
||||||
|
gotIDs[i] = e.ID
|
||||||
|
}
|
||||||
|
if len(tt.wantIDs) == 0 && len(gotIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(gotIDs) != len(tt.wantIDs) {
|
||||||
|
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
|
||||||
|
}
|
||||||
|
for i := range gotIDs {
|
||||||
|
if gotIDs[i] != tt.wantIDs[i] {
|
||||||
|
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesSearch_NilFields(t *testing.T) {
|
||||||
|
e := &db.Entity{Body: "hello world"}
|
||||||
|
if !matchesSearch(e, "hello", nil) {
|
||||||
|
t.Fatal("should match body")
|
||||||
|
}
|
||||||
|
if matchesSearch(e, "title", nil) {
|
||||||
|
t.Fatal("should not match nil title")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hint struct {
|
||||||
|
key string
|
||||||
|
desc string
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHints(hints []hint) string {
|
||||||
|
parts := make([]string, len(hints))
|
||||||
|
for i, h := range hints {
|
||||||
|
parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTab(label, key string, active bool) string {
|
||||||
|
if active {
|
||||||
|
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
|
||||||
|
}
|
||||||
|
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderStatusBar(m model, width int) string {
|
||||||
|
var leftParts []string
|
||||||
|
|
||||||
|
if m.status != "" {
|
||||||
|
leftParts = append(leftParts, statusStyle.Render(m.status))
|
||||||
|
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
|
||||||
|
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
|
||||||
|
} else {
|
||||||
|
leftParts = append(leftParts, statusStyle.Render(countText(m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ")
|
||||||
|
right := renderHints(contextHints(m))
|
||||||
|
|
||||||
|
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right)
|
||||||
|
if gap < 0 {
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pad := lipgloss.NewStyle().Width(gap).Render("")
|
||||||
|
return leftRendered + pad + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func countText(m model) string {
|
||||||
|
var total int
|
||||||
|
if m.mode == modeCards {
|
||||||
|
total = len(m.cards.filtered)
|
||||||
|
} else {
|
||||||
|
total = len(m.list.displayEntities())
|
||||||
|
}
|
||||||
|
if m.filterTag != "" {
|
||||||
|
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d entities", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextHints(m model) []hint {
|
||||||
|
switch m.state {
|
||||||
|
case stateDetail:
|
||||||
|
switch m.detail.mode {
|
||||||
|
case detailRun:
|
||||||
|
return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
|
||||||
|
case detailFill:
|
||||||
|
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
|
||||||
|
default:
|
||||||
|
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
|
||||||
|
}
|
||||||
|
case stateTagFilter:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
|
case stateConfirm:
|
||||||
|
return []hint{{"y", "confirm"}, {"n", "cancel"}}
|
||||||
|
case statePromote:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
|
case stateAbsorb:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
|
||||||
|
case stateStumble:
|
||||||
|
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.focus {
|
||||||
|
case focusCapture:
|
||||||
|
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
|
||||||
|
case focusTagRail:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
|
||||||
|
case focusDetail:
|
||||||
|
if m.splitDetail {
|
||||||
|
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
|
||||||
|
}
|
||||||
|
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
|
||||||
|
default:
|
||||||
|
if m.splitDetail {
|
||||||
|
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
|
||||||
|
}
|
||||||
|
if m.mode == modeCards {
|
||||||
|
return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
|
||||||
|
}
|
||||||
|
return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
const staleThresholdDays = 30
|
||||||
|
|
||||||
|
type stumbleModel struct {
|
||||||
|
entries []*db.Entity
|
||||||
|
cursor int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStumbleModel() stumbleModel {
|
||||||
|
return stumbleModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) setEntries(entries []*db.Entity) {
|
||||||
|
s.entries = entries
|
||||||
|
s.cursor = 0
|
||||||
|
s.done = len(entries) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) setSize(width, height int) {
|
||||||
|
s.width = width
|
||||||
|
s.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) current() *db.Entity {
|
||||||
|
if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.entries[s.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) advance() {
|
||||||
|
s.cursor++
|
||||||
|
if s.cursor >= len(s.entries) {
|
||||||
|
s.done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) removeCurrent() {
|
||||||
|
if s.cursor < len(s.entries) {
|
||||||
|
s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...)
|
||||||
|
if s.cursor >= len(s.entries) {
|
||||||
|
s.done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) total() int {
|
||||||
|
return len(s.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) view() string {
|
||||||
|
if s.done {
|
||||||
|
return s.doneView()
|
||||||
|
}
|
||||||
|
|
||||||
|
e := s.current()
|
||||||
|
if e == nil {
|
||||||
|
return s.doneView()
|
||||||
|
}
|
||||||
|
|
||||||
|
w := s.width
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries))
|
||||||
|
b.WriteString(detailHeaderStyle.Render(progress))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
|
title := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
title = *e.Title
|
||||||
|
}
|
||||||
|
if len(title) > w-6 {
|
||||||
|
title = title[:w-9] + "…"
|
||||||
|
}
|
||||||
|
b.WriteString(" " + glyphStyle.Render(glyph) + " " + title)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
var meta []string
|
||||||
|
meta = append(meta, string(e.Glyph))
|
||||||
|
if e.CardType != nil {
|
||||||
|
meta = append(meta, string(*e.CardType))
|
||||||
|
}
|
||||||
|
for _, t := range e.Tags {
|
||||||
|
meta = append(meta, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2"))
|
||||||
|
b.WriteString(" " + idStyle.Render(strings.Join(meta, " · ")))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
bodyWidth := w - 6
|
||||||
|
if bodyWidth < 20 {
|
||||||
|
bodyWidth = 20
|
||||||
|
}
|
||||||
|
r, _ := glamour.NewTermRenderer(
|
||||||
|
glamour.WithStylePath(glamourStyle()),
|
||||||
|
glamour.WithWordWrap(bodyWidth),
|
||||||
|
)
|
||||||
|
rendered, err := r.Render(e.Body)
|
||||||
|
if err != nil {
|
||||||
|
rendered = e.Body
|
||||||
|
}
|
||||||
|
rendered = strings.TrimRight(rendered, "\n")
|
||||||
|
b.WriteString(" " + rendered)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
age := daysAgo(e.ModifiedAt)
|
||||||
|
ageText := fmt.Sprintf("last touched %d days ago", age)
|
||||||
|
b.WriteString(" " + stumbleAgeStyle.Render(ageText))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||||
|
|
||||||
|
lines := strings.Split(b.String(), "\n")
|
||||||
|
if len(lines) > s.height {
|
||||||
|
lines = lines[:s.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) doneView() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(detailHeaderStyle.Render(" all caught up"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
reviewed := s.total()
|
||||||
|
if reviewed > 0 {
|
||||||
|
b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(idStyle.Render(" no stale entries found"))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysAgo(t time.Time) int {
|
||||||
|
return int(math.Floor(time.Since(t).Hours() / 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleParams() db.ListParams {
|
||||||
|
threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays)
|
||||||
|
return db.ListParams{
|
||||||
|
ModifiedBefore: &threshold,
|
||||||
|
Sort: "modified_at",
|
||||||
|
Order: "asc",
|
||||||
|
Limit: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle lipgloss.Style
|
||||||
|
statusStyle lipgloss.Style
|
||||||
|
listItemStyle lipgloss.Style
|
||||||
|
selectedItemStyle lipgloss.Style
|
||||||
|
glyphStyle lipgloss.Style
|
||||||
|
completedGlyphStyle lipgloss.Style
|
||||||
|
tagStyle lipgloss.Style
|
||||||
|
idStyle lipgloss.Style
|
||||||
|
inputPromptStyle lipgloss.Style
|
||||||
|
detailHeaderStyle lipgloss.Style
|
||||||
|
detailBodyStyle lipgloss.Style
|
||||||
|
helpStyle lipgloss.Style
|
||||||
|
errorStyle lipgloss.Style
|
||||||
|
dateHeaderStyle lipgloss.Style
|
||||||
|
pinnedStyle lipgloss.Style
|
||||||
|
filterPillStyle lipgloss.Style
|
||||||
|
helpKeyStyle lipgloss.Style
|
||||||
|
helpDescStyle lipgloss.Style
|
||||||
|
affordanceStyle lipgloss.Style
|
||||||
|
useCountStyle lipgloss.Style
|
||||||
|
modeStyle lipgloss.Style
|
||||||
|
detailLabelStyle lipgloss.Style
|
||||||
|
detailValueStyle lipgloss.Style
|
||||||
|
checkDoneStyle lipgloss.Style
|
||||||
|
checkPendingStyle lipgloss.Style
|
||||||
|
searchPillStyle lipgloss.Style
|
||||||
|
gutterStyle lipgloss.Style
|
||||||
|
drawerBorderStyle lipgloss.Style
|
||||||
|
drawerHintsStyle lipgloss.Style
|
||||||
|
drawerPreviewStyle lipgloss.Style
|
||||||
|
separatorStyle lipgloss.Style
|
||||||
|
hintKeyStyle lipgloss.Style
|
||||||
|
hintDescStyle lipgloss.Style
|
||||||
|
railHeaderStyle lipgloss.Style
|
||||||
|
railTagStyle lipgloss.Style
|
||||||
|
railActiveTagStyle lipgloss.Style
|
||||||
|
railCountStyle lipgloss.Style
|
||||||
|
stumbleAgeStyle lipgloss.Style
|
||||||
|
acSelectedStyle lipgloss.Style
|
||||||
|
acItemStyle lipgloss.Style
|
||||||
|
backlinkStyle lipgloss.Style
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTheme() {
|
||||||
|
t := activeTheme()
|
||||||
|
accent := lipgloss.Color(t.Accent)
|
||||||
|
dim := lipgloss.Color(t.Dim)
|
||||||
|
muted := lipgloss.Color(t.Muted)
|
||||||
|
ok := lipgloss.Color(t.Ok)
|
||||||
|
todo := lipgloss.Color(t.Todo)
|
||||||
|
event := lipgloss.Color(t.Event)
|
||||||
|
remind := lipgloss.Color(t.Remind)
|
||||||
|
danger := lipgloss.Color(t.Danger)
|
||||||
|
|
||||||
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1)
|
||||||
|
statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||||
|
listItemStyle = lipgloss.NewStyle().PaddingLeft(4)
|
||||||
|
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("›")
|
||||||
|
glyphStyle = lipgloss.NewStyle().Width(2)
|
||||||
|
completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim)
|
||||||
|
tagStyle = lipgloss.NewStyle().Foreground(ok)
|
||||||
|
idStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1)
|
||||||
|
detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1)
|
||||||
|
helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||||
|
errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1)
|
||||||
|
dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||||
|
pinnedStyle = lipgloss.NewStyle().Foreground(todo)
|
||||||
|
filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||||
|
helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18)
|
||||||
|
helpDescStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true)
|
||||||
|
useCountStyle = lipgloss.NewStyle().Foreground(remind)
|
||||||
|
modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true)
|
||||||
|
detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
detailValueStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
checkDoneStyle = lipgloss.NewStyle().Foreground(ok)
|
||||||
|
checkPendingStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true)
|
||||||
|
gutterStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
drawerBorderStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2)
|
||||||
|
drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2)
|
||||||
|
separatorStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
hintDescStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim)
|
||||||
|
railTagStyle = lipgloss.NewStyle().Foreground(ok)
|
||||||
|
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||||
|
railCountStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
||||||
|
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
acItemStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tagRailModel struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
activeTag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTagRailModel() tagRailModel {
|
||||||
|
return tagRailModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tagRailModel) setTags(tags []db.TagCount) {
|
||||||
|
r.tags = tags
|
||||||
|
if r.cursor >= len(tags) {
|
||||||
|
r.cursor = max(0, len(tags)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tagRailModel) setSize(width, height int) {
|
||||||
|
r.width = width
|
||||||
|
r.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) selectedTag() string {
|
||||||
|
if len(r.tags) == 0 || r.cursor >= len(r.tags) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.tags[r.cursor].Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) update(key string) tagRailModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if r.cursor > 0 {
|
||||||
|
r.cursor--
|
||||||
|
if r.cursor < r.offset {
|
||||||
|
r.offset = r.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if r.cursor < len(r.tags)-1 {
|
||||||
|
r.cursor++
|
||||||
|
visible := r.visibleCount()
|
||||||
|
if r.cursor >= r.offset+visible {
|
||||||
|
r.offset = r.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) visibleCount() int {
|
||||||
|
v := r.height - 2
|
||||||
|
if v <= 0 {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) view(focused bool) string {
|
||||||
|
w := r.width
|
||||||
|
if w <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
headerStyle := railHeaderStyle
|
||||||
|
if focused {
|
||||||
|
headerStyle = headerStyle.Foreground(lipgloss.Color(activeTheme().Accent))
|
||||||
|
}
|
||||||
|
b.WriteString(headerStyle.Render("tags"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if len(r.tags) == 0 {
|
||||||
|
b.WriteString(hintDescStyle.Render(" no tags"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := r.visibleCount()
|
||||||
|
end := min(r.offset+visible, len(r.tags))
|
||||||
|
|
||||||
|
countW := 0
|
||||||
|
for _, tc := range r.tags {
|
||||||
|
cw := len(fmt.Sprintf("%d", tc.Count))
|
||||||
|
if cw > countW {
|
||||||
|
countW = cw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nameW := w - countW - 3
|
||||||
|
if nameW < 4 {
|
||||||
|
nameW = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := r.offset; i < end; i++ {
|
||||||
|
tc := r.tags[i]
|
||||||
|
name := "#" + tc.Tag
|
||||||
|
if len(name) > nameW {
|
||||||
|
name = name[:nameW-1] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
count := fmt.Sprintf("%*d", countW, tc.Count)
|
||||||
|
gap := w - len(name) - len(count) - 1
|
||||||
|
if gap < 1 {
|
||||||
|
gap = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var line string
|
||||||
|
if i == r.cursor && focused {
|
||||||
|
line = selectedItemStyle.Render(" " + name + strings.Repeat(" ", gap) + railCountStyle.Render(count))
|
||||||
|
} else if tc.Tag == r.activeTag {
|
||||||
|
line = " " + railActiveTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
||||||
|
} else {
|
||||||
|
line = " " + railTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(line)
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Theme struct {
|
||||||
|
Name string
|
||||||
|
Dark bool
|
||||||
|
Accent string
|
||||||
|
Dim string
|
||||||
|
Muted string
|
||||||
|
Ok string
|
||||||
|
Todo string
|
||||||
|
Event string
|
||||||
|
Remind string
|
||||||
|
Danger string
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes = []Theme{
|
||||||
|
{Name: "dark", Dark: true, Accent: "#c8942a", Dim: "#504840", Muted: "#8c8070", Ok: "#7aab72", Todo: "#d4a84b", Event: "#6898c8", Remind: "#c8784a", Danger: "#b85858"},
|
||||||
|
{Name: "tinycard", Dark: true, Accent: "#ad8ee6", Dim: "#555a6a", Muted: "#8b90a0", Ok: "#4ade80", Todo: "#fbbf24", Event: "#22d3ee", Remind: "#e8845a", Danger: "#ef4444"},
|
||||||
|
{Name: "catppuccin", Dark: true, Accent: "#cba6f7", Dim: "#6c7086", Muted: "#a6adc8", Ok: "#a6e3a1", Todo: "#f9e2af", Event: "#89b4fa", Remind: "#fab387", Danger: "#f38ba8"},
|
||||||
|
{Name: "nord", Dark: true, Accent: "#88c0d0", Dim: "#4c566a", Muted: "#d8dee9", Ok: "#a3be8c", Todo: "#ebcb8b", Event: "#81a1c1", Remind: "#d08770", Danger: "#bf616a"},
|
||||||
|
{Name: "dracula", Dark: true, Accent: "#bd93f9", Dim: "#6272a4", Muted: "#bfbfbf", Ok: "#50fa7b", Todo: "#f1fa8c", Event: "#8be9fd", Remind: "#ffb86c", Danger: "#ff5555"},
|
||||||
|
{Name: "gruvbox", Dark: true, Accent: "#fabd2f", Dim: "#665c54", Muted: "#a89984", Ok: "#b8bb26", Todo: "#fabd2f", Event: "#83a598", Remind: "#fe8019", Danger: "#fb4934"},
|
||||||
|
{Name: "rosepine", Dark: true, Accent: "#c4a7e7", Dim: "#6e6a86", Muted: "#908caa", Ok: "#a6da95", Todo: "#f6c177", Event: "#31748f", Remind: "#ea9a97", Danger: "#eb6f92"},
|
||||||
|
{Name: "tokyonight", Dark: true, Accent: "#7aa2f7", Dim: "#565f89", Muted: "#a9b1d6", Ok: "#9ece6a", Todo: "#e0af68", Event: "#7aa2f7", Remind: "#ff9e64", Danger: "#f7768e"},
|
||||||
|
{Name: "solarized", Dark: true, Accent: "#268bd2", Dim: "#586e75", Muted: "#657b83", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
|
||||||
|
{Name: "paper", Dark: false, Accent: "#8a6018", Dim: "#a09080", Muted: "#6a5e50", Ok: "#2a6828", Todo: "#7a5c00", Event: "#245890", Remind: "#984020", Danger: "#882030"},
|
||||||
|
{Name: "catppuccin-latte", Dark: false, Accent: "#8839ef", Dim: "#9ca0b0", Muted: "#6c6f85", Ok: "#40a02b", Todo: "#df8e1d", Event: "#1e66f5", Remind: "#fe640b", Danger: "#d20f39"},
|
||||||
|
{Name: "rosepine-dawn", Dark: false, Accent: "#907aa9", Dim: "#9893a5", Muted: "#797593", Ok: "#56949f", Todo: "#ea9d34", Event: "#286983", Remind: "#d7827e", Danger: "#b4637a"},
|
||||||
|
{Name: "solarized-light", Dark: false, Accent: "#268bd2", Dim: "#93a1a1", Muted: "#586e75", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeThemeIndex int
|
||||||
|
|
||||||
|
func activeTheme() Theme {
|
||||||
|
return themes[activeThemeIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleTheme() Theme {
|
||||||
|
activeThemeIndex = (activeThemeIndex + 1) % len(themes)
|
||||||
|
applyTheme()
|
||||||
|
saveTheme()
|
||||||
|
return themes[activeThemeIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func glamourStyle() string {
|
||||||
|
if themes[activeThemeIndex].Dark {
|
||||||
|
return "dark"
|
||||||
|
}
|
||||||
|
return "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
func themePath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".nib", "theme")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTheme() {
|
||||||
|
p := themePath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(string(data))
|
||||||
|
for i, t := range themes {
|
||||||
|
if t.Name == name {
|
||||||
|
activeThemeIndex = i
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTheme() {
|
||||||
|
p := themePath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(p, []byte(themes[activeThemeIndex].Name+"\n"), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(store *db.Store) error {
|
||||||
|
m := newModel(store)
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
+1367
-203
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.
+48
-30
@@ -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,27 +42,47 @@
|
|||||||
<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">
|
<div class="type-col">
|
||||||
<span class="type-glyph">◆</span>
|
<div class="type-col-lbl">read</div>
|
||||||
<span>snippet</span>
|
<button data-type="note" class="type-btn">
|
||||||
</button>
|
<span class="type-glyph glyph-note">¶</span>
|
||||||
<button data-type="template" class="type-btn">
|
<span class="type-name">note</span>
|
||||||
<span class="type-glyph">◈</span>
|
<span class="type-hint">markdown content</span>
|
||||||
<span>template</span>
|
|
||||||
</button>
|
|
||||||
<button data-type="checklist" class="type-btn">
|
|
||||||
<span class="type-glyph">☐</span>
|
|
||||||
<span>checklist</span>
|
|
||||||
</button>
|
|
||||||
<button data-type="decision" class="type-btn">
|
|
||||||
<span class="type-glyph">⚖</span>
|
|
||||||
<span>decision</span>
|
|
||||||
</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>
|
||||||
|
<button data-type="decision" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-decision">⚖</span>
|
||||||
|
<span class="type-name">decision</span>
|
||||||
|
<span class="type-hint">choice + rationale</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="type-col">
|
||||||
|
<div class="type-col-lbl">grab</div>
|
||||||
|
<button data-type="snippet" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-snippet">◆</span>
|
||||||
|
<span class="type-name">snippet</span>
|
||||||
|
<span class="type-hint">code, command, text</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="type-col">
|
||||||
|
<div class="type-col-lbl">fill</div>
|
||||||
|
<button data-type="template" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-template">◈</span>
|
||||||
|
<span class="type-name">template</span>
|
||||||
|
<span class="type-hint">fillable ${slot}s</span>
|
||||||
|
</button>
|
||||||
|
<button data-type="checklist" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-checklist">☐</span>
|
||||||
|
<span class="type-name">checklist</span>
|
||||||
|
<span class="type-hint">step-by-step</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close">esc to cancel</button>
|
<button class="modal-close">esc to cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +97,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1330
-186
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user