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
22 changes: 13 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,24 @@ cmd/book/
├── main.go # CLI command tree (urfave/cli/v3), flags, Before hook
├── shelf.go # shelf command actions + TUI root screen
├── collection.go # collection command actions + TUI root screen
└── mark.go # mark command actions (add, get, edit, remove) + TUI root screen
├── mark.go # mark command actions (add, get, edit, remove) + TUI root screen
├── catalog.go # book catalog theme/template/config (dumpDefaults, printConfigSources)
├── 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, LoadCatalog (spinner)
├── catalog.go # VerifyExists, LoadShelves
└── toml.go # TOML read/write, atomic writes, config creation

internal/web/
└── web.go # OpenURL, WebsiteTitle, LoadWebsite
└── web.go # OpenURL, WebsiteTitle

internal/theme/
└── theme.go # Theme loading from JSON, color/style resolution, huh theme generation
├── theme.go # Theme loading from JSON, color/style resolution, huh theme generation
└── uiconfig.go # UIConfig (embeds *book.Config + Theme/Templates), StyledError

internal/model/
├── tea.go # Shared TUI types: Styles, Book, RootScreen, errMsg
Expand All @@ -43,10 +47,10 @@ internal/model/

| Package | Imports | Does NOT import |
|---------|---------|-----------------|
| `internal/book` | stdlib + `theme` | `internal/catalog`, `internal/model` |
| `internal/theme` | `huh`, `lipgloss`, `json` | `internal/book`, `internal/catalog`, `internal/model` |
| `internal/web` | `book`, `goquery`, `huh`, `lipgloss`, `spinner` | `internal/catalog`, `internal/model` |
| `internal/catalog` | `book`, `toml`, `huh`, `lipgloss`, `spinner`, `theme` | `internal/model` |
| `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` | — |
| `cmd` | everything | — |

Expand All @@ -66,7 +70,7 @@ All blocking I/O must happen inside `tea.Cmd` closures, not in `Update()`. File

### Error Handling

Return errors rather than silently falling back. The codebase favors colloquial error messages (e.g., "betta check yerself") — keep the tone, but always propagate the error to the caller. `StyledError` renders a styled banner in interactive mode; plain text in non-interactive mode.
Return errors rather than silently falling back. The codebase favors colloquial error messages (e.g., "betta check yerself") — keep the tone, but always propagate the error to the caller. `theme.UIConfig.StyledError` renders a styled banner in interactive mode; plain text in non-interactive mode.

### TOML Tags

Expand Down
21 changes: 12 additions & 9 deletions cmd/book/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,25 @@ import (

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

func testConfig(t *testing.T) *book.Config {
func testConfig(t *testing.T) *theme.UIConfig {
t.Helper()
tmp := t.TempDir()
return &book.Config{
CatalogFormat: "toml",
ShelfRoot: tmp,
ConfigFile: filepath.Join(tmp, "config"),
return &theme.UIConfig{
Config: &book.Config{
CatalogFormat: "toml",
ShelfRoot: tmp,
ConfigFile: filepath.Join(tmp, "config"),
},
}
}

func loadShelves(t *testing.T, config *book.Config) *book.BookShelves {
func loadShelves(t *testing.T, config *theme.UIConfig) *book.BookShelves {
t.Helper()
var bs book.BookShelves
if err := catalog.LoadShelves(&bs, config); err != nil {
if err := catalog.LoadShelves(&bs, config.Config); err != nil {
t.Fatalf("load shelves: %v", err)
}
return &bs
Expand All @@ -39,7 +42,7 @@ func testShelf(t *testing.T, bs *book.BookShelves, name string) *book.Shelf {
return shelf
}

func seedShelf(t *testing.T, config *book.Config, name, collection string) *book.BookShelves {
func seedShelf(t *testing.T, config *theme.UIConfig, name, collection string) *book.BookShelves {
t.Helper()
bs := &book.BookShelves{}
if err := addShelf(bs, name, "test shelf", config); err != nil {
Expand Down Expand Up @@ -211,7 +214,7 @@ func TestGetMark(t *testing.T) {
}

// Missing both should error in non-interactive mode.
if err := getMark(bs, "", "", "", &book.Config{Interactive: false}); err == nil {
if err := getMark(bs, "", "", "", &theme.UIConfig{Config: &book.Config{Interactive: false}}); err == nil {
t.Error("expected error when id and url are empty")
}

Expand Down
119 changes: 119 additions & 0 deletions cmd/book/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"

"github.com/BurntSushi/toml"

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

// dumpDefaults serialises the built-in theme or template defaults as indented JSON.
func dumpDefaults(config *book.Config, dump string) (err error) {
var jsonData []byte
var (
tmpPath,
writePath string
)

switch dump {
case "theme":
writePath, err = catalog.ResolveWritePath(config.ThemeFile) // = not :=
if err != nil {
return err
}
tmpPath = writePath + ".tmp"

if jsonData, err = json.MarshalIndent(theme.DefaultThemeConfig(), "", " "); err != nil {
return err
}
case "template":
writePath, err = catalog.ResolveWritePath(config.TemplateFile) // = not :=
if err != nil {
return err
}
tmpPath = writePath + ".tmp"

if jsonData, err = json.MarshalIndent(book.DefaultViewTemplates, "", " "); err != nil {
return err
}
}

if !config.Autoconfirm {
return fmt.Errorf("set --confirm to create %s file %s", dump, tmpPath)
}

if exists, _ := catalog.VerifyExists(writePath); exists {
return os.ErrExist
}

f, err := os.Create(tmpPath)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
if err != nil {
_ = os.Remove(tmpPath)
}
}()

if _, err = f.Write(jsonData); err != nil {
return err
}

return os.Rename(tmpPath, writePath)
}

// printConfigSources prints rendered configuration when config file is present
func printConfigSources(config *theme.UIConfig) error {
var fileCfg book.FileConfig
if _, err := toml.DecodeFile(config.ConfigFile, &fileCfg); err != nil {
return err
}

heading := config.Theme.Style("highlight").Render("book config file exists - rendered configuration")
fmt.Println(heading)
fmt.Println()

w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
_, _ = fmt.Fprintln(w, "FIELD\tFILE VALUE\tEFFECTIVE")
_, _ = fmt.Fprintln(w, "-----\t----------\t---------")

// bool helper
printBool := func(name string, fileVal *bool, effVal bool) {
var f string
if fileVal == nil {
f = "<unset>"
} else {
f = fmt.Sprintf("%t", *fileVal)
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%t\n", name, f, effVal)
}

printBool("autoconfirm", fileCfg.Autoconfirm, config.Autoconfirm)
printBool("interactive", fileCfg.Interactive, config.Interactive)

// string helper
printString := func(name, fileVal, effVal string) {
if fileVal == "" {
fileVal = "<unset>"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", name, fileVal, effVal)
}

printString("catalog_format", fileCfg.CatalogFormat, config.CatalogFormat)
printString("shelf_directory", fileCfg.ShelfRoot, config.ShelfRoot)
printString("theme_file", fileCfg.ThemeFile, config.ThemeFile)
printString("template_file", fileCfg.TemplateFile, config.TemplateFile)

_ = w.Flush()
return nil
}
11 changes: 6 additions & 5 deletions cmd/book/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import (
"github.com/polymorcodeus/book/internal/book"
"github.com/polymorcodeus/book/internal/catalog"
"github.com/polymorcodeus/book/internal/model"
"github.com/polymorcodeus/book/internal/theme"
)

func collections(cache *indexCache, bs *book.BookShelves, shelfName string, format string, config *book.Config) error {
func collections(cache *indexCache, bs *book.BookShelves, shelfName string, format string, config *theme.UIConfig) error {
// Non-interactive path: all required flags provided
if shelfName != "" && !config.Interactive {
idx, err := cache.sync(config)
idx, err := cache.sync(config.Config)
if err != nil {
return err
}
Expand All @@ -25,13 +26,13 @@ func collections(cache *indexCache, bs *book.BookShelves, shelfName string, form
}
return nil
}
return book.PrintCatalog(names, format)
return printCatalog(names, format)
}

return runProgram(collectionRootScreen(bs, "list", config))
}

func addCollection(bs *book.BookShelves, shelfName, collectionName, description string, config *book.Config) error {
func addCollection(bs *book.BookShelves, shelfName, collectionName, description string, config *theme.UIConfig) error {
if err := requireFlags("shelf", shelfName, "name", collectionName); err != nil {
if !config.Interactive {
return err
Expand Down Expand Up @@ -77,7 +78,7 @@ func removeCollection(bs *book.BookShelves, shelfName, collectionName string, co
return catalog.UpdateShelfFile(shelf)
}

func collectionRootScreen(bs *book.BookShelves, action string, config *book.Config) model.RootScreen {
func collectionRootScreen(bs *book.BookShelves, action string, config *theme.UIConfig) model.RootScreen {
return model.RootScreen{
Model: model.GetCollectionForm(bs, config, action),
}
Expand Down
41 changes: 22 additions & 19 deletions cmd/book/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/polymorcodeus/book/internal/book"
"github.com/polymorcodeus/book/internal/catalog"
"github.com/polymorcodeus/book/internal/model"
"github.com/polymorcodeus/book/internal/theme"
)

var (
Expand Down Expand Up @@ -53,7 +54,7 @@ func Main() {
var interactive bool
var format string

var config *book.Config
var config *theme.UIConfig
var configFile string
var themeFile string
var templateFile string
Expand Down Expand Up @@ -163,14 +164,16 @@ func Main() {
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
config = &book.Config{
ConfigFile: configFile,
ThemeFile: themeFile,
TemplateFile: templateFile,
CatalogFormat: catalogFormat,
ShelfRoot: shelfDir,
Autoconfirm: confirm,
Interactive: interactive,
config = &theme.UIConfig{
Config: &book.Config{
ConfigFile: configFile,
ThemeFile: themeFile,
TemplateFile: templateFile,
CatalogFormat: catalogFormat,
ShelfRoot: shelfDir,
Autoconfirm: confirm,
Interactive: interactive,
},
}

// Load theme-file (uses built-in defaults if file doesn't exist), use Huhbase as fallback
Expand Down Expand Up @@ -204,7 +207,7 @@ func Main() {
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 {
if err := loadCatalog(&bookShelves, config.Config, config.Interactive); err != nil {
return ctx, cli.Exit(config.StyledError(err), 1)
}
}
Expand Down Expand Up @@ -576,7 +579,7 @@ func Main() {
Name: "migrate",
Usage: "migrate shelf TOML files from v1 to v2 schema",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := migrate(config); err != nil {
if err := migrate(config.Config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -594,7 +597,7 @@ func Main() {
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := gc(cache, config, retentionDays); err != nil {
if err := gc(cache, config.Config, retentionDays); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -612,7 +615,7 @@ func Main() {
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := doctor(cache, config, fix); err != nil {
if err := doctor(cache, config.Config, fix); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -626,7 +629,7 @@ func Main() {
Name: "rebuild",
Usage: "wipe and rebuild the index from shelf TOML files",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := runIndexRebuild(cache, config); err != nil {
if err := runIndexRebuild(cache, config.Config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -636,7 +639,7 @@ func Main() {
Name: "sync",
Usage: "reconcile the index with changes to shelf TOML files",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := runIndexSync(cache, config); err != nil {
if err := runIndexSync(cache, config.Config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -652,7 +655,7 @@ func Main() {
Name: "theme",
Usage: "creates theme.json for TUI customization from default values",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := catalog.DumpDefaults(config, "theme"); err != nil {
if err := dumpDefaults(config.Config, "theme"); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -662,7 +665,7 @@ func Main() {
Name: "template",
Usage: "creates template.json for TUI customization from default values",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := catalog.DumpDefaults(config, "template"); err != nil {
if err := dumpDefaults(config.Config, "template"); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return nil
Expand All @@ -674,14 +677,14 @@ func Main() {
Action: func(ctx context.Context, cmd *cli.Command) error {
// Create config-file if one does not exist
if exists, err := catalog.VerifyExists(config.ConfigFile); !exists {
if err := catalog.EnsureConfig(config); err != nil {
if err := catalog.EnsureConfig(config.Config); err != nil {
return cli.Exit(config.StyledError(err), 1)
}
fmt.Printf("%s - created.", config.ConfigFile)
} else if err != nil {
return cli.Exit(config.StyledError(err), 1)
}
return catalog.PrintConfigSources(config)
return printConfigSources(config)
},
},
},
Expand Down
Loading
Loading