diff --git a/VERSION b/VERSION index 2e7bd91..53b5bbb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.5.0 +v1.5.1 diff --git a/cmd/book/main.go b/cmd/book/main.go index 981a3ce..82f144d 100644 --- a/cmd/book/main.go +++ b/cmd/book/main.go @@ -703,6 +703,11 @@ func runProgram(screen model.RootScreen) error { if err != nil { return err } + if ep, ok := m.(model.ErrorProvider); ok { + if err := ep.Error(); err != nil { + return err + } + } if rp, ok := m.(model.ResultProvider); ok { if v := rp.ResultView(); v != "" { fmt.Print(v) diff --git a/internal/book/types.go b/internal/book/types.go index 1de4635..64a708b 100644 --- a/internal/book/types.go +++ b/internal/book/types.go @@ -577,6 +577,18 @@ func (bs *BookShelves) ValidateNewShelfName(name string) error { return nil } +// ValidateNewCollectionName returns an error if name is empty or already in +// use within the shelf. +func (s *Shelf) ValidateNewCollectionName(name string) error { + if name == "" { + return fmt.Errorf("collection name is required") + } + if _, ok := s.Collections[name]; ok { + return fmt.Errorf("collection already exists") + } + return nil +} + // 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 diff --git a/internal/model/collection_model.go b/internal/model/collection_model.go index fd33028..9830833 100644 --- a/internal/model/collection_model.go +++ b/internal/model/collection_model.go @@ -24,6 +24,12 @@ type getCollectionModel struct { action string } +func (m getCollectionModel) reloadFromForm() getCollectionModel { + shelf, _ := m.get.book.shelves.Shelf(m.get.book.form.GetString("shelf")) + m.get.shelf = shelf + return m +} + func (m getCollectionModel) Init() tea.Cmd { return m.get.book.form.Init() } @@ -33,17 +39,12 @@ func (m getCollectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.get.book.width = min(msg.Width, maxWidth) - m.get.book.styles.Base.GetHorizontalFrameSize() case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+p": - return m, m.get.book.form.PrevGroup() - case "ctrl+c": - return m, tea.Interrupt - case "esc": - return m, tea.Quit + if cmd, handled := handleCommonKeys(m.get.book.form, msg); handled { + return m, cmd } case errMsg: m.get.book.err = msg - return m, nil + return m, tea.Quit } var cmds []tea.Cmd @@ -55,14 +56,13 @@ func (m getCollectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } - // Initialize display - if m.get.shelf == nil { - m.get.shelf = &book.Shelf{} + // Keep model state in sync with form selections. + if m.get.book.form.State != huh.StateCompleted { + m = m.reloadFromForm() } if m.get.book.form.State == huh.StateCompleted { - shelf, _ := m.get.book.shelves.Shelf(m.get.book.form.GetString("shelf")) - m.get.shelf = shelf + m = m.reloadFromForm() switch m.action { case "add": editScreen := editCollectionForm(m.get.book.shelves, m.get.shelf, m.get.config, m.action) @@ -120,12 +120,18 @@ func (m getCollectionModel) View() tea.View { // ResultView returns the completion output for the caller to print after the // program exits. func (m getCollectionModel) ResultView() string { - if m.get.book.form.State != huh.StateCompleted || m.action != "list" { + if m.get.book.form.State != huh.StateCompleted || m.get.book.err != nil || m.action != "list" { return "" } return renderCompletedView(m.get.book.styles, m.get.book.tmpls, "collection-list", m.get.shelf).Content } +// Error returns the terminal error, if any, for the caller to surface after +// the program exits. +func (m getCollectionModel) Error() error { + return m.get.book.err +} + // GetCollectionForm to be used for editing descriptions/names in future func GetCollectionForm(bs *book.BookShelves, config *book.Config, action string) getCollectionModel { m := collectionModel{book: &Book{width: 0}} @@ -210,23 +216,21 @@ func (m editCollectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.editor.book.width = min(msg.Width, maxWidth) - m.editor.book.styles.Base.GetHorizontalFrameSize() case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+z": + if msg.String() == "ctrl+z" { switch m.action { case "edit", "add": getScreen := GetCollectionForm(m.editor.book.shelves, m.editor.config, m.action) return getScreen, getScreen.Init() } - case "ctrl+c": - return m, tea.Interrupt - case "esc": - return m, tea.Quit + } + if cmd, handled := handleCommonKeys(m.editor.book.form, msg); handled { + return m, cmd } case shelfSavedMsg: return m, tea.Quit case errMsg: m.editor.book.err = msg - return m, nil + return m, tea.Quit } var cmds []tea.Cmd @@ -239,8 +243,21 @@ func (m editCollectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.editor.book.form.State == huh.StateCompleted { - m.editor.collection.Shelf = m.editor.shelf - cmds = append(cmds, m.editor.updateShelfFileCmd(m.action)) + newM := m + if m.action == "add" { + collection, err := book.NewCollection(m.editor.shelf, m.editor.collection.Name, m.editor.collection.Description) + if err != nil { + newM.editor.book.err = err + return newM, nil + } + newM.editor.collection = collection + } else { + newCollection := *m.editor.collection + newCollection.Shelf = m.editor.shelf + newM.editor.collection = &newCollection + } + cmds = append(cmds, newM.editor.updateShelfFileCmd(newM.action)) + return newM, tea.Batch(cmds...) } return m, tea.Batch(cmds...) @@ -290,17 +307,26 @@ func (m editCollectionModel) View() tea.View { // ResultView returns the completion output for the caller to print after the // program exits. func (m editCollectionModel) ResultView() string { - if m.editor.book.form.State != huh.StateCompleted { + if m.editor.book.form.State != huh.StateCompleted || m.editor.book.err != nil { return "" } return renderCompletedView(m.editor.book.styles, m.editor.book.tmpls, "collection-add", m.editor.collection).Content } +// Error returns the terminal error, if any, for the caller to surface after +// the program exits. +func (m editCollectionModel) Error() error { + return m.editor.book.err +} + func editCollectionForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Config, action string) editCollectionModel { m := collectionModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates m.book.shelves = bs + if shelf == nil { + shelf = &book.Shelf{} + } m.shelf = shelf m.collection = &book.Collection{} m.config = config @@ -315,6 +341,9 @@ func editCollectionForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Co huh.NewInput(). Title("Name of new collection?"). Description("This will be immortalized, be certain."). + Validate(func(s string) error { + return m.shelf.ValidateNewCollectionName(s) + }). Value(&m.collection.Name), huh.NewInput(). @@ -361,10 +390,6 @@ func (m *collectionModel) updateShelfFileCmd(action string) tea.Cmd { return func() tea.Msg { if action == "add" { m.shelf.AddCollection(m.collection) - - m.collection.ID = book.GenerateCollectionID(m.shelf.Name, m.collection.Name) - m.collection.CreatedAt = book.NowTimestamp() - m.collection.UpdatedAt = m.collection.CreatedAt m.collection.Touch() } if action == "edit" && m.collection.UpdatedAt != "" { diff --git a/internal/model/mark_model.go b/internal/model/mark_model.go index 09f4f89..0f1b88b 100644 --- a/internal/model/mark_model.go +++ b/internal/model/mark_model.go @@ -29,7 +29,7 @@ func (m markModel) verifyCollection() bool { return slices.Contains(validCollections, collection) } -func (m *markModel) verifyMark() bool { +func (m markModel) verifyMark() bool { mark := m.book.form.GetString("mark") // Still needed for custom banner title @@ -40,34 +40,47 @@ func (m *markModel) verifyMark() bool { return false } -func (m *markModel) reloadMarkModel() { - shelf := m.book.form.GetString("shelf") - collection := m.book.form.GetString("collection") - mark := m.book.form.GetString("mark") +func (m getMarkModel) reloadFromForm() getMarkModel { + shelfName := m.get.book.form.GetString("shelf") + collectionName := m.get.book.form.GetString("collection") + + shelf, _ := m.get.book.shelves.Shelf(shelfName) + m.get.shelf = shelf + if shelf == nil { + m.get.collection = nil + if m.action != "add" { + m.get.mark = nil + } + return m + } - s, _ := m.book.shelves.Shelf(shelf) - m.shelf = s - if s == nil { - m.collection = nil - m.mark = nil - return + m.get.collection = shelf.Collection(collectionName) + if m.get.collection == nil { + if m.action != "add" { + m.get.mark = nil + } + return m } - m.collection = s.Collection(collection) - if m.collection == nil { - m.mark = nil - return + + if m.action != "add" { + m.get.mark = m.get.collection.Mark(m.get.book.form.GetString("mark")) } - m.mark = m.collection.Mark(mark) + return m } -func (m *markModel) loadMarkParents() { +func (m markModel) withParents() markModel { + if m.mark == nil { + return m + } shelf, _ := m.book.shelves.Shelf(m.book.form.GetString("shelf")) - m.mark.Shelf = shelf if shelf == nil { - m.mark.Collection = nil - return + return m } - m.mark.Collection = shelf.Collection(m.book.form.GetString("collection")) + newMark := *m.mark + newMark.Shelf = shelf + newMark.Collection = shelf.Collection(m.book.form.GetString("collection")) + m.mark = &newMark + return m } func (m *markModel) updateShelfFileCmd(action string) tea.Cmd { @@ -101,20 +114,15 @@ func (m getMarkModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.get.book.width = min(msg.Width, maxWidth) - m.get.book.styles.Base.GetHorizontalFrameSize() - case tea.KeyMsg: - switch msg.String() { - case "ctrl+p": - return m, m.get.book.form.PrevGroup() - case "ctrl+c": - return m, tea.Interrupt - case "esc": - return m, tea.Quit + case tea.KeyPressMsg: + if cmd, handled := handleCommonKeys(m.get.book.form, msg); handled { + return m, cmd } case shelfSavedMsg: return m, tea.Quit case errMsg: m.get.book.err = msg - return m, nil + return m, tea.Quit } var cmds []tea.Cmd @@ -126,40 +134,20 @@ func (m getMarkModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } - // Initialize display - if m.get.shelf == nil { - m.get.shelf = &book.Shelf{} - } - - // Update model after form has been updated + // Keep model state in sync with form selections. if m.get.book.form.State != huh.StateCompleted { - if m.get.book.form.GetString("shelf") != "" { - shelf, _ := m.get.book.shelves.Shelf(m.get.book.form.GetString("shelf")) - m.get.shelf = shelf - } - - if m.get.verifyCollection() { - // Load valid collection into model to reset when shelf changes - m.get.collection = m.get.shelf.Collection(m.get.book.form.GetString("collection")) - - if m.get.verifyMark() { - // Load valid mark into model for Get|Edit|Delete - if m.get.mark == nil { - m.get.reloadMarkModel() - } - } - } + m = m.reloadFromForm() } if m.get.book.form.State == huh.StateCompleted { // Reload mark and model from final form fields if m.action != "add" { - m.get.reloadMarkModel() + m = m.reloadFromForm() } switch m.action { case "add", "edit": - m.get.loadMarkParents() + m.get = m.get.withParents() editScreen := editMarkForm(m.get.book.shelves, m.get.mark, m.get.config, m.action) return editScreen, editScreen.Init() case "get": @@ -210,8 +198,6 @@ func (m getMarkModel) View() tea.View { displayCollection = m.get.collection.Name if m.get.verifyMark() { - // Need to load selected mark for display - m.get.reloadMarkModel() displayMark = m.get.mark.Name + "\n\n" + m.get.mark.URL + "\n\n" + lipglossList(s.None, m.get.mark.Tags) + "\n" } } @@ -243,7 +229,7 @@ func (m getMarkModel) View() tea.View { // ResultView returns the completion output for the caller to print after the // program exits. func (m getMarkModel) ResultView() string { - if m.get.book.form.State != huh.StateCompleted { + if m.get.book.form.State != huh.StateCompleted || m.get.book.err != nil { return "" } s := m.get.book.styles @@ -259,6 +245,12 @@ func (m getMarkModel) ResultView() string { return "" } +// Error returns the terminal error, if any, for the caller to surface after +// the program exits. +func (m getMarkModel) Error() error { + return m.get.book.err +} + // 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 { m := markModel{book: &Book{width: 0}} @@ -367,9 +359,8 @@ func (m editMarkModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.editor.book.width = min(msg.Width, maxWidth) - m.editor.book.styles.Base.GetHorizontalFrameSize() - case tea.KeyMsg: - switch msg.String() { - case "ctrl+z": + case tea.KeyPressMsg: + if msg.String() == "ctrl+z" { switch m.action { case "edit": getScreen := GetMarkForm(m.editor.book.shelves, nil, m.editor.config, m.action) @@ -378,16 +369,15 @@ func (m editMarkModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { getScreen := GetMarkForm(m.editor.book.shelves, m.editor.mark, m.editor.config, m.action) return getScreen, getScreen.Init() } - case "ctrl+c": - return m, tea.Interrupt - case "esc": - return m, tea.Quit + } + if cmd, handled := handleCommonKeys(m.editor.book.form, msg); handled { + return m, cmd } case shelfSavedMsg: return m, tea.Quit case errMsg: m.editor.book.err = msg - return m, nil + return m, tea.Quit } var cmds []tea.Cmd @@ -400,7 +390,10 @@ func (m editMarkModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.editor.book.form.State == huh.StateCompleted { - cmds = append(cmds, m.editor.updateShelfFileCmd(m.action)) + newM := m + newM.editor = newM.editor.withParents() + cmds = append(cmds, newM.editor.updateShelfFileCmd(newM.action)) + return newM, tea.Batch(cmds...) } return m, tea.Batch(cmds...) @@ -454,7 +447,7 @@ func (m editMarkModel) View() tea.View { // ResultView returns the completion output for the caller to print after the // program exits. func (m editMarkModel) ResultView() string { - if m.editor.book.form.State != huh.StateCompleted { + if m.editor.book.form.State != huh.StateCompleted || m.editor.book.err != nil { return "" } s := m.editor.book.styles @@ -468,6 +461,12 @@ func (m editMarkModel) ResultView() string { return "" } +// Error returns the terminal error, if any, for the caller to surface after +// the program exits. +func (m editMarkModel) Error() error { + return m.editor.book.err +} + func editMarkForm(bs *book.BookShelves, mark *book.Mark, config *book.Config, action string) editMarkModel { m := markModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) diff --git a/internal/model/shelf_model.go b/internal/model/shelf_model.go index 4e7bc89..e7d948b 100644 --- a/internal/model/shelf_model.go +++ b/internal/model/shelf_model.go @@ -32,17 +32,12 @@ func (m getShelfModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.get.book.width = min(msg.Width, maxWidth) - m.get.book.styles.Base.GetHorizontalFrameSize() case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+p": - return m, m.get.book.form.PrevGroup() - case "ctrl+c": - return m, tea.Interrupt - case "esc": - return m, tea.Quit + if cmd, handled := handleCommonKeys(m.get.book.form, msg); handled { + return m, cmd } case errMsg: m.get.book.err = msg - return m, nil + return m, tea.Quit } var cmds []tea.Cmd @@ -54,11 +49,6 @@ func (m getShelfModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } - // Initialize display - if m.get.shelf == nil { - m.get.shelf = &book.Shelf{} - } - if m.get.book.form.State == huh.StateCompleted { switch m.action { case "add": @@ -87,13 +77,8 @@ func (m getShelfModel) View() tea.View { // Status (right side) var status string { - var ( - currentShelf string - ) - - if m.get.shelf != nil { - currentShelf = s.StatusHeader.Render("Picked Shelf") + "\n" + "fake" + "\n\n" - } + displayShelf := m.get.book.form.GetString("shelf") + currentShelf := lipglossDimmer(s.StatusHeader, "Picked Shelf", displayShelf) status = m.get.book.statusPanel(form, currentShelf, 14) } @@ -115,12 +100,18 @@ func (m getShelfModel) View() tea.View { // ResultView returns the completion output for the caller to print after the // program exits. func (m getShelfModel) ResultView() string { - if m.get.book.form.State != huh.StateCompleted || m.action != "list" { + if m.get.book.form.State != huh.StateCompleted || m.get.book.err != nil || m.action != "list" { return "" } return renderCompletedView(m.get.book.styles, m.get.book.tmpls, "shelf-list", m.get.book.shelves).Content } +// Error returns the terminal error, if any, for the caller to surface after +// the program exits. +func (m getShelfModel) Error() error { + return m.get.book.err +} + // GetShelfForm to be used for editing descriptions/names in future func GetShelfForm(bs *book.BookShelves, config *book.Config, action string) getShelfModel { m := shelfModel{book: &Book{width: 0}} @@ -190,23 +181,21 @@ func (m editShelfModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.editor.book.width = min(msg.Width, maxWidth) - m.editor.book.styles.Base.GetHorizontalFrameSize() case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+z": + if msg.String() == "ctrl+z" { switch m.action { - case "edit": + case "edit", "add": getScreen := GetShelfForm(m.editor.book.shelves, m.editor.config, m.action) return getScreen, getScreen.Init() } - case "ctrl+c": - return m, tea.Interrupt - case "esc": - return m, tea.Quit + } + if cmd, handled := handleCommonKeys(m.editor.book.form, msg); handled { + return m, cmd } case shelfSavedMsg: return m, tea.Quit case errMsg: m.editor.book.err = msg - return m, nil + return m, tea.Quit } var cmds []tea.Cmd @@ -219,8 +208,25 @@ func (m editShelfModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.editor.book.form.State == huh.StateCompleted { - m.editor.collection.Shelf = m.editor.shelf - cmds = append(cmds, m.editor.updateShelfFileCmd(m.action)) + newM := m + if m.action == "add" { + shelf, err := book.NewShelf(m.editor.shelf.Name, m.editor.shelf.Description) + if err != nil { + newM.editor.book.err = err + return newM, nil + } + collection, err := book.NewCollection(shelf, m.editor.collection.Name, m.editor.collection.Description) + if err != nil { + newM.editor.book.err = err + return newM, nil + } + shelf.AddCollection(collection) + shelf.AddFileDetail(newM.editor.config) + newM.editor.shelf = shelf + newM.editor.collection = collection + } + cmds = append(cmds, newM.editor.updateShelfFileCmd(newM.action)) + return newM, tea.Batch(cmds...) } return m, tea.Batch(cmds...) @@ -273,17 +279,26 @@ func (m editShelfModel) View() tea.View { // ResultView returns the completion output for the caller to print after the // program exits. func (m editShelfModel) ResultView() string { - if m.editor.book.form.State != huh.StateCompleted { + if m.editor.book.form.State != huh.StateCompleted || m.editor.book.err != nil { return "" } return renderCompletedView(m.editor.book.styles, m.editor.book.tmpls, "shelf-add", m.editor.collection).Content } +// Error returns the terminal error, if any, for the caller to surface after +// the program exits. +func (m editShelfModel) Error() error { + return m.editor.book.err +} + func editShelfForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Config, action string) editShelfModel { m := shelfModel{book: &Book{width: 0}} m.book.styles = NewStyles(config) m.book.tmpls = config.Templates m.book.shelves = bs + if shelf == nil { + shelf = &book.Shelf{} + } m.shelf = shelf m.collection = &book.Collection{} m.config = config @@ -317,6 +332,9 @@ func editShelfForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Config, huh.NewInput(). Title("Name of new collection?"). Description("This will be immortalized, be certain."). + Validate(func(s string) error { + return m.shelf.ValidateNewCollectionName(s) + }). Value(&m.collection.Name), huh.NewInput(). @@ -362,18 +380,6 @@ func editShelfForm(bs *book.BookShelves, shelf *book.Shelf, config *book.Config, func (m *shelfModel) updateShelfFileCmd(action string) tea.Cmd { return func() tea.Msg { if action == "add" { - m.shelf.AddCollection(m.collection) - m.shelf.AddFileDetail(m.config) - - now := book.NowTimestamp() - m.shelf.SchemaVersion = book.IntPtr(2) - m.shelf.ID = book.GenerateShelfID(m.shelf.Name) - m.shelf.CreatedAt = now - m.shelf.UpdatedAt = now - m.collection.ID = book.GenerateCollectionID(m.shelf.Name, m.collection.Name) - m.collection.CreatedAt = now - m.collection.UpdatedAt = now - if err := catalog.UpdateShelfFile(m.shelf); err != nil { return errMsg{err} } diff --git a/internal/model/tea.go b/internal/model/tea.go index 6de4dd8..d790af6 100644 --- a/internal/model/tea.go +++ b/internal/model/tea.go @@ -73,6 +73,20 @@ type errMsg struct{ error } type shelfSavedMsg struct{} +// handleCommonKeys routes shared navigation keys for all TUI forms. It returns +// the command to run and true if the key was handled. +func handleCommonKeys(form *huh.Form, msg tea.KeyPressMsg) (tea.Cmd, bool) { + switch msg.String() { + case "ctrl+c": + return tea.Interrupt, true + case "esc": + return tea.Quit, true + case "ctrl+p": + return form.PrevGroup(), true + } + return nil, false +} + // Book is the shared TUI state container used by all screen models. type Book struct { err error @@ -141,6 +155,12 @@ type ResultProvider interface { ResultView() string } +// ErrorProvider is implemented by models that carry a terminal error to be +// returned by the caller after the program exits. +type ErrorProvider interface { + Error() error +} + // altScreenView returns a view rendered in the alternate screen buffer. The // interactive form is drawn there and discarded on exit, leaving the main // screen clean for the caller to print the result.