/* Copyright © 2026 Hector Alfaro */ package cmd import ( "fmt" "os" "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" "tangled.org/hectorsector.com/books/cli/internal/store" ) type PublishEvent struct { Title string Kind string // "book" or "status" Outcome string // "created", "skipped", or "error" Message string // populated on error } // publishCmd represents the publish command 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 { fmt.Fprintf(os.Stderr, "getting pds from handle %s:\n%s", handle, apperrors.FormatError(err)) os.Exit(1) } appPassword, _ := cmd.Flags().GetString("app-password") session, err := atproto.CreateSession(pds, handle, appPassword) if err != nil { fmt.Fprintf(os.Stderr, "creating session for %s:\n%s", handle, apperrors.FormatError(err)) os.Exit(1) } storePath, _ := cmd.Root().PersistentFlags().GetString("store") // **Load the store** — `store.Load(storePath)` after creating the session books, err := store.Load(storePath) if err != nil { 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] } // `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) }, } func publishBookRecords(session atproto.Session, books []store.Book, storePath string) ([]PublishEvent, error) { var pEvents []PublishEvent // guards against books that haven't been enriched with OLWorkID booksMissingIDs := store.Filter(books, store.Not(store.HasOLWorkID(""))) 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 { return nil, fmt.Errorf("listing book records: %w", err) } index := make(map[string]atproto.RepoRecord) for _, record := range existingRecords { if record.Value.OLWorkID != "" { index[record.Value.OLWorkID] = record } else if record.Value.ISBN13 != "" { index[record.Value.ISBN13] = record } } // loops over unpublished books, builds a `BookRecord` for i, book := range books { // guards against books that have already been published if book.BookURI != "" { pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "book", Outcome: "skipped", }) continue } // guards against PDS records and local store being out of synch _, ok := index[book.OLWorkID] if !ok { _, ok = index[book.ISBN] } if ok { pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "book", Outcome: "skipped", Message: "record exists on PDS but missing from local store", }) continue } record := atproto.BookRecord{ OLWorkID: book.OLWorkID, ISBN13: book.ISBN, CreatedAt: book.DateAdded.Format(time.RFC3339), } // **Call `CreateRecord`** with `"com.hectorsector.book.book"` and the record recordResponse, err := atproto.CreateRecord(session, "com.hectorsector.book.book", record) if err != nil { pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "book", Outcome: "error", Message: "couldn't create record: " + err.Error(), }) continue } books[i].BookURI = recordResponse.URI books[i].BookCID = recordResponse.CID // **Persist immediately after each success** — // set `book.BookURI` and `book.BookCID` on the store book, 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) } pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "book", Outcome: "created", }) } return pEvents, nil } func publishStatusRecords(session atproto.Session, books []store.Book, storePath string) ([]PublishEvent, error) { var pEvents []PublishEvent for _, book := range books { // guards against books that haven't been published if book.BookURI == "" { pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "status", Outcome: "skipped", Message: "this book hasn't yet been published, so status won't be either", }) continue } // guards against statuses that have already been published if book.StatusURI != "" { pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "status", Outcome: "skipped", }) continue } // resolve the timestamp // use LastDateRead for the status update createdAt := book.DateAdded if book.LastDateRead != nil { createdAt = *book.LastDateRead } record := atproto.StatusRecord{ Book: atproto.StrongRef{ URI: book.BookURI, CID: book.BookCID, }, Status: book.LexiconStatus, CreatedAt: createdAt.Format(time.RFC3339), } recordResponse, err := atproto.CreateRecord(session, "com.hectorsector.book.status", record) if err != nil { pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "status", Outcome: "error", Message: "couldn't create record: " + err.Error(), }) continue } books, err = updateStoreRecord(storePath, books, book, "", "", recordResponse.URI) if err != nil { return pEvents, fmt.Errorf("saving store after %q: %s", book.Title, err) } pEvents = append(pEvents, PublishEvent{ Title: book.Title, Kind: "status", Outcome: "created", }) } return pEvents, nil } func updateStoreRecord(storePath string, books []store.Book, targetBook store.Book, bookURI, bookCID, statusURI string) ([]store.Book, error) { for i, storedBook := range books { if (storedBook.OLWorkID == targetBook.OLWorkID) || (storedBook.ISBN == targetBook.ISBN) { if bookURI != "" { books[i].BookURI = bookURI } if bookCID != "" { books[i].BookCID = bookCID } if statusURI != "" { books[i].StatusURI = statusURI } break } } err := store.Save(storePath, books) fmt.Println("💾 Store updated.") return books, err } func init() { rootCmd.AddCommand(publishCmd) // 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) } } return createdC, skippedC, errorC }