feat: add absorb command — merge source entity into target

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 <target> <source> with prefix ID resolution

Web: absorb button on fluid entities, 'a' keyboard shortcut,
source picker modal
This commit is contained in:
2026-05-14 13:47:08 -04:00
parent 702caae1af
commit 7711240d68
9 changed files with 341 additions and 9 deletions
+4 -3
View File
@@ -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 {
+57
View File
@@ -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 = ?