Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,7 @@

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.

The roadmap includes search, lazy loading, stable identifiers, and atomic shelf-collection operations.
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`).

## Quick Demo

Expand DownExpand Up@@ -115,6 +115,8 @@ collection_desc = "language and framework docs"
- `catalog_id` is a stable URL hash — duplicates are rejected across the entire catalog.
- Collections are keyed by name inside the `[Collections]` table.
- Marks are inline arrays-of-tables per collection.
- Optional RFC3339 timestamps (`created_at`, `updated_at`, `deleted_at`) track each entity's lifecycle; `deleted_at` marks a soft-deleted mark.
- `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.
- `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.

## Commands
Expand All@@ -130,8 +132,15 @@ collection_desc = "language and framework docs"
| `mark add <url>` | Add a bookmark (optionally non-interactive) |
| `mark edit` | Edit an existing bookmark (TUI) |
| `mark get` | Browse bookmarks and open one (TUI) |
| `mark list` | List bookmarks in a collection |
| `mark remove` | Remove a bookmark (TUI) |
| `mark list` | List bookmarks in a collection (`--trash` lists soft-deleted) |
| `mark search <query>` | Full-text search by title, URL, or tags |
| `mark remove` | Soft-delete a bookmark (TUI) |
| `mark restore` | Restore a soft-deleted bookmark (`--id`, or `--shelf`/`--collection`/`--url`) |
| `migrate` | Upgrade v1 shelf files to the v2 schema |
| `gc` | Purge soft-deleted marks past the retention window |
| `index rebuild` | Rebuild the derived search index |
| `index sync` | Reconcile the index with shelf changes |
| `doctor` | Detect post-merge duplicates and conflicts (`--fix` auto-merges; alias `sync`) |
| `catalog theme` | Generate `theme.json` with default TUI theme |
| `catalog template` | Generate `template.json` with default TUI templates |
| `catalog config` | Create the config file if missing |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v1.1.1
v1.2.0
168 changes: 168 additions & 0 deletions cmd/book/doctor.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
package cmd

import (
"fmt"

"github.com/polymorcodeus/book/internal/book"
"github.com/polymorcodeus/book/internal/catalog"
)

// doctor inspects the catalog for post-merge problems: duplicate marks, schema
// drift, index staleness, and stray debris. With fix it auto-resolves merge
// duplicates and rewrites the affected shelf files.
func doctor(config *book.Config, fix bool) error {
var shelves book.BookShelves
if err := catalog.LoadShelves(&shelves, config); err != nil {
return err
}

var duplicates, conflicts []book.MarkConflict
for _, c := range shelves.DetectDuplicates() {
if c.TrueConflict {
conflicts = append(conflicts, c)
} else {
duplicates = append(duplicates, c)
}
}

v1Files, err := catalog.V1ShelfFiles(config.ShelfRoot, config.CatalogFormat)
if err != nil {
return err
}
debris, err := catalog.StrayDebris(config.ShelfRoot, config.CatalogFormat)
if err != nil {
return err
}
stale, err := indexStaleFiles(config)
if err != nil {
return err
}

report := doctorReport{
Duplicates: duplicates,
Conflicts: conflicts,
V1Files: v1Files,
Debris: debris,
Stale: stale,
}

if fix {
if !config.Autoconfirm {
return fmt.Errorf("set --confirm to fix merge duplicates")
}
removed, changed := shelves.ResolveDuplicates()
report.Fixed = removed
for _, s := range changed {
if err := catalog.UpdateShelfFile(s); err != nil {
return err
}
report.FixedFiles = append(report.FixedFiles, s.FilePath)
}
// Only reconcile the index when no duplicate IDs remain: the index's
// primary key on catalog_id cannot represent unresolved conflicts.
if len(changed) > 0 && len(shelves.DetectDuplicates()) == 0 {
if _, err := syncIndex(config); err != nil {
return err
}
}
}

printDoctorReport(report)
return nil
}

// indexStaleFiles returns shelf paths whose index entries are out of date, or
// nil when the index has not been built yet.
func indexStaleFiles(config *book.Config) ([]string, error) {
exists, err := catalog.VerifyExists(catalog.IndexPath(config))
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}

idx, err := catalog.OpenIndex(config)
if err != nil {
return nil, err
}
defer func() { _ = idx.Close() }()
return idx.StaleFiles(config)
}

type doctorReport struct {
Duplicates []book.MarkConflict
Conflicts []book.MarkConflict
V1Files []string
Debris []string
Stale []string
Fixed int
FixedFiles []string
}

func printDoctorReport(r doctorReport) {
clean := len(r.Duplicates) == 0 && len(r.Conflicts) == 0 &&
len(r.V1Files) == 0 && len(r.Debris) == 0 && len(r.Stale) == 0 && r.Fixed == 0

fmt.Println("book doctor")
fmt.Println()

if clean {
fmt.Println("catalog is clean")
return
}

if len(r.Duplicates) > 0 {
fmt.Printf("duplicate marks (%d)\n", len(r.Duplicates))
for _, d := range r.Duplicates {
fmt.Printf(" %s %s (%d copies)\n", d.ID, d.URL, len(d.Marks))
for _, m := range d.Marks {
fmt.Printf(" - %s / %s\n", m.Shelf.Name, m.Collection.Name)
}
}
if r.Fixed == 0 {
fmt.Println(" run `book doctor --fix` to auto-merge these")
}
fmt.Println()
}

if len(r.Conflicts) > 0 {
fmt.Printf("conflicting marks (%d, manual resolution needed)\n", len(r.Conflicts))
for _, c := range r.Conflicts {
fmt.Printf(" %s %s\n", c.ID, c.URL)
for _, m := range c.Marks {
fmt.Printf(" - %s / %s title=%q tags=%v deleted=%t\n",
m.Shelf.Name, m.Collection.Name, m.Name, m.Tags, m.IsDeleted())
}
}
fmt.Println()
}

if len(r.V1Files) > 0 {
fmt.Println("v1 schema files (run `book migrate`)")
for _, f := range r.V1Files {
fmt.Printf(" %s\n", f)
}
fmt.Println()
}

if len(r.Stale) > 0 {
fmt.Println("stale index entries (run `book index sync`)")
for _, f := range r.Stale {
fmt.Printf(" %s\n", f)
}
fmt.Println()
}

if len(r.Debris) > 0 {
fmt.Println("stray debris")
for _, f := range r.Debris {
fmt.Printf(" %s\n", f)
}
fmt.Println()
}

if r.Fixed > 0 {
fmt.Printf("fixed: removed %d duplicate mark(s) from %d shelf file(s)\n", r.Fixed, len(r.FixedFiles))
}
}
51 changes: 51 additions & 0 deletions cmd/book/gc.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
package cmd

import (
"fmt"
"time"

"github.com/polymorcodeus/book/internal/book"
"github.com/polymorcodeus/book/internal/catalog"
)

// gc purges soft-deleted marks older than retentionDays from the shelf TOML
// files and reconciles the derived index.
func gc(config *book.Config, retentionDays int) error {
if !config.Autoconfirm {
return fmt.Errorf("set --confirm to run gc")
}
if retentionDays < 0 {
return fmt.Errorf("--retention-days must be non-negative")
}

var shelves book.BookShelves
if err := catalog.LoadShelves(&shelves, config); err != nil {
return err
}

cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)

var purged, changed int
for i := range shelves {
shelf := &shelves[i]
n := shelf.PurgeDeletedMarks(cutoff)
if n == 0 {
continue
}
if err := catalog.UpdateShelfFile(shelf); err != nil {
return err
}
purged += n
changed++
}

if changed > 0 {
// Reconcile the derived index so purged marks disappear from search.
if _, err := syncIndex(config); err != nil {
return err
}
}

fmt.Printf("purged %d mark(s) from %d shelf(s)\n", purged, changed)
return nil
}
94 changes: 92 additions & 2 deletions cmd/book/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,6 +73,11 @@ func Main() {
var markTags string
var markTitle string
var searchTags string
var restoreURL string
var restoreID string
var trash bool
var retentionDays int
var fix bool

cmd := &cli.Command{
Name: "book",
Expand DownExpand Up@@ -184,8 +189,20 @@ func Main() {
// when only a subcommand is given (e.g. "book shelf"), urfave/cli
// will auto-render the help text. We skip catalog loading so help
// renders quickly without reading the filesystem.
// Load Book Shelves only for the data commands (shelf, collection,
// mark) and only when a subcommand is given. This skips `mark
// search` (which reads the SQLite index) and the catalog admin
// tools (migrate, gc, doctor, index, catalog), which load their
// own data. When only a subcommand is given (e.g. "book shelf"),
// urfave/cli auto-renders help, so we skip loading to keep help
// fast. Note the command name must be checked explicitly: a
// top-level tool's own flags (e.g. "doctor --fix") would otherwise
// leak into Args() and trigger an unwanted load.
isSearch := cmd.Args().First() == "mark" && cmd.Args().Get(1) == "search"
if cmd.Args().Len() > 1 && !isSearch {
needsCatalog := cmd.Args().First() == "shelf" ||
cmd.Args().First() == "collection" ||
cmd.Args().First() == "mark"
if cmd.Args().Len() > 1 && needsCatalog && !isSearch {
if err := catalog.LoadCatalog(&bookShelves, config, config.Interactive); err != nil {
return ctx, cli.Exit(config.StyledError(err), 1)
}
Expand DownExpand Up@@ -390,6 +407,11 @@ func Main() {
Usage: "collection selection for mark",
Destination: &collection,
},
&cli.BoolFlag{
Name: "trash",
Usage: "list soft-deleted marks instead of active ones",
Destination: &trash,
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if !config.Interactive && format == "" {
Expand All@@ -398,7 +420,7 @@ func Main() {
return ctx, nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := marks(&bookShelves, shelf, collection, format, config); err != nil {
if err := marks(&bookShelves, shelf, collection, format, trash, config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand DownExpand Up@@ -448,6 +470,38 @@ func Main() {
return nil
},
},
{
Name: "restore",
Usage: "restore a soft-deleted bookmark",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Usage: "catalog_id of the trashed mark to restore (preferred)",
Destination: &restoreID,
},
&cli.StringFlag{
Name: "shelf",
Usage: "shelf containing the trashed mark",
Destination: &shelf,
},
&cli.StringFlag{
Name: "collection",
Usage: "collection containing the trashed mark",
Destination: &collection,
},
&cli.StringFlag{
Name: "url",
Usage: "url of the trashed mark to restore",
Destination: &restoreURL,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := restoreMark(&bookShelves, restoreID, shelf, collection, restoreURL); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
},
},
},
},
{
Expand All@@ -460,6 +514,42 @@ func Main() {
return nil
},
},
{
Name: "gc",
Usage: "purge soft-deleted marks older than the retention window",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "retention-days",
Value: 30,
Usage: "purge marks soft-deleted more than this many days ago",
Destination: &retentionDays,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := gc(config, retentionDays); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
},
},
{
Name: "doctor",
Aliases: []string{"sync"},
Usage: "detect and fix post-merge catalog problems",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "fix",
Usage: "auto-merge duplicate marks (requires --confirm)",
Destination: &fix,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := doctor(config, fix); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
},
},
{
Name: "index",
Usage: "manage the derived SQLite search index",
Expand Down
Loading
Loading