Skip to content

Commit 84c24a4

Browse files
Add soft delete, gc, and doctor for bookmark catalogs
* feat: add gc/restore to deleted book marks. * feat: add doctor for mult-machine mook mark reconcilliation * chore: version bump * docs: updated README
1 parent cfaa7ac commit 84c24a4

15 files changed

Lines changed: 1355 additions & 56 deletions

File tree

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
Terminal bookmark manager with hierarchical organization (Shelf → Collection → Mark). Bookmarks are persisted as plain TOML, enabling version control, clean diffs, and [dotfile manager](https://github.com/polymorcodeus/lnk) integration. Supports both interactive TUI and non-interactive CLI modes for scripting.
1515

16-
The roadmap includes search, lazy loading, stable identifiers, and atomic shelf-collection operations.
16+
Bookmarks ship with stable identifiers (`catalog_id`), full-text search (`book mark search`), atomic writes, schema migration (`book migrate`), and soft-delete recovery (`book mark restore`, `book gc`).
1717

1818
## Quick Demo
1919

@@ -115,6 +115,8 @@ collection_desc = "language and framework docs"
115115
- `catalog_id` is a stable URL hash — duplicates are rejected across the entire catalog.
116116
- Collections are keyed by name inside the `[Collections]` table.
117117
- Marks are inline arrays-of-tables per collection.
118+
- Optional RFC3339 timestamps (`created_at`, `updated_at`, `deleted_at`) track each entity's lifecycle; `deleted_at` marks a soft-deleted mark.
119+
- `mark remove` soft-deletes by setting `deleted_at`; the mark is hidden from `list`/`get`/`search` until `book gc` purges it or `mark restore` brings it back.
118120
- `schema_version` is the on-disk data format version (`2`), independent of the tool's release version (v1.x). `book migrate` upgrades older v1 files in place.
119121

120122
## Commands
@@ -130,8 +132,15 @@ collection_desc = "language and framework docs"
130132
| `mark add <url>` | Add a bookmark (optionally non-interactive) |
131133
| `mark edit` | Edit an existing bookmark (TUI) |
132134
| `mark get` | Browse bookmarks and open one (TUI) |
133-
| `mark list` | List bookmarks in a collection |
134-
| `mark remove` | Remove a bookmark (TUI) |
135+
| `mark list` | List bookmarks in a collection (`--trash` lists soft-deleted) |
136+
| `mark search <query>` | Full-text search by title, URL, or tags |
137+
| `mark remove` | Soft-delete a bookmark (TUI) |
138+
| `mark restore` | Restore a soft-deleted bookmark (`--id`, or `--shelf`/`--collection`/`--url`) |
139+
| `migrate` | Upgrade v1 shelf files to the v2 schema |
140+
| `gc` | Purge soft-deleted marks past the retention window |
141+
| `index rebuild` | Rebuild the derived search index |
142+
| `index sync` | Reconcile the index with shelf changes |
143+
| `doctor` | Detect post-merge duplicates and conflicts (`--fix` auto-merges; alias `sync`) |
135144
| `catalog theme` | Generate `theme.json` with default TUI theme |
136145
| `catalog template` | Generate `template.json` with default TUI templates |
137146
| `catalog config` | Create the config file if missing |

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v1.1.1
1+
v1.2.0

cmd/book/doctor.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/polymorcodeus/book/internal/book"
7+
"github.com/polymorcodeus/book/internal/catalog"
8+
)
9+
10+
// doctor inspects the catalog for post-merge problems: duplicate marks, schema
11+
// drift, index staleness, and stray debris. With fix it auto-resolves merge
12+
// duplicates and rewrites the affected shelf files.
13+
func doctor(config *book.Config, fix bool) error {
14+
var shelves book.BookShelves
15+
if err := catalog.LoadShelves(&shelves, config); err != nil {
16+
return err
17+
}
18+
19+
var duplicates, conflicts []book.MarkConflict
20+
for _, c := range shelves.DetectDuplicates() {
21+
if c.TrueConflict {
22+
conflicts = append(conflicts, c)
23+
} else {
24+
duplicates = append(duplicates, c)
25+
}
26+
}
27+
28+
v1Files, err := catalog.V1ShelfFiles(config.ShelfRoot, config.CatalogFormat)
29+
if err != nil {
30+
return err
31+
}
32+
debris, err := catalog.StrayDebris(config.ShelfRoot, config.CatalogFormat)
33+
if err != nil {
34+
return err
35+
}
36+
stale, err := indexStaleFiles(config)
37+
if err != nil {
38+
return err
39+
}
40+
41+
report := doctorReport{
42+
Duplicates: duplicates,
43+
Conflicts: conflicts,
44+
V1Files: v1Files,
45+
Debris: debris,
46+
Stale: stale,
47+
}
48+
49+
if fix {
50+
if !config.Autoconfirm {
51+
return fmt.Errorf("set --confirm to fix merge duplicates")
52+
}
53+
removed, changed := shelves.ResolveDuplicates()
54+
report.Fixed = removed
55+
for _, s := range changed {
56+
if err := catalog.UpdateShelfFile(s); err != nil {
57+
return err
58+
}
59+
report.FixedFiles = append(report.FixedFiles, s.FilePath)
60+
}
61+
// Only reconcile the index when no duplicate IDs remain: the index's
62+
// primary key on catalog_id cannot represent unresolved conflicts.
63+
if len(changed) > 0 && len(shelves.DetectDuplicates()) == 0 {
64+
if _, err := syncIndex(config); err != nil {
65+
return err
66+
}
67+
}
68+
}
69+
70+
printDoctorReport(report)
71+
return nil
72+
}
73+
74+
// indexStaleFiles returns shelf paths whose index entries are out of date, or
75+
// nil when the index has not been built yet.
76+
func indexStaleFiles(config *book.Config) ([]string, error) {
77+
exists, err := catalog.VerifyExists(catalog.IndexPath(config))
78+
if err != nil {
79+
return nil, err
80+
}
81+
if !exists {
82+
return nil, nil
83+
}
84+
85+
idx, err := catalog.OpenIndex(config)
86+
if err != nil {
87+
return nil, err
88+
}
89+
defer func() { _ = idx.Close() }()
90+
return idx.StaleFiles(config)
91+
}
92+
93+
type doctorReport struct {
94+
Duplicates []book.MarkConflict
95+
Conflicts []book.MarkConflict
96+
V1Files []string
97+
Debris []string
98+
Stale []string
99+
Fixed int
100+
FixedFiles []string
101+
}
102+
103+
func printDoctorReport(r doctorReport) {
104+
clean := len(r.Duplicates) == 0 && len(r.Conflicts) == 0 &&
105+
len(r.V1Files) == 0 && len(r.Debris) == 0 && len(r.Stale) == 0 && r.Fixed == 0
106+
107+
fmt.Println("book doctor")
108+
fmt.Println()
109+
110+
if clean {
111+
fmt.Println("catalog is clean")
112+
return
113+
}
114+
115+
if len(r.Duplicates) > 0 {
116+
fmt.Printf("duplicate marks (%d)\n", len(r.Duplicates))
117+
for _, d := range r.Duplicates {
118+
fmt.Printf(" %s %s (%d copies)\n", d.ID, d.URL, len(d.Marks))
119+
for _, m := range d.Marks {
120+
fmt.Printf(" - %s / %s\n", m.Shelf.Name, m.Collection.Name)
121+
}
122+
}
123+
if r.Fixed == 0 {
124+
fmt.Println(" run `book doctor --fix` to auto-merge these")
125+
}
126+
fmt.Println()
127+
}
128+
129+
if len(r.Conflicts) > 0 {
130+
fmt.Printf("conflicting marks (%d, manual resolution needed)\n", len(r.Conflicts))
131+
for _, c := range r.Conflicts {
132+
fmt.Printf(" %s %s\n", c.ID, c.URL)
133+
for _, m := range c.Marks {
134+
fmt.Printf(" - %s / %s title=%q tags=%v deleted=%t\n",
135+
m.Shelf.Name, m.Collection.Name, m.Name, m.Tags, m.IsDeleted())
136+
}
137+
}
138+
fmt.Println()
139+
}
140+
141+
if len(r.V1Files) > 0 {
142+
fmt.Println("v1 schema files (run `book migrate`)")
143+
for _, f := range r.V1Files {
144+
fmt.Printf(" %s\n", f)
145+
}
146+
fmt.Println()
147+
}
148+
149+
if len(r.Stale) > 0 {
150+
fmt.Println("stale index entries (run `book index sync`)")
151+
for _, f := range r.Stale {
152+
fmt.Printf(" %s\n", f)
153+
}
154+
fmt.Println()
155+
}
156+
157+
if len(r.Debris) > 0 {
158+
fmt.Println("stray debris")
159+
for _, f := range r.Debris {
160+
fmt.Printf(" %s\n", f)
161+
}
162+
fmt.Println()
163+
}
164+
165+
if r.Fixed > 0 {
166+
fmt.Printf("fixed: removed %d duplicate mark(s) from %d shelf file(s)\n", r.Fixed, len(r.FixedFiles))
167+
}
168+
}

cmd/book/gc.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/polymorcodeus/book/internal/book"
8+
"github.com/polymorcodeus/book/internal/catalog"
9+
)
10+
11+
// gc purges soft-deleted marks older than retentionDays from the shelf TOML
12+
// files and reconciles the derived index.
13+
func gc(config *book.Config, retentionDays int) error {
14+
if !config.Autoconfirm {
15+
return fmt.Errorf("set --confirm to run gc")
16+
}
17+
if retentionDays < 0 {
18+
return fmt.Errorf("--retention-days must be non-negative")
19+
}
20+
21+
var shelves book.BookShelves
22+
if err := catalog.LoadShelves(&shelves, config); err != nil {
23+
return err
24+
}
25+
26+
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
27+
28+
var purged, changed int
29+
for i := range shelves {
30+
shelf := &shelves[i]
31+
n := shelf.PurgeDeletedMarks(cutoff)
32+
if n == 0 {
33+
continue
34+
}
35+
if err := catalog.UpdateShelfFile(shelf); err != nil {
36+
return err
37+
}
38+
purged += n
39+
changed++
40+
}
41+
42+
if changed > 0 {
43+
// Reconcile the derived index so purged marks disappear from search.
44+
if _, err := syncIndex(config); err != nil {
45+
return err
46+
}
47+
}
48+
49+
fmt.Printf("purged %d mark(s) from %d shelf(s)\n", purged, changed)
50+
return nil
51+
}

cmd/book/main.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ func Main() {
7373
var markTags string
7474
var markTitle string
7575
var searchTags string
76+
var restoreURL string
77+
var restoreID string
78+
var trash bool
79+
var retentionDays int
80+
var fix bool
7681

7782
cmd := &cli.Command{
7883
Name: "book",
@@ -184,8 +189,20 @@ func Main() {
184189
// when only a subcommand is given (e.g. "book shelf"), urfave/cli
185190
// will auto-render the help text. We skip catalog loading so help
186191
// renders quickly without reading the filesystem.
192+
// Load Book Shelves only for the data commands (shelf, collection,
193+
// mark) and only when a subcommand is given. This skips `mark
194+
// search` (which reads the SQLite index) and the catalog admin
195+
// tools (migrate, gc, doctor, index, catalog), which load their
196+
// own data. When only a subcommand is given (e.g. "book shelf"),
197+
// urfave/cli auto-renders help, so we skip loading to keep help
198+
// fast. Note the command name must be checked explicitly: a
199+
// top-level tool's own flags (e.g. "doctor --fix") would otherwise
200+
// leak into Args() and trigger an unwanted load.
187201
isSearch := cmd.Args().First() == "mark" && cmd.Args().Get(1) == "search"
188-
if cmd.Args().Len() > 1 && !isSearch {
202+
needsCatalog := cmd.Args().First() == "shelf" ||
203+
cmd.Args().First() == "collection" ||
204+
cmd.Args().First() == "mark"
205+
if cmd.Args().Len() > 1 && needsCatalog && !isSearch {
189206
if err := catalog.LoadCatalog(&bookShelves, config, config.Interactive); err != nil {
190207
return ctx, cli.Exit(config.StyledError(err), 1)
191208
}
@@ -390,6 +407,11 @@ func Main() {
390407
Usage: "collection selection for mark",
391408
Destination: &collection,
392409
},
410+
&cli.BoolFlag{
411+
Name: "trash",
412+
Usage: "list soft-deleted marks instead of active ones",
413+
Destination: &trash,
414+
},
393415
},
394416
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
395417
if !config.Interactive && format == "" {
@@ -398,7 +420,7 @@ func Main() {
398420
return ctx, nil
399421
},
400422
Action: func(ctx context.Context, cmd *cli.Command) error {
401-
if err := marks(&bookShelves, shelf, collection, format, config); err != nil {
423+
if err := marks(&bookShelves, shelf, collection, format, trash, config); err != nil {
402424
return cli.Exit(config.StyledError(err), 1)
403425
}
404426
return nil
@@ -448,6 +470,38 @@ func Main() {
448470
return nil
449471
},
450472
},
473+
{
474+
Name: "restore",
475+
Usage: "restore a soft-deleted bookmark",
476+
Flags: []cli.Flag{
477+
&cli.StringFlag{
478+
Name: "id",
479+
Usage: "catalog_id of the trashed mark to restore (preferred)",
480+
Destination: &restoreID,
481+
},
482+
&cli.StringFlag{
483+
Name: "shelf",
484+
Usage: "shelf containing the trashed mark",
485+
Destination: &shelf,
486+
},
487+
&cli.StringFlag{
488+
Name: "collection",
489+
Usage: "collection containing the trashed mark",
490+
Destination: &collection,
491+
},
492+
&cli.StringFlag{
493+
Name: "url",
494+
Usage: "url of the trashed mark to restore",
495+
Destination: &restoreURL,
496+
},
497+
},
498+
Action: func(ctx context.Context, cmd *cli.Command) error {
499+
if err := restoreMark(&bookShelves, restoreID, shelf, collection, restoreURL); err != nil {
500+
return cli.Exit(config.StyledError(err), 1)
501+
}
502+
return nil
503+
},
504+
},
451505
},
452506
},
453507
{
@@ -460,6 +514,42 @@ func Main() {
460514
return nil
461515
},
462516
},
517+
{
518+
Name: "gc",
519+
Usage: "purge soft-deleted marks older than the retention window",
520+
Flags: []cli.Flag{
521+
&cli.IntFlag{
522+
Name: "retention-days",
523+
Value: 30,
524+
Usage: "purge marks soft-deleted more than this many days ago",
525+
Destination: &retentionDays,
526+
},
527+
},
528+
Action: func(ctx context.Context, cmd *cli.Command) error {
529+
if err := gc(config, retentionDays); err != nil {
530+
return cli.Exit(config.StyledError(err), 1)
531+
}
532+
return nil
533+
},
534+
},
535+
{
536+
Name: "doctor",
537+
Aliases: []string{"sync"},
538+
Usage: "detect and fix post-merge catalog problems",
539+
Flags: []cli.Flag{
540+
&cli.BoolFlag{
541+
Name: "fix",
542+
Usage: "auto-merge duplicate marks (requires --confirm)",
543+
Destination: &fix,
544+
},
545+
},
546+
Action: func(ctx context.Context, cmd *cli.Command) error {
547+
if err := doctor(config, fix); err != nil {
548+
return cli.Exit(config.StyledError(err), 1)
549+
}
550+
return nil
551+
},
552+
},
463553
{
464554
Name: "index",
465555
Usage: "manage the derived SQLite search index",

0 commit comments

Comments
 (0)