From cc7e6e120bc2c9b7a79aaff0565464efca78cb00 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Tue, 22 Sep 2026 16:33:49 -0500 Subject: [PATCH] feat: decouple pkg/book in place - phase 1 of public API --- CONTRIBUTING.md | 22 +++--- cmd/book/actions_test.go | 21 ++--- cmd/book/catalog.go | 119 +++++++++++++++++++++++++++++ cmd/book/collection.go | 11 +-- cmd/book/main.go | 41 +++++----- cmd/book/mark.go | 35 ++++----- cmd/book/print.go | 17 +++++ cmd/book/shelf.go | 13 ++-- cmd/book/spinner.go | 51 +++++++++++++ internal/book/types.go | 110 +++++--------------------- internal/book/types_test.go | 48 ++++++++++++ internal/catalog/catalog.go | 22 ------ internal/catalog/toml.go | 114 +-------------------------- internal/model/collection_model.go | 7 +- internal/model/mark_model.go | 7 +- internal/model/shelf_model.go | 9 ++- internal/model/tea.go | 3 +- internal/theme/uiconfig.go | 89 +++++++++++++++++++++ internal/theme/uiconfig_test.go | 31 ++++++++ internal/web/web.go | 21 ----- 20 files changed, 468 insertions(+), 323 deletions(-) create mode 100644 cmd/book/catalog.go create mode 100644 cmd/book/print.go create mode 100644 cmd/book/spinner.go create mode 100644 internal/theme/uiconfig.go create mode 100644 internal/theme/uiconfig_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5043c40..ddf8360 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 | — | @@ -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 diff --git a/cmd/book/actions_test.go b/cmd/book/actions_test.go index 026e387..4170c6b 100644 --- a/cmd/book/actions_test.go +++ b/cmd/book/actions_test.go @@ -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 @@ -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 { @@ -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") } diff --git a/cmd/book/catalog.go b/cmd/book/catalog.go new file mode 100644 index 0000000..ddb538c --- /dev/null +++ b/cmd/book/catalog.go @@ -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 = "" + } 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 = "" + } + _, _ = 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 +} diff --git a/cmd/book/collection.go b/cmd/book/collection.go index 776dc5e..53b3eea 100644 --- a/cmd/book/collection.go +++ b/cmd/book/collection.go @@ -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 } @@ -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 @@ -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), } diff --git a/cmd/book/main.go b/cmd/book/main.go index 82f144d..a9b65e2 100644 --- a/cmd/book/main.go +++ b/cmd/book/main.go @@ -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 ( @@ -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 @@ -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 @@ -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) } } @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) }, }, }, diff --git a/cmd/book/mark.go b/cmd/book/mark.go index cac51c5..ce29c35 100644 --- a/cmd/book/mark.go +++ b/cmd/book/mark.go @@ -8,10 +8,11 @@ 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" "github.com/polymorcodeus/book/internal/web" ) -func getMark(bs *book.BookShelves, id, url, format string, config *book.Config) error { +func getMark(bs *book.BookShelves, id, url, format string, config *theme.UIConfig) error { if id == "" && url == "" { if !config.Interactive { return fmt.Errorf("missing required flag: --id or --url") @@ -36,13 +37,13 @@ func getMark(bs *book.BookShelves, id, url, format string, config *book.Config) fmt.Println(target.FullDetail()) return nil } - return book.PrintCatalog(target, format) + return printCatalog(target, format) } -func marks(cache *indexCache, bs *book.BookShelves, shelfName string, collectionName string, format string, trash bool, config *book.Config) error { +func marks(cache *indexCache, bs *book.BookShelves, shelfName string, collectionName string, format string, trash bool, config *theme.UIConfig) error { // Trash listing reads the derived index and is always non-interactive. if trash { - idx, err := cache.sync(config) + idx, err := cache.sync(config.Config) if err != nil { return err } @@ -52,9 +53,9 @@ func marks(cache *indexCache, bs *book.BookShelves, shelfName string, collection } switch format { case "toml": - return book.PrintCatalog(catalog.SearchResultList{Marks: deleted}, format) + return printCatalog(catalog.SearchResultList{Marks: deleted}, format) case "json": - return book.PrintCatalog(deleted, format) + return printCatalog(deleted, format) default: for _, r := range deleted { fmt.Printf("%s %s\n", r.Title, r.URL) @@ -65,7 +66,7 @@ func marks(cache *indexCache, bs *book.BookShelves, shelfName string, collection // Non-interactive path: all required flags provided if shelfName != "" && collectionName != "" && !config.Interactive { - idx, err := cache.sync(config) + idx, err := cache.sync(config.Config) if err != nil { return err } @@ -82,12 +83,12 @@ func marks(cache *indexCache, bs *book.BookShelves, shelfName string, collection } return nil } - return book.PrintCatalog(collection, format) + return printCatalog(collection, format) } return runProgram(markRootScreen(bs, nil, "list", config)) } -func editMark(bs *book.BookShelves, id, title, tags, url string, config *book.Config) error { +func editMark(bs *book.BookShelves, id, title, tags, url string, config *theme.UIConfig) error { if err := requireFlag("id", id); err != nil { if !config.Interactive { return err @@ -120,13 +121,13 @@ func editMark(bs *book.BookShelves, id, title, tags, url string, config *book.Co return catalog.UpdateShelfFile(target.Shelf) } -func searchMarks(cache *indexCache, query string, tags string, shelfName string, collectionName string, format string, config *book.Config) error { +func searchMarks(cache *indexCache, query string, tags string, shelfName string, collectionName string, format string, config *theme.UIConfig) error { clauses, err := book.ParseTagFilter(tags) if err != nil { return err } - idx, err := cache.sync(config) + idx, err := cache.sync(config.Config) if err != nil { return err } @@ -138,9 +139,9 @@ func searchMarks(cache *indexCache, query string, tags string, shelfName string, switch format { case "json": - return book.PrintCatalog(results, format) + return printCatalog(results, format) case "toml": - return book.PrintCatalog(catalog.SearchResultList{Marks: results}, format) + return printCatalog(catalog.SearchResultList{Marks: results}, format) default: for _, r := range results { fmt.Printf("%s %s\n", r.Title, r.URL) @@ -149,7 +150,7 @@ func searchMarks(cache *indexCache, query string, tags string, shelfName string, } } -func addMark(ctx context.Context, bs *book.BookShelves, URL string, tags string, shelfName string, collectionName string, title string, config *book.Config) error { +func addMark(ctx context.Context, bs *book.BookShelves, URL string, tags string, shelfName string, collectionName string, title string, config *theme.UIConfig) error { mark, err := book.NewMarkFromInput(URL, book.SplitTags(tags)) if err != nil { return err @@ -163,7 +164,7 @@ func addMark(ctx context.Context, bs *book.BookShelves, URL string, tags string, // Use provided title or fetch from URL fetched := book.TitleFetchResult{} if title == "" { - fetchedTitle, err := web.LoadWebsite(ctx, mark.URL) + fetchedTitle, err := loadWebsite(ctx, mark.URL) if err != nil { if !errors.Is(err, web.ErrTitleUnavailable) { return fmt.Errorf("load website: %w", err) @@ -201,7 +202,7 @@ func addMark(ctx context.Context, bs *book.BookShelves, URL string, tags string, return runProgram(markRootScreen(bs, &mark, "add", config)) } -func removeMark(bs *book.BookShelves, id string, confirmed bool, config *book.Config) error { +func removeMark(bs *book.BookShelves, id string, confirmed bool, config *theme.UIConfig) error { if err := requireFlag("id", id); err != nil { if !config.Interactive { return err @@ -266,7 +267,7 @@ func clearSoftDelete(target *book.Mark) error { return catalog.UpdateShelfFile(target.Shelf) } -func markRootScreen(bs *book.BookShelves, mark *book.Mark, action string, config *book.Config) model.RootScreen { +func markRootScreen(bs *book.BookShelves, mark *book.Mark, action string, config *theme.UIConfig) model.RootScreen { screen := model.GetMarkForm(bs, mark, config, action) return model.RootScreen{Model: &screen} } diff --git a/cmd/book/print.go b/cmd/book/print.go new file mode 100644 index 0000000..535361b --- /dev/null +++ b/cmd/book/print.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "fmt" + + "github.com/polymorcodeus/book/internal/book" +) + +// printCatalog marshals an item in the given format and prints it. +func printCatalog[T any](item T, format string) error { + data, err := book.MarshalCatalog(item, format) + if err != nil { + return err + } + fmt.Print(string(data)) + return nil +} diff --git a/cmd/book/shelf.go b/cmd/book/shelf.go index bb26c79..2f1ce41 100644 --- a/cmd/book/shelf.go +++ b/cmd/book/shelf.go @@ -7,11 +7,12 @@ 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 shelves(cache *indexCache, bs *book.BookShelves, format string, config *book.Config) error { +func shelves(cache *indexCache, bs *book.BookShelves, format string, config *theme.UIConfig) error { if !config.Interactive { - idx, err := cache.sync(config) + idx, err := cache.sync(config.Config) if err != nil { return err } @@ -25,13 +26,13 @@ func shelves(cache *indexCache, bs *book.BookShelves, format string, config *boo } return nil } - return book.PrintCatalog(names, format) + return printCatalog(names, format) } return runProgram(shelfRootScreen(bs, "list", config)) } -func addShelf(bs *book.BookShelves, name, description string, config *book.Config) error { +func addShelf(bs *book.BookShelves, name, description string, config *theme.UIConfig) error { if err := requireFlag("name", name); err != nil { if !config.Interactive { return err @@ -47,7 +48,7 @@ func addShelf(bs *book.BookShelves, name, description string, config *book.Confi if err != nil { return err } - shelf.AddFileDetail(config) + shelf.AddFileDetail(config.Config) if err := catalog.UpdateShelfFile(shelf); err != nil { return err } @@ -75,7 +76,7 @@ func removeShelf(bs *book.BookShelves, name string, confirmed bool) error { return nil } -func shelfRootScreen(bs *book.BookShelves, action string, config *book.Config) model.RootScreen { +func shelfRootScreen(bs *book.BookShelves, action string, config *theme.UIConfig) model.RootScreen { return model.RootScreen{ Model: model.GetShelfForm(bs, config, action), } diff --git a/cmd/book/spinner.go b/cmd/book/spinner.go new file mode 100644 index 0000000..b7c26b7 --- /dev/null +++ b/cmd/book/spinner.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + "time" + + "charm.land/huh/v2/spinner" + + "github.com/polymorcodeus/book/internal/book" + "github.com/polymorcodeus/book/internal/catalog" + "github.com/polymorcodeus/book/internal/web" +) + +// loadCatalog loads shelves with an optional spinner when running interactively. +func loadCatalog(bs *book.BookShelves, config *book.Config, interactive bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if !interactive { + return catalog.LoadShelves(bs, config) + } + + return spinner.New(). + Context(ctx). + ActionWithErr(func(context.Context) error { + time.Sleep(1 * time.Second) + return catalog.LoadShelves(bs, config) + }). + Title("Loading your bookshelves ..."). + Run() +} + +// loadWebsite fetches a page title with a spinner and 10-second timeout. The +// provided context is honoured and capped at 10 seconds. +func loadWebsite(ctx context.Context, url string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + var title string + var err error + + return title, spinner.New(). + Context(ctx). + ActionWithErr(func(context.Context) error { + title, err = web.WebsiteTitle(ctx, url) + return err + }). + Title("Loading mark title ..."). + Type(spinner.Line). + Run() +} diff --git a/internal/book/types.go b/internal/book/types.go index 64a708b..305528c 100644 --- a/internal/book/types.go +++ b/internal/book/types.go @@ -1,24 +1,20 @@ -// Package book data models, catalog theme and templates, and their methods +// Package book data models, catalog templates, and their methods package book import ( + "bytes" "crypto/sha256" "encoding/json" "fmt" - "maps" "net/url" - "os" "path/filepath" "slices" "strings" "time" "github.com/BurntSushi/toml" - "github.com/polymorcodeus/book/internal/theme" ) -const errorBullet string = "󰯷" // "nf-md-alpha_e_box_outline - // TOMLFile defines exportable TOML files type TOMLFile interface { FileDetail() string @@ -26,83 +22,13 @@ type TOMLFile interface { // Config holds internal application configuration settings, including loaded files type Config struct { - CatalogFormat string `toml:"catalog_format"` - ShelfRoot string `toml:"shelf_directory"` - Autoconfirm bool `toml:"autoconfirm"` // edit to bypass --confirm for non-interactive adds - Interactive bool `toml:"interactive"` // edit to bypass TUI - false by default - ConfigFile string `toml:"-"` // path to config file, typical BOOK_CONFIG - ThemeFile string `toml:"theme_file"` // path to theme file, typical BOOK_THEME - TemplateFile string `toml:"template_file"` // path to theme file, typical BOOK_TEMPLATE - Theme *theme.Theme `toml:"-"` // loaded at run time - Templates map[string]ViewTemplate `toml:"-"` // loaded at run time -} - -// LoadTheme loads the theme file or falls back to defaults. -func (cfg *Config) LoadTheme(interactive bool) error { - cfg.Theme = theme.NewTheme(&theme.ThemeConfig{}) - - // load theme file or use defaults for TUI/interactive features - if interactive { - raw, err := theme.LoadThemeConfig(cfg.ThemeFile) - if err != nil { - return err - } - cfg.Theme = theme.NewTheme(raw) // nil raw = defaults - } - return nil -} - -// LoadTemplates loads user template overrides on top of built-in defaults. -func (cfg *Config) LoadTemplates() error { - cfg.Templates = make(map[string]ViewTemplate) - - // Start with defaults - maps.Copy(cfg.Templates, DefaultViewTemplates) - - data, err := os.ReadFile(cfg.TemplateFile) - if os.IsNotExist(err) { - // Not an error — user hasn't customized, defaults are fine - return nil - } - if err != nil { - return fmt.Errorf("read templates: %w", err) - } - - var userTmpls map[string]ViewTemplate - if err := json.Unmarshal(data, &userTmpls); err != nil { - return fmt.Errorf("parse templates: %w", err) - } - - // Overlay user partials onto defaults - for k, user := range userTmpls { - base, ok := cfg.Templates[k] - if !ok { - // Unknown key — skip or warn - continue - } - if user.PrimaryTitle != "" { - base.PrimaryTitle = user.PrimaryTitle - } - if user.SecondaryTitle != "" { - base.SecondaryTitle = user.SecondaryTitle - } - if user.ListTitle != "" { - base.ListTitle = user.ListTitle - } - cfg.Templates[k] = base - } - - return nil -} - -// StyledError returns a styled error string for interactive mode, or plain text otherwise. -func (cfg *Config) StyledError(e error) string { - if !cfg.Interactive { - return e.Error() - } - // return styled error only in interactive mode - return cfg.Theme.Style("highlight").Render("HEAVENS TO MURGATROYD!") + "\n" + - cfg.Theme.Style("error").Render(errorBullet, e.Error()) + CatalogFormat string `toml:"catalog_format"` + ShelfRoot string `toml:"shelf_directory"` + Autoconfirm bool `toml:"autoconfirm"` // edit to bypass --confirm for non-interactive adds + Interactive bool `toml:"interactive"` // edit to bypass TUI - false by default + ConfigFile string `toml:"-"` // path to config file, typical BOOK_CONFIG + ThemeFile string `toml:"theme_file"` // path to theme file, typical BOOK_THEME + TemplateFile string `toml:"template_file"` // path to theme file, typical BOOK_TEMPLATE } // FileConfig holds externally writable application configuration settings @@ -615,21 +541,19 @@ func ParseTagFilter(input string) ([][]string, error) { return result, nil } -// PrintCatalog serializes an item as JSON or TOML to stdout. -func PrintCatalog[T any](item T, format string) error { +// MarshalCatalog serializes an item as JSON or TOML. +func MarshalCatalog[T any](item T, format string) ([]byte, error) { switch format { case "json": - jsonData, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - fmt.Print(string(jsonData)) + return json.MarshalIndent(item, "", " ") case "toml": - if err := toml.NewEncoder(os.Stdout).Encode(item); err != nil { - return err + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(item); err != nil { + return nil, err } + return buf.Bytes(), nil } - return nil + return nil, nil } // NewShelf creates a new v2 shelf with the given name and description. diff --git a/internal/book/types_test.go b/internal/book/types_test.go index 7343e2e..bdd1a61 100644 --- a/internal/book/types_test.go +++ b/internal/book/types_test.go @@ -901,3 +901,51 @@ func TestUpdateMarkClearsTags(t *testing.T) { t.Errorf("Tags = %v, want still empty after nil", m.Tags) } } + +func TestMarshalCatalog(t *testing.T) { + item := struct { + Name string `json:"name" toml:"name"` + Tags []string `json:"tags" toml:"tags"` + }{Name: "example", Tags: []string{"a", "b"}} + + tests := []struct { + name string + format string + want string + wantNil bool + }{ + { + name: "json", + format: "json", + want: "{\n \"name\": \"example\",\n \"tags\": [\n \"a\",\n \"b\"\n ]\n}", + }, + { + name: "toml", + format: "toml", + want: "name = \"example\"\ntags = [\"a\", \"b\"]\n", + }, + { + name: "empty format returns nil", + format: "", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MarshalCatalog(item, tt.format) + if err != nil { + t.Fatalf("MarshalCatalog error: %v", err) + } + if tt.wantNil { + if got != nil { + t.Errorf("got %q, want nil", got) + } + return + } + if string(got) != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index e80c930..e6476c6 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -3,12 +3,9 @@ package catalog import ( - "context" "fmt" "path/filepath" - "time" - "charm.land/huh/v2/spinner" "github.com/BurntSushi/toml" "github.com/polymorcodeus/book/internal/book" @@ -37,22 +34,3 @@ func LoadShelves(bs *book.BookShelves, config *book.Config) error { bs.LoadParents() return nil } - -// LoadCatalog loads shelves with an optional spinner when running interactively. -func LoadCatalog(bs *book.BookShelves, config *book.Config, interactive bool) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if !interactive { - return LoadShelves(bs, config) - } - - return spinner.New(). - Context(ctx). - ActionWithErr(func(context.Context) error { - time.Sleep(1 * time.Second) - return LoadShelves(bs, config) - }). - Title("Loading your bookshelves ..."). - Run() -} diff --git a/internal/catalog/toml.go b/internal/catalog/toml.go index 3e367a4..1880fe8 100644 --- a/internal/catalog/toml.go +++ b/internal/catalog/toml.go @@ -1,17 +1,14 @@ package catalog import ( - "encoding/json" "errors" "fmt" "os" "path/filepath" - "text/tabwriter" "github.com/BurntSushi/toml" "github.com/polymorcodeus/book/internal/book" - "github.com/polymorcodeus/book/internal/theme" ) // VerifyExists reports whether a file or directory exists at the given path. @@ -32,7 +29,7 @@ func CreateTOML(t book.TOMLFile) (err error) { path := t.FileDetail() // Resolve symlinks so we write to the real target, not replace the link. - writePath, err := resolveWritePath(path) + writePath, err := ResolveWritePath(path) if err != nil { return err } @@ -113,56 +110,10 @@ func EnsureConfig(c *book.Config) error { return CreateTOML(fileCfg) } -// PrintConfigSources prints rendered configuration when config file is present -func PrintConfigSources(config *book.Config) 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 = "" - } 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 = "" - } - _, _ = 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 -} - -// resolveWritePath returns the real filesystem path that should be written to. +// ResolveWritePath returns the real filesystem path that should be written to. // If path is a symlink, it follows the link and also resolves any directory // symlinks in the parent directories of the target. -func resolveWritePath(path string) (string, error) { +func ResolveWritePath(path string) (string, error) { // Anchor to an absolute path first. If the path is relative, // filepath.Dir will return "." and relative symlink targets // will be resolved against the wrong base. @@ -228,62 +179,3 @@ func resolveWritePath(path string) (string, error) { } return filepath.Join(realDir, filepath.Base(current)), nil } - -// 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 = 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 = 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, _ := 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) -} diff --git a/internal/model/collection_model.go b/internal/model/collection_model.go index 9830833..c5eea66 100644 --- a/internal/model/collection_model.go +++ b/internal/model/collection_model.go @@ -10,13 +10,14 @@ import ( "github.com/polymorcodeus/book/internal/book" "github.com/polymorcodeus/book/internal/catalog" + "github.com/polymorcodeus/book/internal/theme" ) type collectionModel struct { book *Book shelf *book.Shelf collection *book.Collection - config *book.Config + config *theme.UIConfig } type getCollectionModel struct { @@ -133,7 +134,7 @@ func (m getCollectionModel) Error() error { } // GetCollectionForm to be used for editing descriptions/names in future -func GetCollectionForm(bs *book.BookShelves, config *book.Config, action string) getCollectionModel { +func GetCollectionForm(bs *book.BookShelves, config *theme.UIConfig, action string) getCollectionModel { m := collectionModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates @@ -319,7 +320,7 @@ func (m editCollectionModel) Error() error { return m.editor.book.err } -func editCollectionForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Config, action string) editCollectionModel { +func editCollectionForm(bs *book.BookShelves, shelf *book.Shelf, config *theme.UIConfig, action string) editCollectionModel { m := collectionModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates diff --git a/internal/model/mark_model.go b/internal/model/mark_model.go index 0f1b88b..8b7485d 100644 --- a/internal/model/mark_model.go +++ b/internal/model/mark_model.go @@ -11,6 +11,7 @@ import ( "github.com/polymorcodeus/book/internal/book" "github.com/polymorcodeus/book/internal/catalog" + "github.com/polymorcodeus/book/internal/theme" "github.com/polymorcodeus/book/internal/web" ) @@ -19,7 +20,7 @@ type markModel struct { shelf *book.Shelf collection *book.Collection mark *book.Mark - config *book.Config + config *theme.UIConfig } func (m markModel) verifyCollection() bool { @@ -252,7 +253,7 @@ func (m getMarkModel) Error() error { } // GetMarkForm returns a TUI model for navigating shelves, collections, and marks. -func GetMarkForm(bs *book.BookShelves, mark *book.Mark, config *book.Config, action string) getMarkModel { +func GetMarkForm(bs *book.BookShelves, mark *book.Mark, config *theme.UIConfig, action string) getMarkModel { m := markModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates @@ -467,7 +468,7 @@ func (m editMarkModel) Error() error { return m.editor.book.err } -func editMarkForm(bs *book.BookShelves, mark *book.Mark, config *book.Config, action string) editMarkModel { +func editMarkForm(bs *book.BookShelves, mark *book.Mark, config *theme.UIConfig, action string) editMarkModel { m := markModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates diff --git a/internal/model/shelf_model.go b/internal/model/shelf_model.go index e7d948b..a4cf1ab 100644 --- a/internal/model/shelf_model.go +++ b/internal/model/shelf_model.go @@ -9,13 +9,14 @@ import ( "charm.land/lipgloss/v2" "github.com/polymorcodeus/book/internal/book" "github.com/polymorcodeus/book/internal/catalog" + "github.com/polymorcodeus/book/internal/theme" ) type shelfModel struct { book *Book shelf *book.Shelf collection *book.Collection - config *book.Config + config *theme.UIConfig } type getShelfModel struct { @@ -113,7 +114,7 @@ func (m getShelfModel) Error() error { } // GetShelfForm to be used for editing descriptions/names in future -func GetShelfForm(bs *book.BookShelves, config *book.Config, action string) getShelfModel { +func GetShelfForm(bs *book.BookShelves, config *theme.UIConfig, action string) getShelfModel { m := shelfModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates @@ -221,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) + shelf.AddFileDetail(newM.editor.config.Config) newM.editor.shelf = shelf newM.editor.collection = collection } @@ -291,7 +292,7 @@ func (m editShelfModel) Error() error { return m.editor.book.err } -func editShelfForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Config, action string) editShelfModel { +func editShelfForm(bs *book.BookShelves, shelf *book.Shelf, config *theme.UIConfig, action string) editShelfModel { m := shelfModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates diff --git a/internal/model/tea.go b/internal/model/tea.go index d790af6..147e521 100644 --- a/internal/model/tea.go +++ b/internal/model/tea.go @@ -9,6 +9,7 @@ import ( "charm.land/huh/v2" "charm.land/lipgloss/v2" "github.com/polymorcodeus/book/internal/book" + "github.com/polymorcodeus/book/internal/theme" ) const maxWidth = 120 @@ -35,7 +36,7 @@ type Styles struct { } // NewStyles builds a Styles instance from the current theme configuration. -func NewStyles(config *book.Config) *Styles { +func NewStyles(config *theme.UIConfig) *Styles { s := Styles{} s.Base = lipgloss.NewStyle(). Padding(1, 4, 0, 1) diff --git a/internal/theme/uiconfig.go b/internal/theme/uiconfig.go new file mode 100644 index 0000000..00ee74a --- /dev/null +++ b/internal/theme/uiconfig.go @@ -0,0 +1,89 @@ +package theme + +import ( + "encoding/json" + "fmt" + "maps" + "os" + + "github.com/polymorcodeus/book/internal/book" +) + +const errorBullet string = "󰯷" // "nf-md-alpha_e_box_outline + +// UIConfig carries runtime presentation state (theme, templates) alongside +// the embedded storage configuration. The CLI and TUI layers work with +// UIConfig; storage calls receive the embedded *book.Config. +type UIConfig struct { + *book.Config + Theme *Theme + Templates map[string]book.ViewTemplate +} + +// LoadTheme loads the theme file or falls back to defaults. +func (cfg *UIConfig) LoadTheme(interactive bool) error { + cfg.Theme = NewTheme(&ThemeConfig{}) + + // load theme file or use defaults for TUI/interactive features + if interactive { + raw, err := LoadThemeConfig(cfg.ThemeFile) + if err != nil { + return err + } + cfg.Theme = NewTheme(raw) // nil raw = defaults + } + return nil +} + +// LoadTemplates loads user template overrides on top of built-in defaults. +func (cfg *UIConfig) LoadTemplates() error { + cfg.Templates = make(map[string]book.ViewTemplate) + + // Start with defaults + maps.Copy(cfg.Templates, book.DefaultViewTemplates) + + data, err := os.ReadFile(cfg.TemplateFile) + if os.IsNotExist(err) { + // Not an error — user hasn't customized, defaults are fine + return nil + } + if err != nil { + return fmt.Errorf("read templates: %w", err) + } + + var userTmpls map[string]book.ViewTemplate + if err := json.Unmarshal(data, &userTmpls); err != nil { + return fmt.Errorf("parse templates: %w", err) + } + + // Overlay user partials onto defaults + for k, user := range userTmpls { + base, ok := cfg.Templates[k] + if !ok { + // Unknown key — skip or warn + continue + } + if user.PrimaryTitle != "" { + base.PrimaryTitle = user.PrimaryTitle + } + if user.SecondaryTitle != "" { + base.SecondaryTitle = user.SecondaryTitle + } + if user.ListTitle != "" { + base.ListTitle = user.ListTitle + } + cfg.Templates[k] = base + } + + return nil +} + +// StyledError returns a styled error string for interactive mode, or plain text otherwise. +func (cfg *UIConfig) StyledError(e error) string { + if !cfg.Interactive { + return e.Error() + } + // return styled error only in interactive mode + return cfg.Theme.Style("highlight").Render("HEAVENS TO MURGATROYD!") + "\n" + + cfg.Theme.Style("error").Render(errorBullet, e.Error()) +} diff --git a/internal/theme/uiconfig_test.go b/internal/theme/uiconfig_test.go new file mode 100644 index 0000000..0a3f10d --- /dev/null +++ b/internal/theme/uiconfig_test.go @@ -0,0 +1,31 @@ +package theme + +import ( + "errors" + "strings" + "testing" + + "github.com/polymorcodeus/book/internal/book" +) + +func TestUIConfigStyledError(t *testing.T) { + t.Run("plain in non-interactive mode", func(t *testing.T) { + cfg := &UIConfig{Config: &book.Config{Interactive: false}} + got := cfg.StyledError(errors.New("boom")) + if got != "boom" { + t.Errorf("got %q, want %q", got, "boom") + } + }) + + t.Run("styled in interactive mode", func(t *testing.T) { + cfg := &UIConfig{Config: &book.Config{Interactive: true}} + cfg.Theme = NewTheme(nil) + got := cfg.StyledError(errors.New("boom")) + if !strings.Contains(got, "HEAVENS TO MURGATROYD!") { + t.Errorf("styled error missing header: %q", got) + } + if !strings.Contains(got, "boom") { + t.Errorf("styled error missing message: %q", got) + } + }) +} diff --git a/internal/web/web.go b/internal/web/web.go index d507f03..b2a85e9 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "charm.land/huh/v2/spinner" "github.com/PuerkitoBio/goquery" ) @@ -67,23 +66,3 @@ func WebsiteTitle(ctx context.Context, url string) (string, error) { return "", fmt.Errorf("unchecked error: %d", res.StatusCode) } } - -// LoadWebsite fetches a page title with a spinner and 10-second timeout. The -// provided context is honoured and capped at 10 seconds. -func LoadWebsite(ctx context.Context, url string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - var title string - var err error - - return title, spinner.New(). - Context(ctx). - ActionWithErr(func(context.Context) error { - title, err = WebsiteTitle(ctx, url) - return err - }). - Title("Loading mark title ..."). - Type(spinner.Line). - Run() -}