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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ jobs:
- name: Vet
run: go vet ./...

- name: Dependency guard
run: |
! go list -deps ./pkg/... | grep -E '^(charm\.land/(bubbletea|huh|lipgloss)|github\.com/urfave/|github\.com/polymorcodeus/book/internal)'

- name: Test
run: go test -v -race -coverprofile=coverage.out ./...

Expand Down
49 changes: 27 additions & 22 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ cmd/book/
├── spinner.go # huh spinner wrappers: loadCatalog, loadWebsite
└── print.go # printCatalog (marshal + print helper)

internal/book/
└── types.go # Core data structs: Config, BookShelves, Shelf, Collection, Mark

internal/catalog/
├── catalog.go # VerifyExists, LoadShelves
└── toml.go # TOML read/write, atomic writes, config creation

internal/web/
pkg/book/
├── types.go # Core data structs: Config, BookShelves, Shelf, Collection, Mark
├── doctor.go # MarkConflict, DetectDuplicates, ResolveDuplicates
└── templates.go # ViewTemplate, DefaultViewTemplates, Templatable helpers

pkg/catalog/
├── catalog.go # Paths, ShelfPath, VerifyExists, LoadShelves
├── toml.go # TOML read/write, atomic writes, config creation
├── migrate.go # MigrateShelfDir, MigrateShelf (schema v1 -> v2)
├── index.go # SQLite derived index: OpenIndex, Sync, Rebuild, Search, StaleFiles
└── doctor.go # V1ShelfFiles, StrayDebris (filesystem health checks)

pkg/web/
└── web.go # OpenURL, WebsiteTitle

internal/theme/
Expand All @@ -47,13 +52,15 @@ internal/model/

| Package | Imports | Does NOT import |
|---------|---------|-----------------|
| `internal/book` | stdlib + `toml` | `internal/catalog`, `internal/model`, `internal/theme` |
| `internal/theme` | `internal/book`, `huh`, `lipgloss`, `json` | `internal/catalog`, `internal/model` |
| `internal/web` | `goquery` | `internal/book`, `internal/catalog`, `internal/model` |
| `internal/catalog` | `book`, `toml` | `internal/model`, `internal/theme` |
| `internal/model` | `book`, `catalog`, `theme`, `web`, `huh`, `lipgloss`, `bubbletea` | — |
| `pkg/book` | stdlib + `toml` | `pkg/catalog`, `pkg/web`, `internal/*` |
| `pkg/catalog` | `pkg/book`, `toml`, `modernc.org/sqlite` | `pkg/web`, `internal/*` |
| `pkg/web` | `goquery` | `pkg/book`, `pkg/catalog`, `internal/*` |
| `internal/theme` | `pkg/book`, `huh`, `lipgloss`, `json` | `pkg/catalog`, `internal/model` |
| `internal/model` | `pkg/book`, `pkg/catalog`, `internal/theme`, `pkg/web`, `huh`, `lipgloss`, `bubbletea` | — |
| `cmd` | everything | — |

The `pkg/` packages are the public library and must stay free of the TUI stack, `urfave/*`, and `internal/*`; `make check-deps` enforces this.

## Conventions

### Go Version
Expand Down Expand Up @@ -94,7 +101,7 @@ All styling goes through `internal/theme/theme.go`. Never hardcode colors or lip
Run the full check before pushing:

```bash
make check # fmt, vet, lint, test
make check # fmt, vet, lint, test, check-deps
```

Or manually:
Expand All @@ -108,19 +115,17 @@ go test ./...

### Linting

We use `golangci-lint` via `make lint`. Key linters to care about: `errcheck` (explicit `Close()` handling), `govet`, `ineffassign`, `staticcheck`, `unused`.

Note: there is no `.golangci.yml` in the repo yet. If you want to add one, open an issue first.
We use `golangci-lint` via `make lint`, configured in `.golangci.yml`. Key linters to care about: `errcheck` (explicit `Close()` handling), `govet`, `ineffassign`, `staticcheck`, `unused`.

### Tests

There are currently **zero tests**. New features or bug fixes **should** include tests where feasible. Priority targets for coverage:
Unit tests live next to the packages they cover (`pkg/book`, `pkg/catalog`, `pkg/web`, `internal/theme`, `cmd/book`). New features or bug fixes **should** include tests where feasible. Priority targets for coverage:

- `VerifyUniqueURL`, `DedupUnique`, `MergeTags`, `GenerateID` in `internal/book`
- TOML round-trip encoding/decoding in `internal/catalog`
- `WebsiteTitle` / `OpenURL` in `internal/web` (mock HTTP server)
- Pure helpers in `pkg/book` (`VerifyUniqueURL`, `MergeTags`, `GenerateID`, and friends)
- TOML round-trip encoding/decoding in `pkg/catalog`
- `WebsiteTitle` / `OpenURL` in `pkg/web` (mock HTTP server)

Prefer table-driven tests. The TUI layer (`internal/model`) is harder to unit test — focus on extracting pure logic into `internal/book` instead.
Prefer table-driven tests. The TUI layer (`internal/model`) is harder to unit test — focus on extracting pure logic into `pkg/book` instead.

## Pull Request Process

Expand Down
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ YELLOW=\033[0;33m
BLUE=\033[0;34m
NC=\033[0m # No Color

.PHONY: help build test clean install uninstall fmt lint vet tidy run dev cross-compile release goreleaser-check goreleaser-snapshot
.PHONY: help build test clean install uninstall fmt lint vet tidy run dev cross-compile release goreleaser-check goreleaser-snapshot check-deps

## help: Show this help message
help:
Expand All @@ -35,7 +35,8 @@ help:
@echo " lint Run golangci-lint"
@echo " vet Run go vet"
@echo " tidy Tidy Go modules"
@echo " check Run all quality checks (fmt, vet, lint, test)"
@echo " check-deps Assert pkg/ stays free of TUI/CLI dependencies"
@echo " check Run all quality checks (fmt, vet, lint, test, check-deps)"
@echo ""
@echo "$(GREEN)Installation:$(NC)"
@echo " install Install binary to /usr/local/bin"
Expand Down Expand Up @@ -115,8 +116,17 @@ tidy:
@go mod tidy
@echo "$(GREEN)Modules tidied$(NC)"

## check-deps: Assert pkg/ packages stay free of TUI/CLI dependencies
check-deps:
@echo "$(BLUE)Checking pkg/ dependency graph...$(NC)"
@if go list -deps ./pkg/... | grep -E '^(charm\.land/(bubbletea|huh|lipgloss)|github\.com/urfave/|github\.com/polymorcodeus/book/internal)'; then \
echo "$(RED)forbidden dependency in pkg/ graph$(NC)"; \
exit 1; \
fi
@echo "$(GREEN)pkg/ dependency graph is clean$(NC)"

## check: Run all quality checks
check: fmt vet lint test
check: fmt vet lint test check-deps
@echo "$(GREEN)All quality checks passed$(NC)"

## install: Install binary to /usr/local/bin
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,48 @@ Run `book catalog theme` to generate a `theme.json` with default values. Edit co

Run `book catalog template` to generate a `template.json`. This controls the title strings shown in TUI forms (e.g., the main menu header, list headers). Overlay your own values — unset keys keep their defaults.

## Use as a Library

The domain model and storage layer are importable Go packages, fully decoupled from the CLI and TUI:

| Package | What it provides |
| --- | --- |
| `github.com/polymorcodeus/book/pkg/book` | Domain types (`Shelf`, `Collection`, `Mark`) and pure logic: constructors, validation, tag parsing, soft delete, merge reconciliation |
| `github.com/polymorcodeus/book/pkg/catalog` | TOML persistence (`LoadShelves`, `UpdateShelfFile`, atomic writes), schema migration, and the derived SQLite search index |
| `github.com/polymorcodeus/book/pkg/web` | Page-title fetching (`WebsiteTitle`) and browser opening (`OpenURL`) |

```go
paths := catalog.Paths{ShelfRoot: "/path/to/shelf.d", CatalogFormat: "toml"}

shelf, _ := book.NewShelf("work", "work stuff")
collection, _ := book.NewCollection(shelf, "golang", "go links")
mark, _ := book.NewMarkFromInput("https://go.dev", book.SplitTags("lang,official"))
mark.Name = "The Go Programming Language"
mark.Shelf = shelf
mark.Collection = collection
mark.RecordAdd()
collection.AddMark(&mark)
shelf.AddCollection(collection)

shelf.FilePath = catalog.ShelfPath(shelf.Name, paths)
if err := catalog.UpdateShelfFile(shelf); err != nil {
// handle error
}

var shelves book.BookShelves
if err := catalog.LoadShelves(&shelves, paths); err != nil {
// handle error
}
```

Storage entry points take a narrow `catalog.Paths` (shelf directory, file format) rather than the CLI's configuration struct, so library consumers never touch flag, theme, or TUI concerns. A runnable version of this round trip lives in `pkg/book/example_test.go`.

Notes:

- The `pkg/` API follows the module's semver but may break on minor releases until the tool cuts v2.0.0.
- `pkg/catalog` pulls in `modernc.org/sqlite` (pure Go, no cgo) for the search index. Depend on `pkg/book` alone if you only need the domain types.
- `make check-deps` guards the boundary: `pkg/` never imports the TUI stack (`bubbletea`/`huh`/`lipgloss`), the CLI framework (`urfave`), or `internal/`.

## Acknowledgements

Built on [Charm](https://charm.sh/)'s excellent BubbleTea, Huh, and Lipgloss libraries. Uses `gofiglet` for the ASCII banner.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.5.1
v1.6.0
2 changes: 1 addition & 1 deletion cmd/book/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func testConfig(t *testing.T) *theme.UIConfig {
func loadShelves(t *testing.T, config *theme.UIConfig) *book.BookShelves {
t.Helper()
var bs book.BookShelves
if err := catalog.LoadShelves(&bs, config.Config); err != nil {
if err := catalog.LoadShelves(&bs, catalog.PathsFromConfig(config.Config)); err != nil {
t.Fatalf("load shelves: %v", err)
}
return &bs
Expand Down
7 changes: 4 additions & 3 deletions cmd/book/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
// duplicates and rewrites the affected shelf files.
func doctor(cache *indexCache, config *book.Config, fix bool) error {
var shelves book.BookShelves
if err := catalog.LoadShelves(&shelves, config); err != nil {
if err := catalog.LoadShelves(&shelves, catalog.PathsFromConfig(config)); err != nil {
return err
}

Expand Down Expand Up @@ -74,7 +74,8 @@ func doctor(cache *indexCache, config *book.Config, fix bool) error {
// indexStaleFiles returns shelf paths whose index entries are out of date, or
// nil when the index has not been built yet.
func indexStaleFiles(cache *indexCache, config *book.Config) ([]string, error) {
exists, err := catalog.VerifyExists(catalog.IndexPath(config))
paths := catalog.PathsFromConfig(config)
exists, err := catalog.VerifyExists(catalog.IndexPath(paths))
if err != nil {
return nil, err
}
Expand All @@ -86,7 +87,7 @@ func indexStaleFiles(cache *indexCache, config *book.Config) ([]string, error) {
if err != nil {
return nil, err
}
return idx.StaleFiles(config)
return idx.StaleFiles(paths)
}

type doctorReport struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/book/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func gc(cache *indexCache, config *book.Config, retentionDays int) error {
}

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

Expand Down
8 changes: 4 additions & 4 deletions cmd/book/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type indexCache struct {
// get lazily opens the derived index.
func (c *indexCache) get(config *book.Config) (*catalog.Index, error) {
if c.index == nil {
idx, err := catalog.OpenIndex(config)
idx, err := catalog.OpenIndex(catalog.PathsFromConfig(config))
if err != nil {
return nil, err
}
Expand All @@ -33,7 +33,7 @@ func (c *indexCache) sync(config *book.Config) (*catalog.Index, error) {
if err != nil {
return nil, err
}
if _, err := idx.Sync(config); err != nil {
if _, err := idx.Sync(catalog.PathsFromConfig(config)); err != nil {
return nil, err
}
return idx, nil
Expand All @@ -45,7 +45,7 @@ func (c *indexCache) rebuild(config *book.Config) (*catalog.RebuildReport, error
if err != nil {
return nil, err
}
return idx.Rebuild(config)
return idx.Rebuild(catalog.PathsFromConfig(config))
}

// close releases the cached index, if any.
Expand All @@ -71,7 +71,7 @@ func runIndexSync(cache *indexCache, config *book.Config) error {
return err
}

report, err := idx.Sync(config)
report, err := idx.Sync(catalog.PathsFromConfig(config))
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/book/shelf.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func addShelf(bs *book.BookShelves, name, description string, config *theme.UICo
if err != nil {
return err
}
shelf.AddFileDetail(config.Config)
shelf.FilePath = catalog.ShelfPath(shelf.Name, catalog.PathsFromConfig(config.Config))
if err := catalog.UpdateShelfFile(shelf); err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/book/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ func loadCatalog(bs *book.BookShelves, config *book.Config, interactive bool) er
defer cancel()

if !interactive {
return catalog.LoadShelves(bs, config)
return catalog.LoadShelves(bs, catalog.PathsFromConfig(config))
}

return spinner.New().
Context(ctx).
ActionWithErr(func(context.Context) error {
time.Sleep(1 * time.Second)
return catalog.LoadShelves(bs, config)
return catalog.LoadShelves(bs, catalog.PathsFromConfig(config))
}).
Title("Loading your bookshelves ...").
Run()
Expand Down
2 changes: 1 addition & 1 deletion internal/model/shelf_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ func (m editShelfModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return newM, nil
}
shelf.AddCollection(collection)
shelf.AddFileDetail(newM.editor.config.Config)
shelf.FilePath = catalog.ShelfPath(shelf.Name, catalog.PathsFromConfig(newM.editor.config.Config))
newM.editor.shelf = shelf
newM.editor.collection = collection
}
Expand Down
67 changes: 67 additions & 0 deletions pkg/book/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package book_test

import (
"fmt"
"os"

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

// Example demonstrates the library round trip an external consumer performs:
// build a shelf with a collection and a mark, persist it as TOML, and load it
// back from disk.
func Example() {
dir, err := os.MkdirTemp("", "book-example")
if err != nil {
panic(err)
}
defer func() { _ = os.RemoveAll(dir) }()

paths := catalog.Paths{ShelfRoot: dir, CatalogFormat: "toml"}

shelf, err := book.NewShelf("work", "work stuff")
if err != nil {
panic(err)
}
collection, err := book.NewCollection(shelf, "golang", "go links")
if err != nil {
panic(err)
}
mark, err := book.NewMarkFromInput("https://go.dev", book.SplitTags("lang, official"))
if err != nil {
panic(err)
}
mark.Name = "The Go Programming Language"
mark.Shelf = shelf
mark.Collection = collection
mark.RecordAdd()
collection.AddMark(&mark)
shelf.AddCollection(collection)

shelf.FilePath = catalog.ShelfPath(shelf.Name, paths)
if err := catalog.UpdateShelfFile(shelf); err != nil {
panic(err)
}

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

loaded, ok := shelves.Shelf("work")
if !ok {
panic("shelf not found")
}
got := loaded.Collection("golang").Mark("The Go Programming Language")
fmt.Println(loaded.Name)
fmt.Println(got.Name)
fmt.Println(got.URL)
fmt.Println(got.Tags)

// Output:
// work
// The Go Programming Language
// https://go.dev
// [lang official]
}
Loading
Loading