From 71b8065f20d5900cbb4db321f4dbbdae35567624 Mon Sep 17 00:00:00 2001 From: Twooey Date: Thu, 27 Aug 2026 13:59:30 -0400 Subject: [PATCH] AO3-style tag browsing: taxonomies, display, Hardcover sync (schema unverified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four flat taxonomies attached to product (bsc_genre, bsc_mood, bsc_content_warning, bsc_tag) reuse WordPress's native taxonomy archive system for the "click a tag, see everything with it" browsing AO3 is known for — no custom archive templates or query logic needed. Verified: all four register correctly, render as clickable chips on the product page (content warnings get a distinct notice instead of just another chip), and the archive page + term count both work end-to-end with real test data. HardcoverAdapter + `wp bookstore sync-hardcover-tags` pull genre/mood/ content-warning/freeform tags from Hardcover's API to populate these. This part is explicitly NOT verified against a live response — Hardcover's API is in beta with informal docs, and there was no API token available to confirm the exact query/response shape. Flagged clearly in the adapter itself; needs a real token + introspection query before trusting the field-parsing logic in production. ISBN-first-then-title/author matching handles both real future ISBNs and the current Gutenberg-synthetic catalog's fake ones. Co-Authored-By: Claude Sonnet 5 --- .env.example | 4 + docker-compose.yml | 1 + .../plugins/bookstore-core/bookstore-core.php | 6 + .../bookstore-core/includes/Cli/Commands.php | 97 ++++++++++ .../includes/Integration/HardcoverAdapter.php | 182 ++++++++++++++++++ .../includes/Taxonomy/TagDisplay.php | 67 +++++++ .../includes/Taxonomy/TagTaxonomies.php | 57 ++++++ 7 files changed, 414 insertions(+) create mode 100644 wp-content/plugins/bookstore-core/includes/Integration/HardcoverAdapter.php create mode 100644 wp-content/plugins/bookstore-core/includes/Taxonomy/TagDisplay.php create mode 100644 wp-content/plugins/bookstore-core/includes/Taxonomy/TagTaxonomies.php diff --git a/.env.example b/.env.example index 5f221bc..b71c6f5 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,7 @@ HELCIM_ACCOUNT_ID= # --- Marketing --- MAILERLITE_API_KEY= + +# --- Hardcover (AO3-style tag browsing — genre/mood/content-warning/tag sync) --- +# Get a token: hardcover.app account settings -> Hardcover API -> New API Key +HARDCOVER_API_TOKEN= diff --git a/docker-compose.yml b/docker-compose.yml index 71495a0..61b427a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ x-bookstore-env: &bookstore-env HELCIM_API_TOKEN: ${HELCIM_API_TOKEN:-} HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} + HARDCOVER_API_TOKEN: ${HARDCOVER_API_TOKEN:-} services: db: diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index f634cc4..3f7fc7e 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -58,6 +58,12 @@ add_filter('woocommerce_shipping_methods', function (array $methods) { return $methods; }); +add_action('init', function () { + \Bookstore\Core\Taxonomy\TagTaxonomies::register(); +}); + +\Bookstore\Core\Taxonomy\TagDisplay::register(); + if (defined('WP_CLI') && WP_CLI) { \WP_CLI::add_command('bookstore', \Bookstore\Core\Cli\Commands::class); } diff --git a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php index fce3e0f..63beca7 100644 --- a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -2,9 +2,13 @@ namespace Bookstore\Core\Cli; +use Bookstore\Core\Catalog\Isbn; +use Bookstore\Core\Catalog\Work; use Bookstore\Core\Import\GutenbergImporter; use Bookstore\Core\Import\SyntheticOfferGenerator; +use Bookstore\Core\Integration\HardcoverAdapter; use Bookstore\Core\Product\ProductSync; +use Bookstore\Core\Taxonomy\TagTaxonomies; defined('ABSPATH') || exit; @@ -93,4 +97,97 @@ class Commands }); \WP_CLI::success("Products: {$result['created']} created, {$result['updated']} updated."); } + + /** + * Syncs genre/mood/content-warning/freeform tags from Hardcover onto + * each Work's product (design doc-adjacent: AO3-style tag browsing). + * + * ## OPTIONS + * + * [--limit=] + * : Max works to process this run (default: all). + * + * [--force] + * : Re-sync works that already have a synced marker. + * + * [--delay-ms=] + * : Delay between API calls in milliseconds (default 750) — be a good + * citizen of a beta API whose commercial rate limits are still being + * built out. + * + * ## EXAMPLES + * + * wp bookstore sync-hardcover-tags --limit=20 + * + * @subcommand sync-hardcover-tags + */ + public function sync_hardcover_tags($args, $assoc_args) + { + if (!HardcoverAdapter::is_configured()) { + \WP_CLI::error('HARDCOVER_API_TOKEN is not set.'); + } + + $limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : PHP_INT_MAX; + $force = isset($assoc_args['force']); + $delay_ms = (int) ($assoc_args['delay-ms'] ?? 750); + + $matched = 0; + $missed = 0; + $processed = 0; + $offset = 0; + $batch_size = 50; + + while ($processed < $limit) { + $works = Work::all_paginated($batch_size, $offset); + if (empty($works)) { + break; + } + + foreach ($works as $work) { + if ($processed >= $limit) { + break; + } + + $product_id = (int) ($work->wc_product_id ?? 0); + if (!$product_id) { + continue; // no product yet — run sync-products first + } + if (!$force && get_post_meta($product_id, '_bsc_hardcover_synced_at', true)) { + continue; + } + + $isbns = Isbn::isbns_for_work((int) $work->work_id); + $isbn13 = $isbns[0] ?? ''; + + try { + $book = HardcoverAdapter::find_book($isbn13, $work->title, $work->primary_author); + if ($book) { + $tags = HardcoverAdapter::tags_for_book($book); + wp_set_object_terms($product_id, $tags['genre'], TagTaxonomies::GENRE, false); + wp_set_object_terms($product_id, $tags['mood'], TagTaxonomies::MOOD, false); + wp_set_object_terms($product_id, $tags['content_warning'], TagTaxonomies::CONTENT_WARNING, false); + wp_set_object_terms($product_id, $tags['tag'], TagTaxonomies::TAG, false); + $matched++; + } else { + $missed++; + } + update_post_meta($product_id, '_bsc_hardcover_synced_at', current_time('mysql')); + } catch (\RuntimeException $e) { + \WP_CLI::warning("work_id={$work->work_id} ({$work->title}): " . $e->getMessage()); + $missed++; + } + + $processed++; + HardcoverAdapter::throttle($delay_ms); + + if ($processed % 20 === 0) { + \WP_CLI::log(" processed {$processed} (matched {$matched}, missed {$missed})..."); + } + } + + $offset += $batch_size; + } + + \WP_CLI::success("Hardcover sync: {$matched} matched, {$missed} missed, {$processed} processed."); + } } diff --git a/wp-content/plugins/bookstore-core/includes/Integration/HardcoverAdapter.php b/wp-content/plugins/bookstore-core/includes/Integration/HardcoverAdapter.php new file mode 100644 index 0000000..8365a7f --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Integration/HardcoverAdapter.php @@ -0,0 +1,182 @@ + $entries) { + if (in_array($category, ['Genre', 'Mood'], true)) { + continue; // already covered by the dedicated fields below + } + foreach (self::as_string_list($entries) as $name) { + $tag[] = $name; + } + } + } + + return [ + 'genre' => self::as_string_list($book['genres'] ?? []), + 'mood' => self::as_string_list($book['moods'] ?? []), + 'content_warning' => self::as_string_list($book['content_warnings'] ?? []), + 'tag' => array_slice(array_values(array_unique($tag)), 0, 20), // cap freeform noise + ]; + } + + public static function throttle(int $delay_ms = self::DEFAULT_DELAY_MS): void + { + usleep($delay_ms * 1000); + } + + private static function find_by_isbn(string $isbn13): ?array + { + $query = <<<'GQL' + query FindByIsbn($isbn: String!) { + editions(where: {isbn_13: {_eq: $isbn}}, limit: 1) { + book { + id + title + genres + moods + content_warnings + cached_tags + } + } + } + GQL; + + $data = self::query($query, ['isbn' => $isbn13]); + return $data['editions'][0]['book'] ?? null; + } + + private static function find_by_title_author(string $title, ?string $author): ?array + { + $query = <<<'GQL' + query FindByTitle($q: String!) { + books(where: {title: {_ilike: $q}}, limit: 5) { + id + title + genres + moods + content_warnings + cached_tags + contributions { + author { + name + } + } + } + } + GQL; + + $candidates = self::query($query, ['q' => '%' . $title . '%'])['books'] ?? []; + if (empty($candidates)) { + return null; + } + if (!$author) { + return $candidates[0]; + } + + foreach ($candidates as $candidate) { + foreach ($candidate['contributions'] ?? [] as $contribution) { + $name = $contribution['author']['name'] ?? ''; + if ($name !== '' && stripos($author, $name) !== false) { + return $candidate; + } + } + } + + return $candidates[0]; // title matched, author unconfirmed — best-effort, not silent + } + + private static function query(string $query, array $variables = []): array + { + $token = getenv('HARDCOVER_API_TOKEN'); + if (!$token) { + throw new \RuntimeException('HARDCOVER_API_TOKEN is not set'); + } + + $response = wp_remote_post(self::ENDPOINT, [ + 'timeout' => 20, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + ], + 'body' => wp_json_encode(['query' => $query, 'variables' => $variables]), + ]); + + if (is_wp_error($response)) { + throw new \RuntimeException('Hardcover API request failed: ' . $response->get_error_message()); + } + + $code = wp_remote_retrieve_response_code($response); + $body = json_decode(wp_remote_retrieve_body($response), true); + + if ($code !== 200) { + throw new \RuntimeException("Hardcover API returned HTTP {$code}: " . wp_remote_retrieve_body($response)); + } + if (isset($body['errors'])) { + throw new \RuntimeException('Hardcover API errors: ' . wp_json_encode($body['errors'])); + } + + return $body['data'] ?? []; + } + + /** Defensive: accepts plain strings or {tag|name: string} objects — exact shape unverified. */ + private static function as_string_list($value): array + { + if (!is_array($value)) { + return []; + } + $out = []; + foreach ($value as $entry) { + if (is_string($entry)) { + $out[] = $entry; + } elseif (is_array($entry)) { + $name = $entry['tag'] ?? $entry['name'] ?? null; + if (is_string($name)) { + $out[] = $name; + } + } + } + return array_values(array_filter($out, static fn($s) => trim($s) !== '')); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Taxonomy/TagDisplay.php b/wp-content/plugins/bookstore-core/includes/Taxonomy/TagDisplay.php new file mode 100644 index 0000000..d4aa4a2 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Taxonomy/TagDisplay.php @@ -0,0 +1,67 @@ +get_id(); + + self::render_warning_notice($product_id); + self::render_chip_row($product_id, TagTaxonomies::GENRE, 'Genre'); + self::render_chip_row($product_id, TagTaxonomies::MOOD, 'Mood'); + self::render_chip_row($product_id, TagTaxonomies::TAG, 'Tags'); + } + + private static function render_warning_notice(int $product_id): void + { + $terms = get_the_terms($product_id, TagTaxonomies::CONTENT_WARNING); + if (!$terms || is_wp_error($terms)) { + return; + } + echo '
'; + echo 'Content warnings: '; + $links = array_map( + static fn($term) => '' . esc_html($term->name) . '', + $terms + ); + echo implode(', ', $links); + echo '
'; + } + + private static function render_chip_row(int $product_id, string $taxonomy, string $label): void + { + $terms = get_the_terms($product_id, $taxonomy); + if (!$terms || is_wp_error($terms)) { + return; + } + echo '
'; + echo '' . esc_html($label) . ': '; + $links = array_map( + static fn($term) => '' . esc_html($term->name) . '', + $terms + ); + echo implode(' ', $links); + echo '
'; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Taxonomy/TagTaxonomies.php b/wp-content/plugins/bookstore-core/includes/Taxonomy/TagTaxonomies.php new file mode 100644 index 0000000..7b17f4d --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Taxonomy/TagTaxonomies.php @@ -0,0 +1,57 @@ + [ + 'name' => $plural, + 'singular_name' => $singular, + 'search_items' => "Search {$plural}", + 'all_items' => "All {$plural}", + 'edit_item' => "Edit {$singular}", + 'view_item' => "View {$singular}", + 'update_item' => "Update {$singular}", + 'add_new_item' => "Add New {$singular}", + 'new_item_name' => "New {$singular} Name", + 'menu_name' => $plural, + ], + 'hierarchical' => false, + 'public' => true, + 'show_ui' => true, + 'show_in_menu' => true, + 'show_admin_column' => true, + 'show_in_nav_menus' => true, + 'show_tagcloud' => true, + 'show_in_rest' => true, + 'query_var' => true, + 'rewrite' => ['slug' => str_replace('bsc_', '', $key)], + ]); + } +}