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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ cmd/book/
pkg/book/
├── types.go # Core data structs: Config, BookShelves, Shelf, Collection, Mark
├── doctor.go # MarkConflict, DetectDuplicates, ResolveDuplicates
└── templates.go # ViewTemplate, DefaultViewTemplates, Templatable helpers
└── templates.go # ViewTemplate, DefaultViewTemplates

pkg/catalog/
├── catalog.go # Paths, ShelfPath, VerifyExists, LoadShelves
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ 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.Title = "The Go Programming Language"
mark.Shelf = shelf
mark.Collection = collection
mark.RecordAdd()
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.6.1
v1.7.0
79 changes: 75 additions & 4 deletions cmd/book/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -10,6 +12,7 @@ import (
"github.com/polymorcodeus/book/internal/theme"
"github.com/polymorcodeus/book/pkg/book"
"github.com/polymorcodeus/book/pkg/catalog"
"github.com/polymorcodeus/book/pkg/web"
)

func testConfig(t *testing.T) *theme.UIConfig {
Expand Down Expand Up @@ -181,8 +184,8 @@ func TestAddMark(t *testing.T) {
t.Fatalf("got %d marks, want 1", len(collection.Marks))
}
mark := collection.Marks[0]
if mark.Name != "Example" {
t.Errorf("Name = %q, want Example", mark.Name)
if mark.Title != "Example" {
t.Errorf("Title = %q, want Example", mark.Title)
}
if !strings.EqualFold(strings.Join(mark.Tags, ","), "go,cli") {
t.Errorf("Tags = %v, want [go cli]", mark.Tags)
Expand Down Expand Up @@ -239,8 +242,8 @@ func TestEditMark(t *testing.T) {

reloaded := loadShelves(t, config)
updated := testShelf(t, reloaded, "dev").Collection("docs").Marks[0]
if updated.Name != "Updated" {
t.Errorf("Name = %q, want Updated", updated.Name)
if updated.Title != "Updated" {
t.Errorf("Title = %q, want Updated", updated.Title)
}
if !strings.EqualFold(strings.Join(updated.Tags, ","), "go,cli") {
t.Errorf("Tags = %v, want [go cli]", updated.Tags)
Expand Down Expand Up @@ -324,3 +327,71 @@ func TestRequireFlags(t *testing.T) {
}
}
}

func TestWebsiteError(t *testing.T) {
notFound := websiteError(fmt.Errorf("fetch title: %w: %s", web.ErrNotFound, "https://example.com"), "https://example.com")
if !strings.Contains(notFound.Error(), "4oh4") {
t.Errorf("websiteError(ErrNotFound) = %q, want the 4oh4 quip", notFound)
}
if !strings.Contains(notFound.Error(), "https://example.com") {
t.Errorf("websiteError(ErrNotFound) = %q, want the url", notFound)
}

other := websiteError(errors.New("connection refused"), "https://example.com")
if strings.Contains(other.Error(), "4oh4") {
t.Errorf("websiteError(other) = %q, want a plain wrapped error", other)
}
if !strings.Contains(other.Error(), "load website:") {
t.Errorf("websiteError(other) = %q, want the load website context", other)
}
}

func TestUniqueURLError(t *testing.T) {
trashed := &book.Mark{ID: "abc12345", Title: "gone", URL: "https://example.com/trashed", DeletedAt: book.NowTimestamp()}
active := &book.Mark{ID: "def67890", Title: "here", URL: "https://example.com/active"}
bs := book.BookShelves{{
Name: "dev",
Collections: map[string]*book.Collection{
"docs": {Name: "docs", Marks: []*book.Mark{trashed, active}},
},
}}

trashedErr := bs.VerifyUniqueURL("abc12345", nil)
if trashedErr == nil {
t.Fatal("VerifyUniqueURL(trashed) expected error")
}
got := uniqueURLError(&bs, "abc12345", trashedErr)
if !strings.Contains(got.Error(), "book mark restore --id abc12345") {
t.Errorf("uniqueURLError(trashed) = %q, want the restore command with id", got)
}
if !errors.Is(got, book.ErrURLTrashed) {
t.Errorf("uniqueURLError(trashed) lost the ErrURLTrashed chain: %v", got)
}

dupErr := bs.VerifyUniqueURL("def67890", nil)
if dupErr == nil {
t.Fatal("VerifyUniqueURL(active) expected error")
}
got = uniqueURLError(&bs, "def67890", dupErr)
if strings.Contains(got.Error(), "restore") {
t.Errorf("uniqueURLError(active) = %q, want no restore hint", got)
}
if !errors.Is(got, book.ErrDuplicateURL) {
t.Errorf("uniqueURLError(active) lost the ErrDuplicateURL chain: %v", got)
}
}

func TestTitleError(t *testing.T) {
required := titleError(fmt.Errorf("%w for %s", book.ErrTitleRequired, "https://example.com"))
if !strings.Contains(required.Error(), "--title") {
t.Errorf("titleError(ErrTitleRequired) = %q, want the --title hint", required)
}
if !errors.Is(required, book.ErrTitleRequired) {
t.Errorf("titleError(ErrTitleRequired) lost the chain: %v", required)
}

other := errors.New("boom")
if got := titleError(other); got.Error() != "boom" {
t.Errorf("titleError(other) = %q, want the error unchanged", got)
}
}
2 changes: 1 addition & 1 deletion cmd/book/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func printDoctorReport(r doctorReport) {
fmt.Printf(" %s %s\n", c.ID, c.URL)
for _, m := range c.Marks {
fmt.Printf(" - %s / %s title=%q tags=%v deleted=%t\n",
m.Shelf.Name, m.Collection.Name, m.Name, m.Tags, m.IsDeleted())
m.Shelf.Name, m.Collection.Name, m.Title, m.Tags, m.IsDeleted())
}
}
fmt.Println()
Expand Down
49 changes: 39 additions & 10 deletions cmd/book/mark.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func marks(cache *indexCache, bs *book.BookShelves, shelfName string, collection
if m.IsDeleted() {
continue
}
fmt.Printf("%s %s\n", m.Name, m.URL)
fmt.Printf("%s %s\n", m.Title, m.URL)
}
return nil
}
Expand Down Expand Up @@ -109,12 +109,13 @@ func editMark(bs *book.BookShelves, id, title, tags, url string, config *theme.U
if err := book.ValidateURL(url); err != nil {
return err
}
if err := bs.VerifyUniqueURL(book.GenerateID(url), target); err != nil {
return fmt.Errorf("verify unique url: %w", err)
id := book.GenerateID(url)
if err := bs.VerifyUniqueURL(id, target); err != nil {
return uniqueURLError(bs, id, err)
}
}

if err := target.UpdateMark(title, url, book.SplitTags(tags)); err != nil {
if err := target.Update(title, url, book.SplitTags(tags)); err != nil {
return err
}
target.Touch()
Expand Down Expand Up @@ -150,6 +151,33 @@ func searchMarks(cache *indexCache, query string, tags string, shelfName string,
}
}

func websiteError(err error, url string) error {
if errors.Is(err, web.ErrNotFound) {
return fmt.Errorf("betta check yerself - that's a 4oh4!\n%s", url)
}
return fmt.Errorf("load website: %w", err)
}

// uniqueURLError annotates a VerifyUniqueURL failure with the restore command
// when the collision is with a trashed mark.
func uniqueURLError(bs *book.BookShelves, id string, err error) error {
if errors.Is(err, book.ErrURLTrashed) {
if trashed := bs.SoftDeletedByID(id); trashed != nil {
return fmt.Errorf("verify unique url: %w\n\nrestore it with:\nbook mark restore --id %s", err, trashed.ID)
}
}
return fmt.Errorf("verify unique url: %w", err)
}

// titleError annotates a ResolveMarkTitle failure with the flag a
// non-interactive caller needs to supply the title manually.
func titleError(err error) error {
if errors.Is(err, book.ErrTitleRequired) {
return fmt.Errorf("%w; provide --title", err)
}
return err
}

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 {
Expand All @@ -158,25 +186,26 @@ func addMark(ctx context.Context, bs *book.BookShelves, URL string, tags string,

// Ensure URL hash not in bookshelves
if err := bs.VerifyUniqueURL(mark.ID, nil); err != nil {
return fmt.Errorf("verify unique url: %w", err)
return uniqueURLError(bs, mark.ID, err)
}

// Use provided title or fetch from URL
fetched := book.TitleFetchResult{}
if title == "" {
fetchedTitle, err := loadWebsite(ctx, mark.URL)
if err != nil {
if !errors.Is(err, web.ErrTitleUnavailable) {
return fmt.Errorf("load website: %w", err)
if errors.Is(err, web.ErrTitleUnavailable) {
fetched.Unavailable = true
} else {
return websiteError(err, mark.URL)
}
fetched.Unavailable = true
} else {
fetched.Title = fetchedTitle
}
}
mark.Name, err = book.ResolveMarkTitle(title, mark.URL, fetched, config.Interactive)
mark.Title, err = book.ResolveMarkTitle(title, mark.URL, fetched, config.Interactive)
if err != nil {
return err
return titleError(err)
}

// Non-interactive path: all required flags provided
Expand Down
12 changes: 9 additions & 3 deletions internal/model/collection_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ func (m getCollectionModel) ResultView() string {
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
return renderCompletedView(m.get.book.styles, m.get.book.tmpls, "collection-list", viewData{
Primary: m.get.shelf.Name,
List: m.get.shelf.CollectionNames(),
}).Content
}

// Error returns the terminal error, if any, for the caller to surface after
Expand Down Expand Up @@ -166,7 +169,7 @@ func GetCollectionForm(bs *book.BookShelves, config *theme.UIConfig, action stri
if shelf == nil {
return []huh.Option[string]{}
}
return huh.NewOptions(shelf.CollectionsNames()...)
return huh.NewOptions(shelf.CollectionNames()...)
}, &chosenShelf).
Key("collection").
Value(&chosenCollection),
Expand Down Expand Up @@ -311,7 +314,10 @@ func (m editCollectionModel) ResultView() string {
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
return renderCompletedView(m.editor.book.styles, m.editor.book.tmpls, "collection-add", viewData{
Primary: m.editor.collection.Shelf.Name,
Secondary: m.editor.collection.Name,
}).Content
}

// Error returns the terminal error, if any, for the caller to surface after
Expand Down
44 changes: 33 additions & 11 deletions internal/model/mark_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type markModel struct {

func (m markModel) verifyCollection() bool {
collection := m.book.form.GetString("collection")
validCollections := m.shelf.CollectionsNames()
validCollections := m.shelf.CollectionNames()

return slices.Contains(validCollections, collection)
}
Expand All @@ -35,7 +35,7 @@ func (m markModel) verifyMark() bool {

// Still needed for custom banner title
if m.collection != nil {
validMarks := m.collection.MarksNames()
validMarks := m.collection.MarkNames()
return slices.Contains(validMarks, mark)
}
return false
Expand Down Expand Up @@ -199,7 +199,7 @@ func (m getMarkModel) View() tea.View {
displayCollection = m.get.collection.Name

if m.get.verifyMark() {
displayMark = m.get.mark.Name + "\n\n" + m.get.mark.URL + "\n\n" + lipglossList(s.None, m.get.mark.Tags) + "\n"
displayMark = m.get.mark.Title + "\n\n" + m.get.mark.URL + "\n\n" + lipglossList(s.None, m.get.mark.Tags) + "\n"
}
}
}
Expand Down Expand Up @@ -227,6 +227,24 @@ func (m getMarkModel) View() tea.View {
return altScreenView(s.Base.Render(header + "\n" + body + "\n\n" + footer))
}

// markViewData builds the render data for a mark success screen. The parent
// section (the shelf and collection the mark belongs to) is rendered above the
// mark itself.
func markViewData(m *book.Mark) viewData {
data := viewData{Primary: m.Title, Secondary: m.URL, List: m.Tags}
if m.Shelf != nil || m.Collection != nil {
parent := &viewData{}
if m.Shelf != nil {
parent.Primary = m.Shelf.Name
}
if m.Collection != nil {
parent.Secondary = m.Collection.Name
}
data.Parent = parent
}
return data
}

// ResultView returns the completion output for the caller to print after the
// program exits.
func (m getMarkModel) ResultView() string {
Expand All @@ -237,11 +255,15 @@ func (m getMarkModel) ResultView() string {
t := m.get.book.tmpls
switch m.action {
case "get":
return renderCompletedView(s, t, "mark-get", m.get.mark).Content
return renderCompletedView(s, t, "mark-get", markViewData(m.get.mark)).Content
case "list":
return renderCompletedView(s, t, "mark-list", m.get.collection).Content
return renderCompletedView(s, t, "mark-list", viewData{
Primary: m.get.collection.Shelf.Name,
Secondary: m.get.collection.Name,
List: m.get.collection.MarkNames(),
}).Content
case "delete":
return renderCompletedView(s, t, "mark-delete", m.get.mark).Content
return renderCompletedView(s, t, "mark-delete", markViewData(m.get.mark)).Content
}
return ""
}
Expand Down Expand Up @@ -311,7 +333,7 @@ func GetMarkForm(bs *book.BookShelves, mark *book.Mark, config *theme.UIConfig,
if collection == nil {
return []huh.Option[string]{}
}
opts := collection.MarksNames()
opts := collection.MarkNames()
return huh.NewOptions(opts...)
}, &chosenCollection).
Key("mark").
Expand Down Expand Up @@ -425,7 +447,7 @@ func (m editMarkModel) View() tea.View {
currentShelf = s.StatusHeader.Render("Picked Shelf") + "\n" + shelf + "\n\n"
currentCollection = s.StatusHeader.Render("Picked Collection") + "\n" + m.editor.mark.Collection.Name + "\n\n"

currentMark = s.StatusHeader.Render("Editing Mark") + "\n" + m.editor.mark.Name
currentMark = s.StatusHeader.Render("Editing Mark") + "\n" + m.editor.mark.Title
currentMark += "\n\n" + m.editor.mark.URL + "\n\n" + lipglossList(s.None, m.editor.mark.Tags) + "\n"

status = m.editor.book.statusPanel(form, currentShelf+currentCollection+currentMark, 28)
Expand Down Expand Up @@ -455,9 +477,9 @@ func (m editMarkModel) ResultView() string {
t := m.editor.book.tmpls
switch m.action {
case "add":
return renderCompletedView(s, t, "mark-add", m.editor.mark).Content
return renderCompletedView(s, t, "mark-add", markViewData(m.editor.mark)).Content
case "edit":
return renderCompletedView(s, t, "mark-edit", m.editor.mark).Content
return renderCompletedView(s, t, "mark-edit", markViewData(m.editor.mark)).Content
}
return ""
}
Expand Down Expand Up @@ -485,7 +507,7 @@ func editMarkForm(bs *book.BookShelves, mark *book.Mark, config *theme.UIConfig,
huh.NewText().
Title("Review title.").
Key("markTitle").
Value(&m.mark.Name).
Value(&m.mark.Title).
WithWidth(25).
WithHeight(3),

Expand Down
9 changes: 7 additions & 2 deletions internal/model/shelf_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ func (m getShelfModel) ResultView() string {
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
return renderCompletedView(m.get.book.styles, m.get.book.tmpls, "shelf-list", viewData{
List: m.get.book.shelves.ShelfNames(),
}).Content
}

// Error returns the terminal error, if any, for the caller to surface after
Expand Down Expand Up @@ -296,7 +298,10 @@ func (m editShelfModel) ResultView() string {
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
return renderCompletedView(m.editor.book.styles, m.editor.book.tmpls, "shelf-add", viewData{
Primary: m.editor.collection.Shelf.Name,
Secondary: m.editor.collection.Name,
}).Content
}

// Error returns the terminal error, if any, for the caller to surface after
Expand Down
Loading
Loading