diff --git a/README.md b/README.md index efd4db0..0f557da 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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" @@ -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 diff --git a/VERSION b/VERSION index 0ec25f7..795460f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0 +v1.1.0 diff --git a/cmd/book/collection.go b/cmd/book/collection.go index 53a0c35..b55fa6d 100644 --- a/cmd/book/collection.go +++ b/cmd/book/collection.go @@ -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 } diff --git a/cmd/book/index.go b/cmd/book/index.go new file mode 100644 index 0000000..fa4c34d --- /dev/null +++ b/cmd/book/index.go @@ -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 +} diff --git a/cmd/book/main.go b/cmd/book/main.go index 6c5458f..04db0e3 100644 --- a/cmd/book/main.go +++ b/cmd/book/main.go @@ -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 @@ -64,6 +70,7 @@ func Main() { var markURL string var markTags string var markTitle string + var searchTags string cmd := &cli.Command{ Name: "book", @@ -170,11 +177,13 @@ func Main() { } // Load Book Shelves only if 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) } @@ -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", @@ -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) @@ -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", @@ -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", diff --git a/cmd/book/mark.go b/cmd/book/mark.go index d82fe5b..b34d7e0 100644 --- a/cmd/book/mark.go +++ b/cmd/book/mark.go @@ -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() @@ -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 diff --git a/cmd/book/shelf.go b/cmd/book/shelf.go index 0b17e78..f483cf9 100644 --- a/cmd/book/shelf.go +++ b/cmd/book/shelf.go @@ -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 } diff --git a/go.mod b/go.mod index c4f6253..9661648 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/urfave/cli-altsrc/v3 v3.1.0 github.com/urfave/cli-validation v0.0.0-20230629031421-92802a7fd6e9 github.com/urfave/cli/v3 v3.10.1 + modernc.org/sqlite v1.57.0 ) require ( @@ -30,14 +31,21 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index cc475de..56a6068 100644 --- a/go.sum +++ b/go.sum @@ -55,18 +55,30 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polymorcodeus/gofiglet v0.2.1 h1:TN/LxxLpfoWDTpoq5/5QBUJV3kiNFg6kOF1Q8z4Skfs= github.com/polymorcodeus/gofiglet v0.2.1/go.mod h1:ji+eY1w+SFfh9InR9PncFryjdUOO9lkV6GstXB3riYE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -93,6 +105,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -150,6 +164,36 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/book/types.go b/internal/book/types.go index 3bfbbd4..5178e35 100644 --- a/internal/book/types.go +++ b/internal/book/types.go @@ -366,6 +366,32 @@ func MergeTags(sources ...[]string) []string { return slices.DeleteFunc(merged, func(e string) bool { return e == "" }) } +// ParseTagFilter parses the search --tags grammar into AND clauses of OR tags. +// A plus (+) separates AND clauses and a comma (,) separates OR alternatives +// within a clause, so "a,b+c" means (a OR b) AND c. Empty groups (for example +// "a,", ",a", "a+", or "+a") are rejected. A blank input yields nil. +func ParseTagFilter(input string) ([][]string, error) { + if strings.TrimSpace(input) == "" { + return nil, nil + } + + clauses := strings.Split(input, "+") + result := make([][]string, 0, len(clauses)) + for _, clause := range clauses { + rawTags := strings.Split(clause, ",") + tags := make([]string, 0, len(rawTags)) + for _, raw := range rawTags { + tag := strings.TrimSpace(raw) + if tag == "" { + return nil, fmt.Errorf("empty tag group in %q", input) + } + tags = append(tags, tag) + } + result = append(result, tags) + } + return result, nil +} + // PrintCatalog serializes an item as JSON or TOML to stdout. func PrintCatalog[T any](item T, format string) error { switch format { diff --git a/internal/book/types_test.go b/internal/book/types_test.go index 507a24f..d665482 100644 --- a/internal/book/types_test.go +++ b/internal/book/types_test.go @@ -1,6 +1,7 @@ package book import ( + "reflect" "slices" "strings" "testing" @@ -351,3 +352,43 @@ func TestMergeTags(t *testing.T) { }) } } + +func TestParseTagFilter(t *testing.T) { + tests := []struct { + name string + in string + want [][]string + wantErr bool + }{ + {name: "blank", in: "", want: nil}, + {name: "whitespace", in: " ", want: nil}, + {name: "single tag", in: "a", want: [][]string{{"a"}}}, + {name: "or", in: "a,b", want: [][]string{{"a", "b"}}}, + {name: "and", in: "a+b", want: [][]string{{"a"}, {"b"}}}, + {name: "and of ors", in: "a,b+c", want: [][]string{{"a", "b"}, {"c"}}}, + {name: "trims spaces", in: "a, b + c", want: [][]string{{"a", "b"}, {"c"}}}, + {name: "trailing comma", in: "a,", wantErr: true}, + {name: "leading comma", in: ",a", wantErr: true}, + {name: "trailing plus", in: "a+", wantErr: true}, + {name: "leading plus", in: "+a", wantErr: true}, + {name: "double plus", in: "a++b", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTagFilter(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseTagFilter(%q) = %v, want error", tt.in, got) + } + return + } + if err != nil { + t.Fatalf("ParseTagFilter(%q) error: %v", tt.in, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseTagFilter(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/catalog/index.go b/internal/catalog/index.go new file mode 100644 index 0000000..11fdb3e --- /dev/null +++ b/internal/catalog/index.go @@ -0,0 +1,722 @@ +// Package catalog handles loading of shelf files and creating/writing of toml +// and json files +package catalog + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + _ "modernc.org/sqlite" // registers the pure-Go "sqlite" driver + + "github.com/polymorcodeus/book/internal/book" +) + +// indexSchema creates the derived SQLite index. TOML remains the source of +// truth; these tables are rebuilt from it and are safe to delete at any time. +const indexSchema = ` +CREATE TABLE IF NOT EXISTS shelves ( + shelf_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS collections ( + collection_id TEXT PRIMARY KEY, + shelf_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_collections_shelf ON collections(shelf_id); +CREATE TABLE IF NOT EXISTS marks ( + catalog_id TEXT PRIMARY KEY, + collection_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + deleted_at TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_marks_collection ON marks(collection_id); +CREATE TABLE IF NOT EXISTS tags ( + mark_id TEXT NOT NULL, + tag TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag); +CREATE TABLE IF NOT EXISTS file_meta ( + path TEXT PRIMARY KEY, + mtime INTEGER NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + indexed_at TEXT NOT NULL +); +CREATE VIRTUAL TABLE IF NOT EXISTS marks_fts USING fts5(title, url, catalog_id UNINDEXED); +` + +// Index is a derived SQLite index over the shelf TOML files. It is the read and +// search path; writes remain TOML-first and the index is refreshed lazily via +// Sync or eagerly via Rebuild. +type Index struct { + db *sql.DB + path string +} + +// IndexPath returns the on-disk location of the derived index. It prefers the +// user cache dir ($XDG_CACHE_HOME/book/index.db), falling back to the config +// dir next to the shelf directory when the cache dir is unavailable. +func IndexPath(config *book.Config) string { + if cache := os.Getenv("XDG_CACHE_HOME"); cache != "" { + return filepath.Join(cache, "book", "index.db") + } + return filepath.Join(filepath.Dir(config.ShelfRoot), "index.db") +} + +// OpenIndex opens (creating if needed) the SQLite index and ensures its schema +// is present. +func OpenIndex(config *book.Config) (*Index, error) { + path := IndexPath(config) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create index directory: %w", err) + } + + // When falling back to the config dir (which may be git-committed), make + // sure the disposable index never gets committed. + if os.Getenv("XDG_CACHE_HOME") == "" { + ensureIndexGitignore(filepath.Dir(path)) + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + // A single connection sidesteps SQLITE_BUSY between this process's own + // read and write statements. + db.SetMaxOpenConns(1) + + if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { + _ = db.Close() + return nil, err + } + if _, err := db.Exec(indexSchema); err != nil { + _ = db.Close() + return nil, err + } + + return &Index{db: db, path: path}, nil +} + +// Close releases the underlying database connection. +func (ix *Index) Close() error { + return ix.db.Close() +} + +// RebuildReport summarizes a full Rebuild run. +type RebuildReport struct { + Indexed int +} + +// SyncReport summarizes an incremental Sync run. +type SyncReport struct { + Reindexed int + Removed int + Unchanged int +} + +// Rebuild wipes the index and re-indexes every shelf file from disk. It is the +// escape hatch for corruption or drift. +func (ix *Index) Rebuild(config *book.Config) (*RebuildReport, error) { + files, err := shelfFilePaths(config) + if err != nil { + return nil, err + } + + tx, err := ix.db.Begin() + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + for _, stmt := range []string{ + "DELETE FROM tags", + "DELETE FROM marks_fts", + "DELETE FROM marks", + "DELETE FROM collections", + "DELETE FROM shelves", + "DELETE FROM file_meta", + } { + if _, err := tx.Exec(stmt); err != nil { + return nil, err + } + } + + report := &RebuildReport{} + for _, file := range files { + if err := ix.indexShelfFile(tx, file); err != nil { + return nil, err + } + report.Indexed++ + } + + if err := tx.Commit(); err != nil { + return nil, err + } + return report, nil +} + +// Sync reconciles the index with the shelf files on disk. It stats each file +// (fast path); only when mtime or size change does it re-hash the file and +// re-index it. Files that no longer exist are pruned from the index. +func (ix *Index) Sync(config *book.Config) (*SyncReport, error) { + files, err := shelfFilePaths(config) + if err != nil { + return nil, err + } + + tx, err := ix.db.Begin() + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + report := &SyncReport{} + seen := make(map[string]bool, len(files)) + for _, file := range files { + seen[file] = true + changed, err := ix.fileChanged(tx, file) + if err != nil { + return nil, err + } + if !changed { + report.Unchanged++ + continue + } + if err := ix.indexShelfFile(tx, file); err != nil { + return nil, err + } + report.Reindexed++ + } + + if err := ix.pruneRemoved(tx, seen, report); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + return report, nil +} + +// UpsertShelf writes a single shelf (and its collections, marks, and tags) into +// the index. The caller must have already persisted the shelf to TOML. +func (ix *Index) UpsertShelf(s *book.Shelf) error { + tx, err := ix.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := ix.deleteShelf(tx, s.ID, s.FilePath); err != nil { + return err + } + if err := insertShelf(tx, s); err != nil { + return err + } + if err := ix.recordFileMeta(tx, s.FilePath); err != nil { + return err + } + return tx.Commit() +} + +// ShelfNames returns the names of all indexed shelves, sorted. +func (ix *Index) ShelfNames() ([]string, error) { + rows, err := ix.db.Query(`SELECT name FROM shelves ORDER BY name`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// CollectionNames returns the names of all collections in a shelf, sorted. +func (ix *Index) CollectionNames(shelfName string) ([]string, error) { + var exists bool + if err := ix.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM shelves WHERE name = ?)`, shelfName).Scan(&exists); err != nil { + return nil, err + } + if !exists { + return nil, fmt.Errorf("shelf %q not found", shelfName) + } + + rows, err := ix.db.Query(` + SELECT c.name + FROM collections c + JOIN shelves s ON s.shelf_id = c.shelf_id + WHERE s.name = ? + ORDER BY c.name`, shelfName) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// Collection returns a reconstructed collection (with marks and tags) for the +// named shelf and collection. Soft-deleted marks are excluded. +func (ix *Index) Collection(shelfName, collectionName string) (*book.Collection, error) { + var shelfID string + err := ix.db.QueryRow(`SELECT shelf_id FROM shelves WHERE name = ?`, shelfName).Scan(&shelfID) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("shelf %q not found", shelfName) + } + if err != nil { + return nil, err + } + + col := &book.Collection{} + err = ix.db.QueryRow(` + SELECT collection_id, name, description, created_at, updated_at + FROM collections + WHERE shelf_id = ? AND name = ?`, shelfID, collectionName). + Scan(&col.ID, &col.Name, &col.Description, &col.CreatedAt, &col.UpdatedAt) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("collection %q not found in shelf %q", collectionName, shelfName) + } + if err != nil { + return nil, err + } + + rows, err := ix.db.Query(` + SELECT catalog_id, title, url, created_at, updated_at, deleted_at + FROM marks + WHERE collection_id = ? AND deleted_at = '' + ORDER BY rowid`, col.ID) + if err != nil { + return nil, err + } + + var marks []*book.Mark + for rows.Next() { + m := &book.Mark{} + if err := rows.Scan(&m.ID, &m.Name, &m.URL, &m.CreatedAt, &m.UpdatedAt, &m.DeletedAt); err != nil { + _ = rows.Close() + return nil, err + } + marks = append(marks, m) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + // Load tags after closing the marks cursor; with a single connection the + // nested tag query cannot run while the cursor still holds it. + for _, m := range marks { + if err := ix.loadTags(m); err != nil { + return nil, err + } + } + col.Marks = marks + return col, nil +} + +// SearchResult is a single match from a full-text search over the index. +type SearchResult struct { + Shelf string `json:"shelf" toml:"shelf"` + Collection string `json:"collection" toml:"collection"` + Title string `json:"title" toml:"title"` + URL string `json:"url" toml:"url"` + Tags []string `json:"tags" toml:"tags"` +} + +// Search runs an FTS5 query over mark titles and URLs, excluding soft-deleted +// marks. The query string uses FTS5 match syntax and may be empty to search by +// filters alone. tagClauses filters results to marks matching every clause +// (AND), where each clause is a set of tags of which at least one must match +// (OR). An empty tagClauses applies no tag filter. +func (ix *Index) Search(query, shelfName, collectionName string, tagClauses [][]string) ([]SearchResult, error) { + var sqlQuery string + var args []any + + if query != "" { + sqlQuery = ` + SELECT m.catalog_id, m.title, m.url, c.name, s.name + FROM marks_fts + JOIN marks m ON m.catalog_id = marks_fts.catalog_id + JOIN collections c ON c.collection_id = m.collection_id + JOIN shelves s ON s.shelf_id = c.shelf_id + WHERE marks_fts MATCH ? AND m.deleted_at = ''` + args = append(args, query) + } else { + sqlQuery = ` + SELECT m.catalog_id, m.title, m.url, c.name, s.name + FROM marks m + JOIN collections c ON c.collection_id = m.collection_id + JOIN shelves s ON s.shelf_id = c.shelf_id + WHERE m.deleted_at = ''` + } + + if shelfName != "" { + sqlQuery += " AND s.name = ?" + args = append(args, shelfName) + } + if collectionName != "" { + sqlQuery += " AND c.name = ?" + args = append(args, collectionName) + } + for i, clause := range tagClauses { + alias := fmt.Sprintf("tf%d", i) + sqlQuery += fmt.Sprintf( + " AND EXISTS (SELECT 1 FROM tags %s WHERE %s.mark_id = m.catalog_id AND %s.tag IN (%s))", + alias, alias, alias, placeholders(len(clause))) + for _, tag := range clause { + args = append(args, tag) + } + } + if query != "" { + sqlQuery += " ORDER BY rank" + } else { + sqlQuery += " ORDER BY s.name, c.name, m.title" + } + + rows, err := ix.db.Query(sqlQuery, args...) + if err != nil { + return nil, err + } + + type match struct { + id, title, url, collection, shelf string + } + var matches []match + for rows.Next() { + var m match + if err := rows.Scan(&m.id, &m.title, &m.url, &m.collection, &m.shelf); err != nil { + _ = rows.Close() + return nil, err + } + matches = append(matches, m) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + results := make([]SearchResult, 0, len(matches)) + for _, m := range matches { + mark := &book.Mark{ID: m.id} + if err := ix.loadTags(mark); err != nil { + return nil, err + } + results = append(results, SearchResult{ + Shelf: m.shelf, + Collection: m.collection, + Title: m.title, + URL: m.url, + Tags: mark.Tags, + }) + } + return results, nil +} + +// loadTags populates the mark's Tags from the tags table, sorted. +func (ix *Index) loadTags(m *book.Mark) error { + rows, err := ix.db.Query(`SELECT tag FROM tags WHERE mark_id = ? ORDER BY tag`, m.ID) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return err + } + m.Tags = append(m.Tags, tag) + } + return rows.Err() +} + +// fileChanged reports whether the file's content differs from what is indexed. +// It returns true on a stat fast-path miss only after confirming the hash also +// differs, so a mere touch does not trigger a reindex. +func (ix *Index) fileChanged(tx *sql.Tx, path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + var ( + storedMtime int64 + storedSize int64 + storedHash string + ) + err = tx.QueryRow(`SELECT mtime, size, sha256 FROM file_meta WHERE path = ?`, path). + Scan(&storedMtime, &storedSize, &storedHash) + if err == sql.ErrNoRows { + return true, nil + } + if err != nil { + return false, err + } + + mtime := info.ModTime().UnixNano() + size := info.Size() + if mtime == storedMtime && size == storedSize { + return false, nil + } + + sum, err := hashFile(path) + if err != nil { + return false, err + } + return sum != storedHash, nil +} + +// indexShelfFile decodes a single shelf file and replaces its rows in the +// index, then records the file metadata used for future invalidation. +func (ix *Index) indexShelfFile(tx *sql.Tx, path string) error { + var shelf book.Shelf + if _, err := toml.DecodeFile(path, &shelf); err != nil { + return err + } + shelf.FilePath = path + + // Ensure v2 identity fields exist so primary keys are never empty. This is + // in-memory only; the TOML file is not rewritten here. + if !shelf.IsV2() { + MigrateShelf(&shelf) + } + for _, c := range shelf.Collections { + for _, m := range c.Marks { + if m.ID == "" { + m.ID = book.GenerateID(m.URL) + } + } + } + + if err := ix.deleteShelf(tx, shelf.ID, path); err != nil { + return err + } + if err := insertShelf(tx, &shelf); err != nil { + return err + } + return ix.recordFileMeta(tx, path) +} + +// deleteShelf removes every row belonging to a shelf, identified by shelf_id, +// plus the file metadata for its path. +func (ix *Index) deleteShelf(tx *sql.Tx, shelfID, filePath string) error { + if _, err := tx.Exec(` + DELETE FROM marks_fts WHERE catalog_id IN ( + SELECT m.catalog_id FROM marks m + JOIN collections c ON c.collection_id = m.collection_id + WHERE c.shelf_id = ? + )`, shelfID); err != nil { + return err + } + if _, err := tx.Exec(` + DELETE FROM tags WHERE mark_id IN ( + SELECT m.catalog_id FROM marks m + JOIN collections c ON c.collection_id = m.collection_id + WHERE c.shelf_id = ? + )`, shelfID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM marks WHERE collection_id IN (SELECT collection_id FROM collections WHERE shelf_id = ?)`, shelfID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM collections WHERE shelf_id = ?`, shelfID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM shelves WHERE shelf_id = ?`, shelfID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM file_meta WHERE path = ?`, filePath); err != nil { + return err + } + return nil +} + +// insertShelf writes a shelf and all of its nested collections, marks, and tags. +func insertShelf(tx *sql.Tx, s *book.Shelf) error { + if _, err := tx.Exec(` + INSERT INTO shelves (shelf_id, name, description, created_at, updated_at, file_path) + VALUES (?, ?, ?, ?, ?, ?)`, + s.ID, s.Name, s.Description, s.CreatedAt, s.UpdatedAt, s.FilePath); err != nil { + return err + } + + for _, c := range s.Collections { + if _, err := tx.Exec(` + INSERT INTO collections (collection_id, shelf_id, name, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + c.ID, s.ID, c.Name, c.Description, c.CreatedAt, c.UpdatedAt); err != nil { + return err + } + + for _, m := range c.Marks { + if _, err := tx.Exec(` + INSERT INTO marks (catalog_id, collection_id, title, url, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + m.ID, c.ID, m.Name, m.URL, m.CreatedAt, m.UpdatedAt, m.DeletedAt); err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO marks_fts (title, url, catalog_id) VALUES (?, ?, ?)`, m.Name, m.URL, m.ID); err != nil { + return err + } + for _, tag := range m.Tags { + if _, err := tx.Exec(`INSERT INTO tags (mark_id, tag) VALUES (?, ?)`, m.ID, tag); err != nil { + return err + } + } + } + } + return nil +} + +// recordFileMeta stores the stat and hash of a shelf file for invalidation. +func (ix *Index) recordFileMeta(tx *sql.Tx, path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + sum, err := hashFile(path) + if err != nil { + return err + } + _, err = tx.Exec(` + INSERT OR REPLACE INTO file_meta (path, mtime, size, sha256, indexed_at) + VALUES (?, ?, ?, ?, ?)`, + path, info.ModTime().UnixNano(), info.Size(), sum, book.NowTimestamp()) + return err +} + +// pruneRemoved deletes index rows for shelf files that no longer exist on disk. +func (ix *Index) pruneRemoved(tx *sql.Tx, seen map[string]bool, report *SyncReport) error { + rows, err := tx.Query(`SELECT shelf_id, file_path FROM shelves`) + if err != nil { + return err + } + + var stale []struct{ id, path string } + for rows.Next() { + var id, path string + if err := rows.Scan(&id, &path); err != nil { + _ = rows.Close() + return err + } + if !seen[path] { + stale = append(stale, struct{ id, path string }{id, path}) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + for _, d := range stale { + if err := ix.deleteShelf(tx, d.id, d.path); err != nil { + return err + } + report.Removed++ + } + return nil +} + +// shelfFilePaths returns the shelf TOML files in the shelf directory. +func shelfFilePaths(config *book.Config) ([]string, error) { + globDir := fmt.Sprintf("%s/*.%s", config.ShelfRoot, config.CatalogFormat) + files, err := filepath.Glob(globDir) + if err != nil { + return nil, err + } + + out := files[:0] + for _, file := range files { + if filepath.Base(file) == filepath.Base(config.ConfigFile) { + continue + } + out = append(out, file) + } + return out, nil +} + +// placeholders returns a comma-separated list of n SQL "?" placeholders. +func placeholders(n int) string { + if n <= 0 { + return "" + } + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} + +// hashFile returns the lowercase hex SHA-256 of the file's contents. +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// ensureIndexGitignore best-effort adds the disposable index to the config +// directory's .gitignore so it is never committed alongside the TOML shelves. +func ensureIndexGitignore(dir string) { + gi := filepath.Join(dir, ".gitignore") + if data, err := os.ReadFile(gi); err == nil { + if strings.Contains(string(data), "index.db") { + return + } + } + + f, err := os.OpenFile(gi, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer func() { _ = f.Close() }() + _, _ = fmt.Fprintln(f, "index.db") +} diff --git a/internal/catalog/index_test.go b/internal/catalog/index_test.go new file mode 100644 index 0000000..72b4245 --- /dev/null +++ b/internal/catalog/index_test.go @@ -0,0 +1,406 @@ +package catalog + +import ( + "os" + "path/filepath" + "testing" + + "github.com/polymorcodeus/book/internal/book" +) + +func testConfig(t *testing.T) *book.Config { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "cache")) + + cfg := &book.Config{ + ShelfRoot: filepath.Join(dir, "shelf.d"), + CatalogFormat: "toml", + ConfigFile: filepath.Join(dir, "config"), + } + if err := os.MkdirAll(cfg.ShelfRoot, 0o755); err != nil { + t.Fatal(err) + } + return cfg +} + +func writeShelfFile(t *testing.T, cfg *book.Config, s *book.Shelf) { + t.Helper() + s.AddFileDetail(cfg) + if err := CreateTOML(s); err != nil { + t.Fatalf("write shelf file: %v", err) + } +} + +func sampleShelf() *book.Shelf { + v2 := 2 + return &book.Shelf{ + SchemaVersion: &v2, + ID: book.GenerateShelfID("work"), + Name: "work", + Description: "work stuff", + Collections: map[string]*book.Collection{ + "golang": { + ID: book.GenerateCollectionID("work", "golang"), + Name: "golang", + Description: "go links", + Marks: []*book.Mark{ + {ID: book.GenerateID("https://go.dev"), Name: "The Go Programming Language", URL: "https://go.dev", Tags: []string{"lang", "official"}}, + {ID: book.GenerateID("https://pkg.go.dev"), Name: "Golang patterns", URL: "https://pkg.go.dev", Tags: []string{"docs"}}, + }, + }, + }, + } +} + +func TestOpenIndexCreatesSchema(t *testing.T) { + cfg := testConfig(t) + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + for _, table := range []string{"shelves", "collections", "marks", "tags", "file_meta", "marks_fts"} { + var name string + err := ix.db.QueryRow(`SELECT name FROM sqlite_master WHERE type IN ('table','virtual') AND name = ?`, table).Scan(&name) + if err != nil { + t.Errorf("table %q missing: %v", table, err) + } + } +} + +func TestUpsertAndRead(t *testing.T) { + cfg := testConfig(t) + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + s := sampleShelf() + writeShelfFile(t, cfg, s) + if err := ix.UpsertShelf(s); err != nil { + t.Fatalf("UpsertShelf: %v", err) + } + + names, err := ix.ShelfNames() + if err != nil { + t.Fatalf("ShelfNames: %v", err) + } + if len(names) != 1 || names[0] != "work" { + t.Fatalf("ShelfNames = %v, want [work]", names) + } + + cols, err := ix.CollectionNames("work") + if err != nil { + t.Fatalf("CollectionNames: %v", err) + } + if len(cols) != 1 || cols[0] != "golang" { + t.Fatalf("CollectionNames = %v, want [golang]", cols) + } + + col, err := ix.Collection("work", "golang") + if err != nil { + t.Fatalf("Collection: %v", err) + } + if len(col.Marks) != 2 { + t.Fatalf("Collection marks = %d, want 2", len(col.Marks)) + } + if got := col.Marks[0].Tags; len(got) != 2 || got[0] != "lang" || got[1] != "official" { + t.Fatalf("mark tags = %v, want [lang official]", got) + } +} + +func TestCollectionNotFound(t *testing.T) { + cfg := testConfig(t) + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.CollectionNames("missing"); err == nil { + t.Fatal("CollectionNames(missing) = nil error, want not-found error") + } + if _, err := ix.Collection("work", "nope"); err == nil { + t.Fatal("Collection(work, nope) = nil error, want not-found error") + } +} + +func TestRebuildFromDisk(t *testing.T) { + cfg := testConfig(t) + writeShelfFile(t, cfg, sampleShelf()) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + report, err := ix.Rebuild(cfg) + if err != nil { + t.Fatalf("Rebuild: %v", err) + } + if report.Indexed != 1 { + t.Fatalf("Rebuild indexed = %d, want 1", report.Indexed) + } + + names, err := ix.ShelfNames() + if err != nil { + t.Fatalf("ShelfNames: %v", err) + } + if len(names) != 1 || names[0] != "work" { + t.Fatalf("ShelfNames = %v, want [work]", names) + } +} + +func TestSyncIncrementalAndPrune(t *testing.T) { + cfg := testConfig(t) + writeShelfFile(t, cfg, sampleShelf()) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.Rebuild(cfg); err != nil { + t.Fatalf("Rebuild: %v", err) + } + + // No changes: everything unchanged, nothing reindexed. + report, err := ix.Sync(cfg) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if report.Reindexed != 0 || report.Unchanged != 1 || report.Removed != 0 { + t.Fatalf("Sync = %+v, want unchanged only", report) + } + + // Add a mark and rewrite: should reindex exactly one file. + s := sampleShelf() + s.Collections["golang"].Marks = append(s.Collections["golang"].Marks, + &book.Mark{ID: book.GenerateID("https://example.com"), Name: "Example", URL: "https://example.com", Tags: []string{"misc"}}) + writeShelfFile(t, cfg, s) + + report, err = ix.Sync(cfg) + if err != nil { + t.Fatalf("Sync after change: %v", err) + } + if report.Reindexed != 1 { + t.Fatalf("Sync reindexed = %d, want 1", report.Reindexed) + } + + col, err := ix.Collection("work", "golang") + if err != nil { + t.Fatalf("Collection: %v", err) + } + if len(col.Marks) != 3 { + t.Fatalf("Collection marks = %d, want 3", len(col.Marks)) + } + + // Remove the file: should prune exactly one shelf. + if err := os.Remove(s.FilePath); err != nil { + t.Fatal(err) + } + report, err = ix.Sync(cfg) + if err != nil { + t.Fatalf("Sync after remove: %v", err) + } + if report.Removed != 1 { + t.Fatalf("Sync removed = %d, want 1", report.Removed) + } + names, err := ix.ShelfNames() + if err != nil { + t.Fatalf("ShelfNames: %v", err) + } + if len(names) != 0 { + t.Fatalf("ShelfNames = %v, want empty", names) + } +} + +func TestSyncIgnoresContentUnchangedTouch(t *testing.T) { + cfg := testConfig(t) + writeShelfFile(t, cfg, sampleShelf()) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.Rebuild(cfg); err != nil { + t.Fatalf("Rebuild: %v", err) + } + + // Rewrite identical content: mtime changes but hash does not. + writeShelfFile(t, cfg, sampleShelf()) + + report, err := ix.Sync(cfg) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if report.Reindexed != 0 { + t.Fatalf("Sync reindexed = %d, want 0 (hash unchanged)", report.Reindexed) + } +} + +func TestSearch(t *testing.T) { + cfg := testConfig(t) + writeShelfFile(t, cfg, sampleShelf()) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.Rebuild(cfg); err != nil { + t.Fatalf("Rebuild: %v", err) + } + + results, err := ix.Search("golang", "", "", nil) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 1 { + t.Fatalf("Search(golang) = %d results, want 1", len(results)) + } + if results[0].Title != "Golang patterns" || results[0].Shelf != "work" || results[0].Collection != "golang" { + t.Fatalf("Search result = %+v", results[0]) + } + + // URL token search should also match. + results, err = ix.Search("pkg", "", "", nil) + if err != nil { + t.Fatalf("Search(pkg): %v", err) + } + if len(results) != 1 { + t.Fatalf("Search(pkg) = %d results, want 1", len(results)) + } +} + +func TestSearchTagFilter(t *testing.T) { + cfg := testConfig(t) + writeShelfFile(t, cfg, sampleShelf()) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.Rebuild(cfg); err != nil { + t.Fatalf("Rebuild: %v", err) + } + + // "go" matches both marks; OR filter (lang OR docs) keeps both. + results, err := ix.Search("go", "", "", [][]string{{"lang", "docs"}}) + if err != nil { + t.Fatalf("Search OR: %v", err) + } + if len(results) != 2 { + t.Fatalf("Search OR = %d results, want 2", len(results)) + } + + // Single tag filter narrows to the mark carrying "lang". + results, err = ix.Search("go", "", "", [][]string{{"lang"}}) + if err != nil { + t.Fatalf("Search single tag: %v", err) + } + if len(results) != 1 || results[0].Title != "The Go Programming Language" { + t.Fatalf("Search single tag = %+v, want mark with lang", results) + } + + // AND filter (lang AND official) matches only mark 1. + results, err = ix.Search("go", "", "", [][]string{{"lang"}, {"official"}}) + if err != nil { + t.Fatalf("Search AND: %v", err) + } + if len(results) != 1 || results[0].Title != "The Go Programming Language" { + t.Fatalf("Search AND = %+v, want mark with lang+official", results) + } + + // AND filter with no overlap matches nothing. + results, err = ix.Search("go", "", "", [][]string{{"lang"}, {"docs"}}) + if err != nil { + t.Fatalf("Search AND empty: %v", err) + } + if len(results) != 0 { + t.Fatalf("Search AND empty = %d results, want 0", len(results)) + } +} + +func TestSearchTagsOnly(t *testing.T) { + cfg := testConfig(t) + writeShelfFile(t, cfg, sampleShelf()) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.Rebuild(cfg); err != nil { + t.Fatalf("Rebuild: %v", err) + } + + // Empty query with a tag filter returns every mark carrying that tag. + results, err := ix.Search("", "", "", [][]string{{"docs"}}) + if err != nil { + t.Fatalf("Search tags-only: %v", err) + } + if len(results) != 1 || results[0].Title != "Golang patterns" { + t.Fatalf("Search tags-only = %+v, want mark with docs", results) + } + + // Empty query with a shelf filter returns every mark in that shelf. + results, err = ix.Search("", "work", "", nil) + if err != nil { + t.Fatalf("Search shelf-only: %v", err) + } + if len(results) != 2 { + t.Fatalf("Search shelf-only = %d results, want 2", len(results)) + } +} + +func TestSearchExcludesSoftDeleted(t *testing.T) { + cfg := testConfig(t) + + v2 := 2 + s := &book.Shelf{ + SchemaVersion: &v2, + ID: book.GenerateShelfID("work"), + Name: "work", + Collections: map[string]*book.Collection{ + "golang": { + ID: book.GenerateCollectionID("work", "golang"), + Name: "golang", + Marks: []*book.Mark{ + {ID: book.GenerateID("https://go.dev"), Name: "The Go Programming Language", URL: "https://go.dev", Tags: []string{"lang"}}, + {ID: book.GenerateID("https://pkg.go.dev"), Name: "Golang patterns", URL: "https://pkg.go.dev", Tags: []string{"docs"}, DeletedAt: book.NowTimestamp()}, + }, + }, + }, + } + writeShelfFile(t, cfg, s) + + ix, err := OpenIndex(cfg) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer func() { _ = ix.Close() }() + + if _, err := ix.Rebuild(cfg); err != nil { + t.Fatalf("Rebuild: %v", err) + } + + results, err := ix.Search("go", "", "", nil) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 1 || results[0].Title != "The Go Programming Language" { + t.Fatalf("Search excluded soft-deleted = %+v, want only the non-deleted mark", results) + } +}