From f1beba00a03c081421456a1da7e3093676f749a3 Mon Sep 17 00:00:00 2001 From: hectorsector Date: Fri, 15 May 2026 12:04:53 -0400 Subject: [PATCH] clean publishing, record store, env reading --- cli/cmd/enrich.go | 15 ++-- cli/cmd/filters.go | 10 +++ cli/cmd/import.go | 4 +- cli/cmd/list.go | 5 +- cli/cmd/publish.go | 123 +++++++++++++++++++++----------- cli/cmd/root.go | 15 +++- cli/internal/atproto/atproto.go | 1 + cli/internal/store/store.go | 19 ++--- 8 files changed, 127 insertions(+), 65 deletions(-) diff --git a/cli/cmd/enrich.go b/cli/cmd/enrich.go index 032f07a..f810e0c 100644 --- a/cli/cmd/enrich.go +++ b/cli/cmd/enrich.go @@ -6,7 +6,6 @@ package cmd import ( "fmt" "os" - "path/filepath" "github.com/spf13/cobra" "tangled.org/hectorsector.com/books/cli/internal/apperrors" @@ -29,12 +28,11 @@ var enrichCmd = &cobra.Command{ } func runEnrich(storePath string) error { - absPath, _ := filepath.Abs(storePath) - - books, err := store.Load(absPath) + books, err := store.Load(storePath) if err != nil { - return fmt.Errorf("loading books from store at %s: %w", absPath, err) + return fmt.Errorf("loading books from store: %w", err) } + fmt.Println("šŸ“‚ Loaded from store.") for i := range books { fmt.Printf("Enriching: %s\n", books[i].Title) @@ -58,12 +56,13 @@ func runEnrich(storePath string) error { } } - err = store.Save(absPath, books) + err = store.Save(storePath, books) if err != nil { - return fmt.Errorf("saving to store %s: %w", absPath, err) + return fmt.Errorf("saving to store: %w", err) } + fmt.Println("šŸ’¾ Store updated.") - fmt.Println("\nšŸ‘€ You can review books that couldn't be matched with:\n books list --no-olworkid") + fmt.Println("šŸ‘€ You can review books that couldn't be matched with:\n books list --no-olworkid") return nil } diff --git a/cli/cmd/filters.go b/cli/cmd/filters.go index ec5cdaa..d8806ec 100644 --- a/cli/cmd/filters.go +++ b/cli/cmd/filters.go @@ -49,6 +49,16 @@ func filtersFromFlags(cmd *cobra.Command) []store.Predicate { predicates = append(predicates, store.Not(store.HasOLWorkID(""))) } + published, _ := cmd.Flags().GetBool("published") + if published { + predicates = append(predicates, store.IsPublished()) + } + + noPublished, _ := cmd.Flags().GetBool("not-published") + if noPublished { + predicates = append(predicates, store.Not(store.IsPublished())) + } + return predicates } diff --git a/cli/cmd/import.go b/cli/cmd/import.go index 05c147e..f0ffc46 100644 --- a/cli/cmd/import.go +++ b/cli/cmd/import.go @@ -6,7 +6,6 @@ package cmd import ( "fmt" "os" - "path/filepath" "strings" "github.com/spf13/cobra" @@ -43,9 +42,8 @@ var importCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "saving store:\n%s", apperrors.FormatError(err)) os.Exit(1) } + fmt.Println("šŸ’¾ Store updated.") - fullPath, _ := filepath.Abs(storePath) - fmt.Fprintf(cmd.OutOrStdout(), "āœ… Wrote store to %s\n", fullPath) }, } diff --git a/cli/cmd/list.go b/cli/cmd/list.go index c557894..bd2f47c 100644 --- a/cli/cmd/list.go +++ b/cli/cmd/list.go @@ -24,6 +24,7 @@ var listCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "loading store at %s:\n%s", storePath, apperrors.FormatError(err)) os.Exit(1) } + fmt.Println("šŸ“‚ Loaded from store.") books = store.Filter(books, filtersFromFlags(cmd)...) n, _ := cmd.Flags().GetInt("count") @@ -42,7 +43,7 @@ var listCmd = &cobra.Command{ func printFull(books []store.Book) { for _, b := range books { - fmt.Printf("%-40.40s %-25.25s %-12.12s %-13.13s %s\n", b.Title, b.Author, lexicon.DisplayStatus(b.LexiconStatus), b.ISBN, b.OLWorkID) + fmt.Printf("%-40.40s %-25.25s %-12.12s %-13.13s %-20.20s %-50.50s %s\n", b.Title, b.Author, lexicon.DisplayStatus(b.LexiconStatus), b.ISBN, b.OLWorkID, b.ATURI, b.StatusURI) } } @@ -67,4 +68,6 @@ func init() { listCmd.Flags().Bool("no-status", false, "filter books with no status") listCmd.Flags().String("olworkid", "", "filter by OL work ID") listCmd.Flags().Bool("no-olworkid", false, "filter books with no OL work ID") + listCmd.Flags().Bool("published", false, "filter books that have been published") + listCmd.Flags().Bool("not-published", false, "filter books that have not been published") } diff --git a/cli/cmd/publish.go b/cli/cmd/publish.go index 6e30434..1369726 100644 --- a/cli/cmd/publish.go +++ b/cli/cmd/publish.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/joho/godotenv" "github.com/spf13/cobra" "tangled.org/hectorsector.com/books/cli/internal/apperrors" "tangled.org/hectorsector.com/books/cli/internal/atproto" @@ -26,7 +27,28 @@ type PublishEvent struct { var publishCmd = &cobra.Command{ Use: "publish", Short: "Publishes book and status records to a user's PDS", + PreRunE: func(cmd *cobra.Command, args []string) error { + //fallbacks for env variables + godotenv.Load() + + handle, _ := cmd.Flags().GetString("handle") + if handle == "" { + cmd.Flags().Set("handle", os.Getenv("BSKY_HANDLE")) + } + + appPwd, _ := cmd.Flags().GetString("app-password") + if appPwd == "" { + envAppPwd := os.Getenv("BSKY_APP_PASSWORD") + if strings.HasPrefix(envAppPwd, "op://") { + return fmt.Errorf("BSKY_APP_PASSWORD is unresolved - run via\n op run --env-file=.env -- ./bin/books publish") + } + cmd.Flags().Set("app-password", envAppPwd) + } + + return nil + }, Run: func(cmd *cobra.Command, args []string) { + handle, _ := cmd.Flags().GetString("handle") pds, err := atproto.ResolveHandleToPDS(handle) if err != nil { @@ -42,8 +64,6 @@ var publishCmd = &cobra.Command{ os.Exit(1) } - fmt.Printf("Session:\n DID: %s\n JWT: %s", session.DID, session.AccessJwt) - storePath, _ := cmd.Root().PersistentFlags().GetString("store") // **Load the store** — `store.Load(storePath)` after creating the session books, err := store.Load(storePath) @@ -51,16 +71,35 @@ var publishCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "loading store at %s:\n%s", storePath, apperrors.FormatError(err)) os.Exit(1) } + fmt.Println("šŸ“‚ Loaded from store.") n, _ := cmd.Flags().GetInt("count") if n > 0 && n < len(books) { books = books[:n] } - // TODO: - // 3. Update the callers in `RunE`** - // `publishBookRecords` and `publishStatusRecords` are called around lines 53–63 of `publish.go`. Capture the returned slices, then loop over them to print per-book lines and accumulate counts for the tally. + // `publishBookRecords` and `publishStatusRecords` are called around lines 53–63 of `publish.go`. + // Capture the returned slices, then + // loop over them to print per-book lines and accumulate counts for the tally. + publishedBooks, err := publishBookRecords(session, books, storePath) + if err != nil { + fmt.Fprintf(os.Stderr, "publishing book.book records: %s", apperrors.FormatError(err)) + os.Exit(1) + } + + c, s, e := countAndPrintErrors(publishedBooks) + fmt.Printf("book.book:\t%d created, %d skipped, %d error\n", c, s, e) + + publishedStatuses, err := publishStatusRecords(session, books, storePath) + if err != nil { + fmt.Fprintf(os.Stderr, "publishing book.status records: %s", apperrors.FormatError(err)) + os.Exit(1) + } + c, s, e = countAndPrintErrors(publishedStatuses) + fmt.Printf("book.status:\t%d created, %d skipped, %d error\n", c, s, e) + + // TODO: // **4. Print the tally** // After both functions return, count events by outcome across both slices and print a summary like: // ``` @@ -71,18 +110,6 @@ var publishCmd = &cobra.Command{ // **5. Remove the session debug output** // Line 38 currently prints the DID and JWT — that's noise for a normal run. Remove it while you're in there. - err = publishBookRecords(session, books, storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "publishing book.book records: %s", apperrors.FormatError(err)) - os.Exit(1) - } - - err = publishStatusRecords(session, books, storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "publishing book.status records: %s", apperrors.FormatError(err)) - os.Exit(1) - } - }, } @@ -92,14 +119,17 @@ func publishBookRecords(session atproto.Session, books []store.Book, storePath s // guards against books that haven't been enriched with OLWorkID booksMissingIDs := store.Filter(books, store.Not(store.HasOLWorkID(""))) - if len(booksMissingIDs) > 0 { - var missingTitles []string - for _, missing := range booksMissingIDs { - missingTitles = append(missingTitles, missing.Title) - } - return nil, fmt.Errorf("Please run `books enrich` before attempting to publish.\nBooks without OLWorkID:\n%s", strings.Join(missingTitles, "\n")) + for _, missing := range booksMissingIDs { + pEvents = append(pEvents, PublishEvent{ + Title: missing.Title, + Kind: "book", + Outcome: "skipped", + Message: "missing OLWorkID - run `books enrich` first or couldn't resolve automatically", + }) } + books = store.Filter(books, store.HasOLWorkID("")) + // plumbing to get records for later guards existingRecords, err := atproto.ListBookRecords(session) if err != nil { @@ -167,7 +197,7 @@ func publishBookRecords(session atproto.Session, books []store.Book, storePath s // **Persist immediately after each success** — // set `book.ATURI` and `book.ATCid` on the store book, - err = updateStoreRecord(storePath, book, recordResponse.URI, recordResponse.CID, "") + books, err = updateStoreRecord(storePath, books, book, recordResponse.URI, recordResponse.CID, "") if err != nil { return pEvents, fmt.Errorf("saving store after %q: %s", book.Title, err) } @@ -234,7 +264,7 @@ func publishStatusRecords(session atproto.Session, books []store.Book, storePath continue } - err = updateStoreRecord(storePath, book, "", "", recordResponse.URI) + books, err = updateStoreRecord(storePath, books, book, "", "", recordResponse.URI) if err != nil { return pEvents, fmt.Errorf("saving store after %q: %s", book.Title, err) } @@ -250,12 +280,7 @@ func publishStatusRecords(session atproto.Session, books []store.Book, storePath return pEvents, nil } -func updateStoreRecord(storePath string, targetBook store.Book, aturi, atcid, statusURI string) error { - books, err := store.Load(storePath) - if err != nil { - return fmt.Errorf("Couldn't load store: %w", err) - } - +func updateStoreRecord(storePath string, books []store.Book, targetBook store.Book, aturi, atcid, statusURI string) ([]store.Book, error) { for i, storedBook := range books { if (storedBook.OLWorkID == targetBook.OLWorkID) || (storedBook.ISBN == targetBook.ISBN) { if aturi != "" { @@ -274,22 +299,36 @@ func updateStoreRecord(storePath string, targetBook store.Book, aturi, atcid, st } } - return store.Save(storePath, books) + err := store.Save(storePath, books) + fmt.Println("šŸ’¾ Store updated.") + + return books, err } func init() { rootCmd.AddCommand(publishCmd) - // Here you will define your flags and configuration settings. + // fallbacks happen in the PreRunE hook + publishCmd.Flags().String("handle", "", "the atproto handle to which to publish") + publishCmd.Flags().String("app-password", "", "bsky app password") + publishCmd.Flags().IntP("count", "n", 0, "limit to n books (0=all)") +} + +func countAndPrintErrors(publishEvents []PublishEvent) (int, int, int) { + var createdC, skippedC, errorC int + for _, pe := range publishEvents { + + switch pe.Outcome { + case "created": + createdC++ + case "skipped": + skippedC++ + case "error": + errorC++ + fmt.Fprintf(os.Stderr, "error [%s] %s: %s\n", pe.Kind, pe.Title, pe.Message) + } - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // publishCmd.PersistentFlags().String("foo", "", "A help for foo") + } - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // publishCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") - publishCmd.Flags().String("handle", os.Getenv("BSKY_HANDLE"), "the atproto handle to which to publish") - publishCmd.Flags().String("app-password", os.Getenv("BSKY_APP_PASSWORD"), "bsky app password") - publishCmd.Flags().IntP("count", "n", 0, "limit to n books (0=all)") + return createdC, skippedC, errorC } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index fcc5089..98e18e1 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -13,6 +13,16 @@ import ( var rootCmd = &cobra.Command{ Use: "books", Short: "Manage book and status data for atproto lexicon records", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // fallbacks for env variables + godotenv.Load() + + store, _ := cmd.Root().PersistentFlags().GetString("store") + if store == "" { + cmd.Root().PersistentFlags().Set("store", os.Getenv("STORE_PATH")) + } + return nil + }, } func Execute() { @@ -23,8 +33,7 @@ func Execute() { } func init() { - godotenv.Load() - os.UserHomeDir() - rootCmd.PersistentFlags().String("store", os.Getenv("STORE_PATH"), "Path to the local book storage") + // we set default path at runtime + rootCmd.PersistentFlags().String("store", "", "Path to the local book storage") } diff --git a/cli/internal/atproto/atproto.go b/cli/internal/atproto/atproto.go index 8ff14c7..2f82417 100644 --- a/cli/internal/atproto/atproto.go +++ b/cli/internal/atproto/atproto.go @@ -69,6 +69,7 @@ func CreateSession(pdsHost, handle, appPassword string) (Session, error) { } sesh.PDSHost = pdsHost + fmt.Printf("šŸ”Œ Session created for DID [%s] to host: %s\n", sesh.DID, sesh.PDSHost) // return a `Session` struct with `AccessJwt` and `DID`. return sesh, nil } diff --git a/cli/internal/store/store.go b/cli/internal/store/store.go index 60756a2..7b67879 100644 --- a/cli/internal/store/store.go +++ b/cli/internal/store/store.go @@ -24,7 +24,7 @@ type Book struct { type Predicate func(Book) bool -// loads book data from the store +// loads book data from the store, takes in a path (could be absolute or relative, will convert to absolute) func Load(path string) ([]Book, error) { absPath, _ := filepath.Abs(path) file, err := os.Open(absPath) @@ -37,7 +37,6 @@ func Load(path string) ([]Book, error) { json.NewDecoder(file).Decode(&books) - fmt.Printf("šŸ“‚ Loaded store from %s\n", absPath) return books, nil } @@ -50,11 +49,12 @@ func Save(path string, books []Book) error { } // create a tmp file - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + absPath, _ := filepath.Abs(path) + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { return fmt.Errorf("creating store directory: %w", err) } - tmpFile, err := os.CreateTemp(filepath.Dir(path), "*.tmp") + tmpFile, err := os.CreateTemp(filepath.Dir(absPath), "*.tmp") if err != nil { return fmt.Errorf("creating tmp file: %w", err) } @@ -66,14 +66,11 @@ func Save(path string, books []Book) error { } tmpFile.Close() - err = os.Rename(tmpFile.Name(), path) + err = os.Rename(tmpFile.Name(), absPath) if err != nil { return fmt.Errorf("renaming tmp file: %w", err) } - absPath, _ := filepath.Abs(path) - fmt.Printf("šŸ’¾ Saved store to %s\n", absPath) - return nil } @@ -159,6 +156,12 @@ func NeedsPublishing() Predicate { } } +func IsPublished() Predicate { + return func(book Book) bool { + return book.ATURI != "" + } +} + func HasISBN(isbn string) Predicate { return func(book Book) bool { if isbn == "" { -- 2.51.2