From 7711240d68626507eb6adfe40c175f6b0ef2017f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 13:47:08 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20absorb=20command=20=E2=80=94=20me?= =?UTF-8?q?rge=20source=20entity=20into=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB: Absorb() merges body (newline-separated), unions tags, demotes crystallized sources, soft-deletes source. Rejects crystallized targets. API: POST /api/entities/:id/absorb { source_id } CLI: nib absorb with prefix ID resolution Web: absorb button on fluid entities, 'a' keyboard shortcut, source picker modal --- cmd/absorb.go | 53 +++++++++++++++++++++++++ internal/api/api_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ internal/api/entities.go | 44 ++++++++++++++++++++ internal/api/router.go | 1 + internal/db/db.go | 7 ++-- internal/db/entities.go | 57 ++++++++++++++++++++++++++ web/app.js | 66 +++++++++++++++++++++++++++--- web/index.html | 9 +++++ web/style.css | 27 +++++++++++++ 9 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 cmd/absorb.go diff --git a/cmd/absorb.go b/cmd/absorb.go new file mode 100644 index 0000000..acba7f7 --- /dev/null +++ b/cmd/absorb.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var absorbCmd = &cobra.Command{ + Use: "absorb ", + Short: "pull source material into target, ghost the source", + Args: cobra.ExactArgs(2), + RunE: runAbsorb, +} + +func init() { + rootCmd.AddCommand(absorbCmd) +} + +func runAbsorb(_ *cobra.Command, args []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + targetID, err := store.Resolve(args[0]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[0]) + } + + sourceID, err := store.Resolve(args[1]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[1]) + } + + if targetID == sourceID { + return fmt.Errorf("target and source must be different entities") + } + + if err := store.Absorb(targetID, sourceID); err != nil { + if err == db.ErrTargetCrystallized { + return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first", + display.FormatID(targetID)) + } + return err + } + + fmt.Printf("absorbed %s → %s\n", display.FormatID(sourceID), display.FormatID(targetID)) + return nil +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index bcb9b8e..303e508 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -421,6 +421,92 @@ func TestCORS_ProdMode(t *testing.T) { } } +func TestAbsorbEntity_Success(t *testing.T) { + srv, _ := testServer(t) + target := createTestEntity(t, srv, "target body", []string{"ops"}) + source := createTestEntity(t, srv, "source body", []string{"ops", "infra"}) + + resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ + "source_id": source.ID, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Body != "target body\nsource body" { + t.Errorf("merged body: %q", e.Body) + } + if len(e.Tags) != 2 { + t.Errorf("expected 2 tags, got %v", e.Tags) + } + + // Source should be soft-deleted (not in default list) + listResp, _ := http.Get(srv.URL + "/api/entities") + var entities []EntityResponse + json.NewDecoder(listResp.Body).Decode(&entities) + listResp.Body.Close() + for _, ent := range entities { + if ent.ID == source.ID { + t.Error("source should be soft-deleted and hidden from default list") + } + } +} + +func TestAbsorbEntity_TargetCrystallized(t *testing.T) { + srv, _ := testServer(t) + target := createTestEntity(t, srv, "target", nil) + source := createTestEntity(t, srv, "source", nil) + + postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{ + "card_type": "snippet", + }).Body.Close() + + resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ + "source_id": source.ID, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + var errResp ErrorResponse + json.NewDecoder(resp.Body).Decode(&errResp) + if errResp.Error != "invalid_absorb" { + t.Errorf("error: %q", errResp.Error) + } +} + +func TestAbsorbEntity_SameEntity(t *testing.T) { + srv, _ := testServer(t) + e := createTestEntity(t, srv, "self", nil) + + resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{ + "source_id": e.ID, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestAbsorbEntity_MissingSourceID(t *testing.T) { + srv, _ := testServer(t) + e := createTestEntity(t, srv, "target", nil) + + resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{}) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + func mustJSON(v any) []byte { b, _ := json.Marshal(v) return b diff --git a/internal/api/entities.go b/internal/api/entities.go index 0a93c62..4f1a6b8 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -304,6 +304,50 @@ func demoteEntity(store *db.Store) http.HandlerFunc { } } +type AbsorbRequest struct { + SourceID string `json:"source_id"` +} + +func absorbEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req AbsorbRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.SourceID == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "source_id is required") + return + } + if req.SourceID == id { + writeError(w, http.StatusBadRequest, "invalid_input", "target and source must be different entities") + return + } + + if err := store.Absorb(id, req.SourceID); err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "target or source entity not found") + return + } + if err == db.ErrTargetCrystallized { + writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + e, err := store.Get(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, entityToResponse(e)) + } +} + func useEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") diff --git a/internal/api/router.go b/internal/api/router.go index 035d861..fe6ff94 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -31,6 +31,7 @@ func NewRouter(store *db.Store, devMode bool, webFS ...fs.FS) chi.Router { r.Post("/entities/{id}/promote", promoteEntity(store)) r.Post("/entities/{id}/demote", demoteEntity(store)) r.Post("/entities/{id}/use", useEntity(store)) + r.Post("/entities/{id}/absorb", absorbEntity(store)) r.Get("/tags", listTags(store)) }) diff --git a/internal/db/db.go b/internal/db/db.go index e96509f..46795af 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -10,9 +10,10 @@ import ( ) var ( - ErrNotFound = errors.New("not_found") - ErrAlreadyPromoted = errors.New("invalid_promote") - ErrAlreadyFluid = errors.New("invalid_demote") + ErrNotFound = errors.New("not_found") + ErrAlreadyPromoted = errors.New("invalid_promote") + ErrAlreadyFluid = errors.New("invalid_demote") + ErrTargetCrystallized = errors.New("invalid_absorb") ) type Store struct { diff --git a/internal/db/entities.go b/internal/db/entities.go index 34e453f..e802a68 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -405,6 +405,63 @@ func (s *Store) SoftDelete(id string) (DeleteResult, error) { return DeletedSoft, err } +func (s *Store) Absorb(targetID, sourceID string) error { + target, err := s.Get(targetID) + if err != nil { + return err + } + source, err := s.Get(sourceID) + if err != nil { + return err + } + + if target.CardType != nil { + return ErrTargetCrystallized + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now().UTC().Format(time.RFC3339) + merged := target.Body + "\n" + source.Body + + if _, err := tx.Exec("UPDATE entities SET body = ?, modified_at = ? WHERE id = ?", + merged, now, targetID); err != nil { + return err + } + + seen := map[string]bool{} + for _, t := range target.Tags { + seen[t] = true + } + for _, t := range source.Tags { + if !seen[t] { + if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", + targetID, t); err != nil { + return err + } + } + } + + if source.CardType != nil { + if _, err := tx.Exec(`UPDATE entities SET card_type = NULL, card_data = NULL, + use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`, + now, sourceID); err != nil { + return err + } + } + + if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?", + now, sourceID); err != nil { + return err + } + + return tx.Commit() +} + func (s *Store) IncrementUse(id string) error { res, err := s.db.Exec(` UPDATE entities SET use_count = use_count + 1, last_used_at = ? diff --git a/web/app.js b/web/app.js index cc08fbb..a0bb833 100644 --- a/web/app.js +++ b/web/app.js @@ -85,6 +85,14 @@ }); return resp.json(); }, + async absorbEntity(targetId, sourceId) { + const resp = await fetch('/api/entities/' + targetId + '/absorb', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_id: sourceId }), + }); + return resp.json(); + }, async listTags() { const resp = await fetch('/api/tags'); return resp.json(); @@ -269,6 +277,7 @@ actions += ``; } else { actions += ``; + actions += ``; } actions += ``; @@ -469,6 +478,44 @@ } }, + showAbsorb(targetId) { + const target = state.entities.find(x => x.id === targetId); + if (!target) return; + if (target.card_type) return; + + const modal = $('#absorb-modal'); + modal.dataset.targetId = targetId; + const list = $('#absorb-source-list'); + + const sources = state.entities.filter(x => x.id !== targetId); + if (!sources.length) { list.innerHTML = '
no other entities
'; } + else { + list.innerHTML = sources.map(e => { + const g = displayGlyph(e); + const gc = glyphClass(e); + return `
+ ${g} + ${escHtml(e.body)} +
`; + }).join(''); + } + + list.querySelectorAll('.absorb-source-item').forEach(el => { + el.addEventListener('click', async () => { + modal.classList.add('hidden'); + modal.classList.remove('visible'); + await api.absorbEntity(targetId, el.dataset.id); + await loadEntities(); + await loadTags(); + const idx = state.entities.findIndex(x => x.id === targetId); + if (idx >= 0) selectEntity(idx); + }); + }); + + modal.classList.remove('hidden'); + modal.classList.add('visible'); + }, + async toggleStep(id, stepIdx) { const e = state.entities.find(x => x.id === id); if (!e || !e.card_data) return; @@ -520,13 +567,14 @@ }); }); - $('.modal-backdrop').addEventListener('click', closeModal); - $('.modal-close').addEventListener('click', closeModal); + $$('.modal-backdrop').forEach(el => el.addEventListener('click', closeModal)); + $$('.modal-close').forEach(el => el.addEventListener('click', closeModal)); function closeModal() { - const modal = $('#promote-modal'); - modal.classList.add('hidden'); - modal.classList.remove('visible'); + $$('.modal.visible').forEach(m => { + m.classList.add('hidden'); + m.classList.remove('visible'); + }); } // ========== Keyboard shortcuts ========== @@ -541,7 +589,8 @@ return; } - if ($('#promote-modal').classList.contains('visible')) { + if ($('#promote-modal').classList.contains('visible') || + $('#absorb-modal').classList.contains('visible')) { if (ev.key === 'Escape') closeModal(); return; } @@ -586,6 +635,11 @@ startEditBody(); break; } + case 'a': { + const e = state.entities[state.selectedIndex]; + if (e && !e.card_type) nibApp.showAbsorb(e.id); + break; + } case '1': switchView('stream'); break; case '2': switchView('cards'); break; } diff --git a/web/index.html b/web/index.html index e92bcc2..5f04747 100644 --- a/web/index.html +++ b/web/index.html @@ -59,6 +59,15 @@ + + diff --git a/web/style.css b/web/style.css index 5223578..3d9e5cb 100644 --- a/web/style.css +++ b/web/style.css @@ -483,6 +483,33 @@ main { font-family: var(--font-mono); } +/* Absorb modal */ +.absorb-list { + max-height: 300px; + overflow-y: auto; +} + +.absorb-source-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + cursor: pointer; + border-radius: var(--radius); + transition: background 0.1s; +} + +.absorb-source-item:hover { + background: var(--bg-hover); +} + +.absorb-source-item .entity-body { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Responsive */ @media (max-width: 900px) { main { grid-template-columns: 1fr; }