From 1ea9c701d57673c375b16b328f01c285dfd05d29 Mon Sep 17 00:00:00 2001 From: hectorsector Date: Mon, 11 May 2026 12:09:02 -0400 Subject: [PATCH] create book.book and book.status records Loops over unpublished books, creates AT Proto records for each, and persists ATURI/ATCid/StatusURI back to the store after each success so partial runs are resumable. Adds -n flag to cap publish runs for testing. --- cli/cmd/publish.go | 129 +++++++++++++++++++++++++++++++++--- cli/internal/store/store.go | 9 ++- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/cli/cmd/publish.go b/cli/cmd/publish.go index 799d81e..5a2b266 100644 --- a/cli/cmd/publish.go +++ b/cli/cmd/publish.go @@ -6,10 +6,13 @@ package cmd import ( "fmt" "os" + "strings" + "time" "github.com/spf13/cobra" - "tangled.org/hectorsector.com/books/cli/internal/atproto" "tangled.org/hectorsector.com/books/cli/internal/apperrors" + "tangled.org/hectorsector.com/books/cli/internal/atproto" + "tangled.org/hectorsector.com/books/cli/internal/store" ) // publishCmd represents the publish command @@ -34,19 +37,126 @@ var publishCmd = &cobra.Command{ fmt.Printf("Session:\n DID: %s\n JWT: %s", session.DID, session.AccessJwt) - // TODO - // 1. **Load the store** — `store.Load(storePath)` after creating the session - // 2. **Filter to unpublished books** — add a `NotPublished` predicate to `store.go` that returns `book.ATURI == ""` - // 3. **Loop over unpublished books** — for each, build a `BookRecord` from the store book (`OLWorkID`, `ISBN` → `ISBN13`, `DateAdded` → `CreatedAt`) - // 4. **Call `CreateRecord`** with `"com.hectorsector.book.book"` and the record - // 5. **Persist immediately after each success** — set `book.ATURI` and `book.ATCid` on the store book, call `store.Save` inside the loop so a partial run is resumable + 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) + } + + n, _ := cmd.Flags().GetInt("count") + if n > 0 && n < len(books) { + books = books[:n] + } + + err = publishBookRecords(session, books, storePath) + if err != nil { + fmt.Fprintf(os.Stderr, "publishing book.book records: %s", apperrors.FormatError(err)) + os.Exit(1) + } -**In `store.go`:** -6. **Add `NotPublished` predicate** — `book.ATURI == "" + err = publishStatusRecords(session, books, storePath) + if err != nil { + fmt.Fprintf(os.Stderr, "publishing book.status records: %s", apperrors.FormatError(err)) + os.Exit(1) + } }, } +func publishBookRecords(session atproto.Session, books []store.Book, storePath string) error { + + var failed []string + + // **Loop over unpublished books** — for each, build a `BookRecord` from the store book (`OLWorkID`, `ISBN` → `ISBN13`, `DateAdded` → `CreatedAt`) + for i, book := range books { + if book.ATURI != "" { + 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 { + fmt.Fprintf(os.Stderr, "creating book.book record for %q: %s\n", book.Title, apperrors.FormatError(err)) + failed = append(failed, "Title: "+book.Title+"; OLWorkID: "+book.OLWorkID) + continue + } + + // **Persist immediately after each success** — + // set `book.ATURI` and `book.ATCid` on the store book, + books[i].ATURI = recordResponse.URI + books[i].ATCid = recordResponse.CID + + // call `store.Save` inside the loop so a partial run is resumable + err = store.Save(storePath, books) + if err != nil { + return fmt.Errorf("saving store after %q: %s", book.Title, err) + } + + fmt.Printf("✅ Published book.book record: %s\n", book.Title) + } + + if len(failed) > 0 { + return fmt.Errorf("failed to publish %d book.book records for:\n%s", len(failed), strings.Join(failed, "\n")) + } + return nil +} + +func publishStatusRecords(session atproto.Session, books []store.Book, storePath string) error { + var failed []string + + for i, book := range books { + if book.StatusURI != "" || book.ATURI == "" { + 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.ATURI, + CID: book.ATCid, + }, + Status: book.LexiconStatus, + CreatedAt: createdAt.Format(time.RFC3339), + } + + recordResponse, err := atproto.CreateRecord(session, "com.hectorsector.book.status", record) + if err != nil { + fmt.Fprintf(os.Stderr, "creating book.status for %q: %s\n", book.Title, apperrors.FormatError(err)) + failed = append(failed, "Title: "+book.Title+"; OLWorkID: "+book.OLWorkID) + continue + } + + books[i].StatusURI = recordResponse.URI + err = store.Save(storePath, books) + if err != nil { + return fmt.Errorf("saving store after %q: %s", book.Title, err) + } + + fmt.Printf("✅ Published book.status record: %s\n", book.Title) + + } + + if len(failed) > 0 { + return fmt.Errorf("failed to publish %d book.status records for:\n%s", len(failed), strings.Join(failed, "\n")) + } + + return nil +} + func init() { rootCmd.AddCommand(publishCmd) @@ -61,4 +171,5 @@ func init() { // 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)") } diff --git a/cli/internal/store/store.go b/cli/internal/store/store.go index 4ca45d3..8cf5329 100644 --- a/cli/internal/store/store.go +++ b/cli/internal/store/store.go @@ -1,8 +1,5 @@ package store -// TODO: -// Add `NotPublished` predicate** — `book.ATURI == "" - import ( "encoding/json" "fmt" @@ -156,6 +153,12 @@ func NeedsSearchEnrichment() Predicate { ) } +func NeedsPublishing() Predicate { + return func(book Book) bool { + return book.ATURI == "" + } +} + func HasISBN(isbn string) Predicate { return func(book Book) bool { if isbn == "" { -- 2.51.2