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
6 changes: 5 additions & 1 deletion 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.

v1 roadmap includes search, lazy loading, stable identifiers, and atomic shelf-collection operations.
The roadmap includes search, lazy loading, stable identifiers, and atomic shelf-collection operations.

## Quick Demo

Expand DownExpand Up@@ -95,10 +95,13 @@ $XDG_CONFIG_HOME/book/
### TOML Shelf File Format

```toml
schema_version = 2
shelf_id = "a0d6e1c2"
shelf_name = "dev"
shelf_desc = "software development bookmarks"

[Collections.docs]
collection_id = "6d264600"
collection_name = "docs"
collection_desc = "language and framework docs"

Expand All@@ -112,6 +115,7 @@ 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.
- `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 Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v1.0.0
v1.1.0
19 changes: 9 additions & 10 deletions cmd/book/collection.go
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
package cmd

import (
"fmt"

tea "charm.land/bubbletea/v2"
"github.com/polymorcodeus/book/internal/book"
"github.com/polymorcodeus/book/internal/model"
)

func collections(bs *book.BookShelves, shelfName string, format string, config *book.Config) error {
var err error

// Non-interactive path: all required flags provided
if shelfName != "" && !config.Interactive {
shelf := bs.Shelf(shelfName)
if shelf == nil || book.StructIsEmpty(shelf) {
return fmt.Errorf("shelf %q not found", shelfName)
idx, err := syncIndex(config)
if err != nil {
return err
}
names, err := idx.CollectionNames(shelfName)
if err != nil {
return err
}
return book.PrintCatalog(shelf.CollectionsNames(), format)
} else {
_, err = tea.NewProgram(collectionRootScreen(bs, "list", config)).Run()
return book.PrintCatalog(names, format)
}

_, err := tea.NewProgram(collectionRootScreen(bs, "list", config)).Run()
return err
}

Expand Down
58 changes: 58 additions & 0 deletions cmd/book/index.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
package cmd

import (
"fmt"

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

// index holds the lazily-opened SQLite index shared by the read paths within a
// single command invocation.
var index *catalog.Index

// syncIndex lazily opens the derived index and reconciles it with the shelf
// files on disk, returning the ready-to-query index.
func syncIndex(config *book.Config) (*catalog.Index, error) {
if index == nil {
idx, err := catalog.OpenIndex(config)
if err != nil {
return nil, err
}
index = idx
}
if _, err := index.Sync(config); err != nil {
return nil, err
}
return index, nil
}

func runIndexRebuild(config *book.Config) error {
idx, err := catalog.OpenIndex(config)
if err != nil {
return err
}
defer func() { _ = idx.Close() }()

report, err := idx.Rebuild(config)
if err != nil {
return err
}
fmt.Printf("indexed %d shelf file(s)\n", report.Indexed)
return nil
}

func runIndexSync(config *book.Config) error {
idx, err := catalog.OpenIndex(config)
if err != nil {
return err
}
defer func() { _ = idx.Close() }()

report, err := idx.Sync(config)
if err != nil {
return err
}
fmt.Printf("reindexed %d, removed %d, unchanged %d\n", report.Reindexed, report.Removed, report.Unchanged)
return nil
}
112 changes: 95 additions & 17 deletions cmd/book/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,12 @@ func buildVersion() string {

// Main builds and runs the book CLI application.
func Main() {
defer func() {
if index != nil {
_ = index.Close()
}
}()

var confirm bool
var interactive bool
var format string
Expand All@@ -64,6 +70,7 @@ func Main() {
var markURL string
var markTags string
var markTitle string
var searchTags string

cmd := &cli.Command{
Name: "book",
Expand DownExpand Up@@ -170,11 +177,13 @@ func Main() {
}

// Load Book Shelves only if <command> <subcommand> is passed.
// Additionally, this is skipped for `catalog` as those are admin tools.
// This is intentional: 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.
if cmd.Args().Len() > 1 {
// Additionally, this is skipped for `mark search` (which reads the
// SQLite index) and the catalog admin tools. This is intentional:
// 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.
isSearch := cmd.Args().First() == "mark" && cmd.Args().Get(1) == "search"
if cmd.Args().Len() > 1 && !isSearch {
if err := catalog.LoadCatalog(&bookShelves, config, config.Interactive); err != nil {
return ctx, cli.Exit(config.StyledError(err), 1)
}
Expand DownExpand Up@@ -290,23 +299,21 @@ func Main() {
{
Name: "mark",
Usage: "options for bookmarks",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "shelf",
Usage: "shelf selection for mark",
Destination: &shelf,
},
&cli.StringFlag{
Name: "collection",
Usage: "collection selection for mark",
Destination: &collection,
},
},
Commands: []*cli.Command{
{
Name: "add",
Usage: "add a new bookmark",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "shelf",
Usage: "shelf selection for mark",
Destination: &shelf,
},
&cli.StringFlag{
Name: "collection",
Usage: "collection selection for mark",
Destination: &collection,
},
&cli.StringFlag{
Name: "tags",
Usage: "comma-separated list of tags to add to mark",
Expand DownExpand Up@@ -370,6 +377,18 @@ func Main() {
Name: "list",
Usage: "list marks in a collection",
Aliases: []string{"ls"},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "shelf",
Usage: "shelf selection for mark",
Destination: &shelf,
},
&cli.StringFlag{
Name: "collection",
Usage: "collection selection for mark",
Destination: &collection,
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if !config.Interactive && format == "" {
return ctx, cli.Exit(config.StyledError(fmt.Errorf("set --format=[json|toml] to output collections non-interactively")), 1)
Expand All@@ -383,6 +402,39 @@ func Main() {
return nil
},
},
{
Name: "search",
Usage: "search bookmarks by title, URL, or tags",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "shelf",
Usage: "filter results by shelf",
Destination: &shelf,
},
&cli.StringFlag{
Name: "collection",
Usage: "filter results by collection",
Destination: &collection,
},
&cli.StringFlag{
Name: "tags",
Usage: "filter results by tags; comma=OR, plus=AND (e.g. a,b+c)",
Destination: &searchTags,
},
},
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
if c.Args().First() == "" && searchTags == "" && shelf == "" && collection == "" {
return ctx, cli.Exit(config.StyledError(fmt.Errorf("must pass a search query or a filter (--tags, --shelf, --collection)")), 1)
}
return ctx, nil
},
Action: func(ctx context.Context, c *cli.Command) error {
if err := searchMarks(c.Args().First(), searchTags, shelf, collection, format, config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
},
},
{
Name: "remove",
Usage: "remove an existing bookmark",
Expand All@@ -406,6 +458,32 @@ func Main() {
return nil
},
},
{
Name: "index",
Usage: "manage the derived SQLite search index",
Commands: []*cli.Command{
{
Name: "rebuild",
Usage: "wipe and rebuild the index from shelf TOML files",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := runIndexRebuild(config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
},
},
{
Name: "sync",
Usage: "reconcile the index with changes to shelf TOML files",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := runIndexSync(config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
},
},
},
},
{
Name: "catalog",
Usage: "options for catalog - e.g. admin + customization",
Expand Down
46 changes: 39 additions & 7 deletions cmd/book/mark.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,15 +21,14 @@ func mark(bs *book.BookShelves, config *book.Config) error {
func marks(bs *book.BookShelves, shelfName string, collectionName string, format string, config *book.Config) error {
// Non-interactive path: all required flags provided
if shelfName != "" && collectionName != "" && !config.Interactive {
shelf := bs.Shelf(shelfName)
if shelf == nil || book.StructIsEmpty(shelf) {
return fmt.Errorf("shelf %q not found", shelfName)
idx, err := syncIndex(config)
if err != nil {
return err
}
collection:= shelf.Collection(collectionName)
if collection == nil || book.StructIsEmpty(collection) {
return fmt.Errorf("collection %q not found in shelf %q", collectionName, shelfName)
collection, err := idx.Collection(shelfName, collectionName)
if err != nil {
return err
}

return book.PrintCatalog(collection, format)
}
_, err := tea.NewProgram(markRootScreen(bs, &book.Mark{}, "list", config)).Run()
Expand All@@ -41,6 +40,39 @@ func editMark(bs *book.BookShelves, config *book.Config) error {
return err
}

func searchMarks(query string, tags string, shelfName string, collectionName string, format string, config *book.Config) error {
clauses, err := book.ParseTagFilter(tags)
if err != nil {
return err
}

idx, err := syncIndex(config)
if err != nil {
return err
}

results, err := idx.Search(query, shelfName, collectionName, clauses)
if err != nil {
return err
}

switch format {
case "json":
return book.PrintCatalog(results, format)
case "toml":
// TOML requires a top-level map or struct, so wrap the slice.
wrapped := struct {
Marks []catalog.SearchResult `toml:"marks"`
}{results}
return book.PrintCatalog(wrapped, format)
default:
for _, r := range results {
fmt.Printf("%s %s\n", r.Title, r.URL)
}
return nil
}
}

func addMark(bs *book.BookShelves, URL string, tags string, shelfName string, collectionName string, title string, config *book.Config) error {
if _, err := url.ParseRequestURI(URL); err != nil {
return err
Expand Down
15 changes: 10 additions & 5 deletions cmd/book/shelf.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,14 +7,19 @@ import (
)

func shelves(bs *book.BookShelves, format string, config *book.Config) error {
var err error

if !config.Interactive {
return book.PrintCatalog(bs.ShelfNames(), format)
} else {
_, err = tea.NewProgram(shelfRootScreen(bs, "list", config)).Run()
idx, err := syncIndex(config)
if err != nil {
return err
}
names, err := idx.ShelfNames()
if err != nil {
return err
}
return book.PrintCatalog(names, format)
}

_, err := tea.NewProgram(shelfRootScreen(bs, "list", config)).Run()
return err
}

Expand Down
Loading
Loading