Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4517b2e37c | |||
| 2684eb1d24 |
@@ -1,70 +1,27 @@
|
|||||||
# Code Review Fixes
|
# Code Hardening — Senior Dev Audit Fixes
|
||||||
|
|
||||||
## Phase 1: Critical — Concurrency & Data Safety
|
## 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)
|
||||||
|
|
||||||
### 1. ULID entropy not goroutine-safe
|
## Phase 2: CI Pipeline
|
||||||
- [x] Wrap MonotonicEntropy in LockedMonotonicReader (`internal/ulid/ulid.go`)
|
- [x] Gitea Actions workflow: test + lint on PR
|
||||||
- [x] Add concurrent uniqueness test (100 goroutines)
|
|
||||||
- [x] Verify `go test -race ./internal/ulid/...`
|
|
||||||
|
|
||||||
### 2. Migration PRAGMA bug
|
## Phase 3: context.Context in Store
|
||||||
- [x] Move `PRAGMA foreign_keys = OFF` before transaction in v3 migration (`internal/db/db.go`)
|
- [x] Thread context.Context through all Store methods
|
||||||
- [x] Re-enable after commit
|
- [x] Use context in API handlers (from r.Context())
|
||||||
- [x] Remove dead `currentSchema` constant while here
|
- [x] Use context in CLI commands (cobra context)
|
||||||
|
|
||||||
### 3. LIKE wildcard injection in link resolution
|
## Phase 4: cmd/ Tests
|
||||||
- [x] Escape `%` and `_` in link text before LIKE query (`internal/db/links.go`)
|
- [x] Test add command
|
||||||
- [x] Add `ESCAPE '\'` clause
|
- [x] Test ls command
|
||||||
- [ ] Test with `[[%]]` and `[[_]]` link text
|
- [x] Test promote/demote commands
|
||||||
|
- [x] Test delete command
|
||||||
|
- [x] Test absorb command
|
||||||
|
|
||||||
## Phase 2: High — Security & API Hygiene
|
## Phase 5: Backup/Export
|
||||||
|
- [x] nib export — dump entities to JSON
|
||||||
### 4. No auth warning for non-localhost binding
|
- [x] nib backup — safe SQLite backup (handles WAL)
|
||||||
- [x] Print loud warning when `--host != 127.0.0.1` and no auth configured (`cmd/serve.go`)
|
|
||||||
- [ ] Consider `--no-auth` flag requirement for non-localhost
|
|
||||||
|
|
||||||
### 5. writeJSON ignores encoder errors
|
|
||||||
- [x] Log error from `json.Encoder.Encode()` in `writeJSON` (`internal/api/helpers.go`)
|
|
||||||
|
|
||||||
### 6. DELETE endpoint semantics
|
|
||||||
- [x] Add `?permanent=true` query param for hard delete (`internal/api/entities.go`)
|
|
||||||
- [x] Add `HardDelete` store method (`internal/db/entities.go`)
|
|
||||||
- [ ] Update frontend and CLI to match
|
|
||||||
- [x] Keep backward compat: double-delete still works without param
|
|
||||||
|
|
||||||
## Phase 3: Medium — Frontend Robustness
|
|
||||||
|
|
||||||
### 7. No resp.ok checks on fetch calls
|
|
||||||
- [x] Add `checkedJSON()` wrapper with error extraction (`web/app.js`)
|
|
||||||
- [x] All API methods use `checkedJSON(resp)` instead of `resp.json()`
|
|
||||||
- [ ] Surface API errors to user via notification/toast
|
|
||||||
|
|
||||||
### 8. Inconsistent event.stopPropagation
|
|
||||||
- [x] Add stopPropagation to all renderStreamPeek action buttons
|
|
||||||
- [x] Add stopPropagation to all renderCardPeek action buttons and inline section buttons
|
|
||||||
|
|
||||||
### 9. Duplicate section rendering
|
|
||||||
- [x] Extract `renderCardSections()` shared function (`web/app.js`)
|
|
||||||
- [x] Refactor `renderInlineDetail` to use shared function
|
|
||||||
- [x] Refactor `renderCardPeek` to use shared function
|
|
||||||
|
|
||||||
## Phase 4: Medium — Data Integrity
|
|
||||||
|
|
||||||
### 10. Absorb discards source title/description
|
|
||||||
- [x] If target has no title, inherit from source (`internal/db/entities.go`)
|
|
||||||
- [x] If target has no description, inherit from source
|
|
||||||
- [ ] Test title/description preservation
|
|
||||||
|
|
||||||
## Phase 5: Low — Housekeeping & UX
|
|
||||||
|
|
||||||
### 11. Backup path collision
|
|
||||||
- [x] Use millisecond-precision timestamps (`cmd/backup.go`)
|
|
||||||
|
|
||||||
### 12. spaHandler path safety
|
|
||||||
- [x] Add explicit `path.Clean` in spaHandler (`internal/api/router.go`)
|
|
||||||
|
|
||||||
### 13. Focus-peek escape hatch
|
|
||||||
- [ ] Add visible close/back button in focus-peek mode (`web/app.js`)
|
|
||||||
|
|
||||||
### 14. Hard delete confirmation
|
|
||||||
- [ ] Add confirmation step or undo toast before permanent delete (`web/app.js`)
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ func runBackup(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405.000"))
|
dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405"))
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
dst = args[0]
|
dst = args[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,6 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
if serveDev {
|
if serveDev {
|
||||||
fmt.Println(" CORS enabled (dev mode)")
|
fmt.Println(" CORS enabled (dev mode)")
|
||||||
}
|
}
|
||||||
if serveHost != "127.0.0.1" && serveHost != "localhost" && serveHost != "::1" {
|
|
||||||
fmt.Fprintln(os.Stderr, " WARNING: binding to non-localhost with no authentication — API is open to the network")
|
|
||||||
}
|
|
||||||
|
|
||||||
var listenErr error
|
var listenErr error
|
||||||
if useTLS {
|
if useTLS {
|
||||||
|
|||||||
@@ -272,20 +272,6 @@ 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")
|
||||||
|
|
||||||
if r.URL.Query().Get("permanent") == "true" {
|
|
||||||
if err := store.HardDelete(r.Context(), id); err != nil {
|
|
||||||
if err == db.ErrNotFound {
|
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeInternalError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, DeleteResponse{Result: "hard"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := store.SoftDelete(r.Context(), id)
|
result, err := store.SoftDelete(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ type EntityResponse struct {
|
|||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
json.NewEncoder(w).Encode(v)
|
||||||
log.Printf("writeJSON encode error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
|
|||||||
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := path.Clean(r.URL.Path)
|
p := r.URL.Path
|
||||||
if p == "/" || path.Ext(p) == "" {
|
if p == "/" || path.Ext(p) == "" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write(indexHTML)
|
w.Write(indexHTML)
|
||||||
|
|||||||
+10
-12
@@ -56,6 +56,8 @@ func (s *Store) Backup(dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentSchema = 5
|
||||||
|
|
||||||
var migrations = []func(db *sql.DB) error{
|
var migrations = []func(db *sql.DB) error{
|
||||||
// v1: initial schema
|
// v1: initial schema
|
||||||
func(db *sql.DB) error {
|
func(db *sql.DB) error {
|
||||||
@@ -106,18 +108,17 @@ var migrations = []func(db *sql.DB) error{
|
|||||||
|
|
||||||
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||||
func(db *sql.DB) error {
|
func(db *sql.DB) error {
|
||||||
// PRAGMA foreign_keys must be set outside a transaction (SQLite ignores it inside one)
|
|
||||||
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
|
||||||
return fmt.Errorf("migrate fk off: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Disable FK checks during rebuild to avoid dangling references
|
||||||
|
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk off: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||||
return fmt.Errorf("migrate rename: %w", err)
|
return fmt.Errorf("migrate rename: %w", err)
|
||||||
}
|
}
|
||||||
@@ -168,14 +169,11 @@ var migrations = []func(db *sql.DB) error{
|
|||||||
return fmt.Errorf("migrate tags drop: %w", err)
|
return fmt.Errorf("migrate tags drop: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
||||||
return fmt.Errorf("migrate fk on: %w", err)
|
return fmt.Errorf("migrate fk on: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return tx.Commit()
|
||||||
},
|
},
|
||||||
|
|
||||||
// v4: add indexes for common query filters
|
// v4: add indexes for common query filters
|
||||||
|
|||||||
+2
-24
@@ -464,18 +464,6 @@ func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error)
|
|||||||
return DeletedSoft, err
|
return DeletedSoft, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
|
||||||
res, err := s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
|
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
|
||||||
target, err := s.Get(ctx, targetID)
|
target, err := s.Get(ctx, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -499,18 +487,8 @@ func (s *Store) Absorb(ctx context.Context, 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
|
||||||
|
|
||||||
title := target.Title
|
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
|
||||||
if title == nil {
|
merged, now, targetID); err != nil {
|
||||||
title = source.Title
|
|
||||||
}
|
|
||||||
desc := target.Description
|
|
||||||
if desc == nil {
|
|
||||||
desc = source.Description
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
|
||||||
"UPDATE entities SET body = ?, title = ?, description = ?, modified_at = ? WHERE id = ?",
|
|
||||||
merged, title, desc, now, targetID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -15,11 +15,6 @@ type Backlink struct {
|
|||||||
LinkText string
|
LinkText string
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeLike(s string) string {
|
|
||||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
|
||||||
return r.Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
|
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
|
||||||
lower := strings.ToLower(linkText)
|
lower := strings.ToLower(linkText)
|
||||||
|
|
||||||
@@ -34,8 +29,8 @@ func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, ex
|
|||||||
|
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
SELECT id FROM entities
|
SELECT id FROM entities
|
||||||
WHERE LOWER(body) LIKE ? ESCAPE '\' AND id != ? AND deleted_at IS NULL
|
WHERE LOWER(body) LIKE ? AND id != ? AND deleted_at IS NULL
|
||||||
ORDER BY created_at DESC LIMIT 1`, "%"+escapeLike(lower)+"%", excludeID).Scan(&id)
|
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%", excludeID).Scan(&id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &id
|
return &id
|
||||||
}
|
}
|
||||||
@@ -60,6 +55,26 @@ func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ResolveLink(ctx context.Context, linkText string) (*Entity, error) {
|
||||||
|
lower := strings.ToLower(linkText)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM entities
|
||||||
|
WHERE LOWER(title) = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, lower).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM entities
|
||||||
|
WHERE LOWER(body) LIKE ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%").Scan(&id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
|
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT e.id, e.title, e.body, el.link_text
|
SELECT e.id, e.title, e.body, el.link_text
|
||||||
|
|||||||
@@ -223,3 +223,50 @@ func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
|
|||||||
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
|
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveLink_TitleMatch(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
title := "nginx config"
|
||||||
|
target := &Entity{Body: "proxy_pass details", Title: &title, Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := s.ResolveLink(ctx, "nginx config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.ID != target.ID {
|
||||||
|
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLink_BodyFallback(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &Entity{Body: "deploy staging checklist", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := s.ResolveLink(ctx, "deploy staging")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.ID != target.ID {
|
||||||
|
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLink_NotFound(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := s.ResolveLink(ctx, "nonexistent entry")
|
||||||
|
if err != ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ type backlinksLoadedMsg struct {
|
|||||||
backlinks []db.Backlink
|
backlinks []db.Backlink
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type linkFollowedMsg struct {
|
||||||
|
entity *db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
type tagsLoadedMsg struct {
|
type tagsLoadedMsg struct {
|
||||||
tags []db.TagCount
|
tags []db.TagCount
|
||||||
}
|
}
|
||||||
@@ -208,6 +212,26 @@ func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func followLink(store *db.Store, linkText string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entity, err := store.ResolveLink(context.Background(), linkText)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return linkFollowedMsg{entity}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func followLinkByID(store *db.Store, entityID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entity, err := store.Get(context.Background(), entityID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return linkFollowedMsg{entity}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadRailTags(store *db.Store) tea.Cmd {
|
func loadRailTags(store *db.Store) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
tags, err := store.ListTags(context.Background(), false)
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ func renderHelp(width, height int) string {
|
|||||||
{"!", "toggle pin"},
|
{"!", "toggle pin"},
|
||||||
{"r", "run checklist"},
|
{"r", "run checklist"},
|
||||||
{"f", "fill template"},
|
{"f", "fill template"},
|
||||||
|
{"[", "follow [[link]]"},
|
||||||
|
{"esc", "back (pops link history)"},
|
||||||
}},
|
}},
|
||||||
{"Stumble", [][2]string{
|
{"Stumble", [][2]string{
|
||||||
{"n / →", "skip to next"},
|
{"n / →", "skip to next"},
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
type linkKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
linkOutgoing linkKind = iota
|
||||||
|
linkBacklink
|
||||||
|
)
|
||||||
|
|
||||||
|
type linkItem struct {
|
||||||
|
text string
|
||||||
|
entityID string
|
||||||
|
kind linkKind
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkPickerModel struct {
|
||||||
|
items []linkItem
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLinkPicker(body string, backlinks []db.Backlink) linkPickerModel {
|
||||||
|
var items []linkItem
|
||||||
|
|
||||||
|
for _, lt := range link.ExtractLinks(body) {
|
||||||
|
items = append(items, linkItem{text: lt, kind: linkOutgoing})
|
||||||
|
}
|
||||||
|
for _, bl := range backlinks {
|
||||||
|
label := bl.Body
|
||||||
|
if bl.Title != nil {
|
||||||
|
label = *bl.Title
|
||||||
|
} else if len(label) > 50 {
|
||||||
|
label = label[:50] + "…"
|
||||||
|
}
|
||||||
|
items = append(items, linkItem{text: label, entityID: bl.EntityID, kind: linkBacklink})
|
||||||
|
}
|
||||||
|
|
||||||
|
return linkPickerModel{items: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lp linkPickerModel) selected() linkItem {
|
||||||
|
if len(lp.items) == 0 || lp.cursor >= len(lp.items) {
|
||||||
|
return linkItem{}
|
||||||
|
}
|
||||||
|
return lp.items[lp.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lp linkPickerModel) update(key string) linkPickerModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if lp.cursor > 0 {
|
||||||
|
lp.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if lp.cursor < len(lp.items)-1 {
|
||||||
|
lp.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lp linkPickerModel) view(width int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("follow link") + "\n\n")
|
||||||
|
|
||||||
|
if len(lp.items) == 0 {
|
||||||
|
b.WriteString(hintDescStyle.Render(" no links or backlinks"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("esc:back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
prevKind := linkKind(-1)
|
||||||
|
for i, item := range lp.items {
|
||||||
|
if item.kind != prevKind {
|
||||||
|
if item.kind == linkOutgoing {
|
||||||
|
b.WriteString(dateHeaderStyle.Render("── outgoing ──") + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(dateHeaderStyle.Render("── backlinks ──") + "\n")
|
||||||
|
}
|
||||||
|
prevKind = item.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
var label string
|
||||||
|
if item.kind == linkOutgoing {
|
||||||
|
label = "[[" + item.text + "]]"
|
||||||
|
} else {
|
||||||
|
label = "← " + item.text
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == lp.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + label))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(label))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("enter:follow esc:back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
statePromote
|
statePromote
|
||||||
stateAbsorb
|
stateAbsorb
|
||||||
stateStumble
|
stateStumble
|
||||||
|
stateLinkPicker
|
||||||
)
|
)
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
@@ -90,6 +91,8 @@ type model struct {
|
|||||||
stumble stumbleModel
|
stumble stumbleModel
|
||||||
showHelp bool
|
showHelp bool
|
||||||
autocomplete autocompleteModel
|
autocomplete autocompleteModel
|
||||||
|
linkPicker linkPickerModel
|
||||||
|
navStack []string
|
||||||
|
|
||||||
focus focusPane
|
focus focusPane
|
||||||
splitDetail bool
|
splitDetail bool
|
||||||
@@ -293,6 +296,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case linkFollowedMsg:
|
||||||
|
m.detail.setEntity(msg.entity)
|
||||||
|
m.state = stateDetail
|
||||||
|
return m, loadBacklinks(m.store, msg.entity.ID)
|
||||||
|
|
||||||
case tagsLoadedMsg:
|
case tagsLoadedMsg:
|
||||||
m.filter.setTags(msg.tags)
|
m.filter.setTags(msg.tags)
|
||||||
m.tagRail.setTags(msg.tags)
|
m.tagRail.setTags(msg.tags)
|
||||||
@@ -346,6 +354,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateAbsorb(msg)
|
return m.updateAbsorb(msg)
|
||||||
case stateStumble:
|
case stateStumble:
|
||||||
return m.updateStumble(msg)
|
return m.updateStumble(msg)
|
||||||
|
case stateLinkPicker:
|
||||||
|
return m.updateLinkPicker(msg)
|
||||||
default:
|
default:
|
||||||
return m.updateKeys(msg)
|
return m.updateKeys(msg)
|
||||||
}
|
}
|
||||||
@@ -717,6 +727,19 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if len(m.navStack) > 0 {
|
||||||
|
prevID := m.navStack[len(m.navStack)-1]
|
||||||
|
m.navStack = m.navStack[:len(m.navStack)-1]
|
||||||
|
return m, tea.Batch(
|
||||||
|
func() tea.Msg {
|
||||||
|
e, err := m.store.Get(context.Background(), prevID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return linkFollowedMsg{e}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
if m.isSplit() {
|
if m.isSplit() {
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
m.splitDetail = true
|
m.splitDetail = true
|
||||||
@@ -835,6 +858,14 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "[":
|
||||||
|
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||||
|
m.linkPicker = newLinkPicker(m.detail.entity.Body, m.detail.backlinks)
|
||||||
|
m.state = stateLinkPicker
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case "r":
|
case "r":
|
||||||
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
|
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||||
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
|
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
|
||||||
@@ -927,6 +958,29 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) updateLinkPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.state = stateDetail
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
item := m.linkPicker.selected()
|
||||||
|
if item.text == "" && item.entityID == "" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.detail.entity != nil {
|
||||||
|
m.navStack = append(m.navStack, m.detail.entity.ID)
|
||||||
|
}
|
||||||
|
if item.kind == linkBacklink {
|
||||||
|
return m, followLinkByID(m.store, item.entityID)
|
||||||
|
}
|
||||||
|
return m, followLink(m.store, item.text)
|
||||||
|
default:
|
||||||
|
m.linkPicker = m.linkPicker.update(msg.String())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "q":
|
case "esc", "q":
|
||||||
@@ -1045,6 +1099,8 @@ func (m model) View() string {
|
|||||||
content = m.absorb.view(m.width)
|
content = m.absorb.view(m.width)
|
||||||
case stateStumble:
|
case stateStumble:
|
||||||
content = m.stumble.view()
|
content = m.stumble.view()
|
||||||
|
case stateLinkPicker:
|
||||||
|
content = m.linkPicker.view(m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
header := m.headerView()
|
header := m.headerView()
|
||||||
|
|||||||
@@ -8,15 +8,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
entropy *ulid.LockedMonotonicReader
|
entropy *ulid.MonotonicEntropy
|
||||||
entropyOnce sync.Once
|
entropyOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func New() string {
|
func New() string {
|
||||||
entropyOnce.Do(func() {
|
entropyOnce.Do(func() {
|
||||||
entropy = &ulid.LockedMonotonicReader{
|
entropy = ulid.Monotonic(rand.Reader, 0)
|
||||||
MonotonicReader: ulid.Monotonic(rand.Reader, 0),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return ulid.MustNew(ulid.Now(), entropy).String()
|
return ulid.MustNew(ulid.Now(), entropy).String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ulid
|
package ulid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,25 +26,3 @@ func TestNew_Sortable(t *testing.T) {
|
|||||||
t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b)
|
t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_ConcurrentUnique(t *testing.T) {
|
|
||||||
const n = 100
|
|
||||||
ids := make([]string, n)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
go func(idx int) {
|
|
||||||
defer wg.Done()
|
|
||||||
ids[idx] = New()
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
seen := make(map[string]struct{}, n)
|
|
||||||
for _, id := range ids {
|
|
||||||
if _, dup := seen[id]; dup {
|
|
||||||
t.Fatalf("duplicate ULID under concurrency: %s", id)
|
|
||||||
}
|
|
||||||
seen[id] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+112
-83
@@ -44,15 +44,6 @@
|
|||||||
|
|
||||||
// ========== API ==========
|
// ========== API ==========
|
||||||
|
|
||||||
async function checkedJSON(resp) {
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
const msg = body.message || body.error || `HTTP ${resp.status}`;
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
async listEntities(params = {}) {
|
async listEntities(params = {}) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -66,7 +57,7 @@
|
|||||||
if (params.limit) q.set('limit', String(params.limit));
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
if (params.offset) q.set('offset', String(params.offset));
|
if (params.offset) q.set('offset', String(params.offset));
|
||||||
const resp = await fetch('/api/entities?' + q);
|
const resp = await fetch('/api/entities?' + q);
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async createEntity(data) {
|
async createEntity(data) {
|
||||||
const resp = await fetch('/api/entities', {
|
const resp = await fetch('/api/entities', {
|
||||||
@@ -74,11 +65,11 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async getEntity(id) {
|
async getEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id);
|
const resp = await fetch('/api/entities/' + id);
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async updateEntity(id, data) {
|
async updateEntity(id, data) {
|
||||||
const resp = await fetch('/api/entities/' + id, {
|
const resp = await fetch('/api/entities/' + id, {
|
||||||
@@ -86,11 +77,10 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async deleteEntity(id) {
|
async deleteEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id, { method: 'DELETE' });
|
return fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||||
return checkedJSON(resp);
|
|
||||||
},
|
},
|
||||||
async promoteEntity(id, cardType, cardData) {
|
async promoteEntity(id, cardType, cardData) {
|
||||||
const resp = await fetch('/api/entities/' + id + '/promote', {
|
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||||||
@@ -98,7 +88,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||||||
});
|
});
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async demoteEntity(id) {
|
async demoteEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id + '/demote', {
|
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||||||
@@ -106,7 +96,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async useEntity(id) {
|
async useEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id + '/use', {
|
const resp = await fetch('/api/entities/' + id + '/use', {
|
||||||
@@ -114,7 +104,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async absorbEntity(targetId, sourceId) {
|
async absorbEntity(targetId, sourceId) {
|
||||||
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
|
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
|
||||||
@@ -122,13 +112,13 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ source_id: sourceId }),
|
body: JSON.stringify({ source_id: sourceId }),
|
||||||
});
|
});
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
async listTags(params = {}) {
|
async listTags(params = {}) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params.cards_only) q.set('cards_only', 'true');
|
if (params.cards_only) q.set('cards_only', 'true');
|
||||||
const resp = await fetch('/api/tags?' + q);
|
const resp = await fetch('/api/tags?' + q);
|
||||||
return checkedJSON(resp);
|
return resp.json();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -687,52 +677,6 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCardSections(e, bodyClass) {
|
|
||||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
|
||||||
const hasDecision = data.chose != null;
|
|
||||||
const hasSteps = data.steps && data.steps.length;
|
|
||||||
const hasLink = !!data.url;
|
|
||||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
|
||||||
|
|
||||||
let sections = '';
|
|
||||||
if (hasDecision) {
|
|
||||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
|
||||||
sections += `<div class="peek-sec">
|
|
||||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
|
||||||
<div class="peek-sec-inner peek-decision">
|
|
||||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
|
||||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
|
||||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
if (hasLink && !hasDecision) {
|
|
||||||
sections += `<div class="peek-sec">
|
|
||||||
<div class="peek-sec-lbl">link</div>
|
|
||||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
if (hasSteps) {
|
|
||||||
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
|
||||||
sections += `<div class="peek-sec">
|
|
||||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
|
||||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
if (!hasDecision && e.body) {
|
|
||||||
const lang = data.lang || '';
|
|
||||||
const isCode = lang || e.card_type === 'snippet';
|
|
||||||
const bodyHtml = isCode
|
|
||||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
|
||||||
: `<div class="${bodyClass} md">${renderMd(e.body)}</div>`;
|
|
||||||
sections += `<div class="peek-sec">
|
|
||||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
|
|
||||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
return { sections, data, hasDecision, hasSteps, hasLink, hasFill };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInlineDetail(e) {
|
function renderInlineDetail(e) {
|
||||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||||
let actions = '';
|
let actions = '';
|
||||||
@@ -750,10 +694,51 @@
|
|||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
if (e.card_type) {
|
if (e.card_type) {
|
||||||
const cs = renderCardSections(e, 'exp-body');
|
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||||
content = cs.sections;
|
const hasDecision = data.chose != null;
|
||||||
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
const hasSteps = data.steps && data.steps.length;
|
||||||
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
const hasLink = !!data.url;
|
||||||
|
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||||
|
|
||||||
|
if (hasDecision) {
|
||||||
|
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||||
|
<div class="peek-sec-inner peek-decision">
|
||||||
|
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||||
|
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||||
|
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (hasLink && !hasDecision) {
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">link</div>
|
||||||
|
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (hasSteps) {
|
||||||
|
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||||
|
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||||
|
</div>`;
|
||||||
|
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||||
|
}
|
||||||
|
if (hasFill) {
|
||||||
|
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||||
|
}
|
||||||
|
if (!hasDecision && e.body) {
|
||||||
|
const lang = data.lang || '';
|
||||||
|
const isCode = lang || e.card_type === 'snippet';
|
||||||
|
const bodyHtml = isCode
|
||||||
|
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||||
|
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
|
||||||
|
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
||||||
}
|
}
|
||||||
@@ -876,15 +861,15 @@
|
|||||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||||
|
|
||||||
let actions = '';
|
let actions = '';
|
||||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||||
if (!e.card_type) {
|
if (!e.card_type) {
|
||||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
||||||
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote →</button>`;
|
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||||
}
|
}
|
||||||
if (e.card_type) {
|
if (e.card_type) {
|
||||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||||
} else {
|
} else {
|
||||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
|
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="peek-scroll">
|
return `<div class="peek-scroll">
|
||||||
@@ -914,17 +899,61 @@
|
|||||||
const glyph = GLYPHS[e.card_type] || '◆';
|
const glyph = GLYPHS[e.card_type] || '◆';
|
||||||
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
|
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
|
||||||
const affs = detectAffordances(e);
|
const affs = detectAffordances(e);
|
||||||
|
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||||
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
|
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
|
||||||
|
const hasSteps = data.steps && data.steps.length;
|
||||||
|
const hasDecision = data.chose != null;
|
||||||
|
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||||
|
const hasLink = !!data.url;
|
||||||
|
|
||||||
const cs = renderCardSections(e, 'peek-body');
|
let sections = '';
|
||||||
|
|
||||||
let actions = `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
if (hasDecision) {
|
||||||
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||||
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
sections += `<div class="peek-sec">
|
||||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
<div class="peek-sec-inner peek-decision">
|
||||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||||
|
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||||
|
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLink && !hasDecision) {
|
||||||
|
sections += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">link</div>
|
||||||
|
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSteps) {
|
||||||
|
const steps = data.steps.map((s, i) => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||||
|
sections += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
|
||||||
|
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDecision && e.body) {
|
||||||
|
const lang = data.lang || '';
|
||||||
|
const isCode = lang || e.card_type === 'snippet';
|
||||||
|
const bodyHtml = isCode
|
||||||
|
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||||
|
: `<div class="peek-body md">${renderMd(e.body)}</div>`;
|
||||||
|
sections += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
|
||||||
|
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
||||||
|
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
||||||
|
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
||||||
|
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||||
|
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
||||||
|
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||||
|
|
||||||
return `<div class="peek-scroll">
|
return `<div class="peek-scroll">
|
||||||
<div class="peek-card">
|
<div class="peek-card">
|
||||||
@@ -940,7 +969,7 @@
|
|||||||
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
|
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
|
||||||
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
|
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
${cs.sections}
|
${sections}
|
||||||
</div>
|
</div>
|
||||||
<div class="peek-acts">${actions}</div>
|
<div class="peek-acts">${actions}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user