AO3-style tag browsing: taxonomies, display, Hardcover sync (schema unverified)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:59:30 -04:00
co-authored by Claude Sonnet 5
parent 6299391f23
commit 71b8065f20
7 changed files with 414 additions and 0 deletions
+4
View File
@@ -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=
+1
View File
@@ -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:
@@ -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);
}
@@ -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=<number>]
* : Max works to process this run (default: all).
*
* [--force]
* : Re-sync works that already have a synced marker.
*
* [--delay-ms=<number>]
* : 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.");
}
}
@@ -0,0 +1,182 @@
<?php
namespace Bookstore\Core\Integration;
defined('ABSPATH') || exit;
/**
* UNVERIFIED SCHEMA WARNING: Hardcover's GraphQL API is in beta with
* informal documentation. Field *names* below (genres, moods,
* content_warnings, cached_tags, editions.isbn_13) are confirmed to exist
* via public docs/search, but the exact query shape and response
* structure have NOT been tested against a live response — there was no
* API token available to verify with at write time. Run a real
* introspection query against a known book before trusting this, and
* adjust as needed. See plan verification step 2.
*/
class HardcoverAdapter
{
private const ENDPOINT = 'https://api.hardcover.app/v1/graphql';
private const DEFAULT_DELAY_MS = 750;
public static function is_configured(): bool
{
return (bool) getenv('HARDCOVER_API_TOKEN');
}
/**
* Tries ISBN first (works once real supplier ISBNs exist), falls back
* to title+author search (needed for the current Gutenberg-synthetic
* catalog, whose ISBNs are fake). Fuzzy by nature — a miss returns
* null rather than guessing wrong.
*/
public static function find_book(string $isbn13, string $title, ?string $author): ?array
{
return self::find_by_isbn($isbn13) ?? self::find_by_title_author($title, $author);
}
/**
* @return array{genre: string[], mood: string[], content_warning: string[], tag: string[]}
*/
public static function tags_for_book(array $book): array
{
$tag = [];
$cached = $book['cached_tags'] ?? [];
if (is_array($cached)) {
foreach ($cached as $category => $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) !== ''));
}
}
@@ -0,0 +1,67 @@
<?php
namespace Bookstore\Core\Taxonomy;
defined('ABSPATH') || exit;
/**
* Renders assigned terms as clickable chips on the product page — genre,
* mood, and freeform tags link to their archive (WordPress's native
* taxonomy archive, doing the AO3 "click a tag, see everything with it"
* job for free). Content warnings get their own distinct notice instead
* of just another chip, matching how AO3 actually surfaces them
* (informed consent before reading, not just a discovery filter).
*/
class TagDisplay
{
public static function register(): void
{
add_action('woocommerce_product_meta_end', [self::class, 'render']);
}
public static function render(): void
{
global $product;
if (!$product) {
return;
}
$product_id = $product->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 '<div class="bsc-content-warning" style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #a13a2f;background:rgba(161,58,47,0.08);">';
echo '<strong>Content warnings:</strong> ';
$links = array_map(
static fn($term) => '<a href="' . esc_url(get_term_link($term)) . '">' . esc_html($term->name) . '</a>',
$terms
);
echo implode(', ', $links);
echo '</div>';
}
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 '<div class="bsc-tag-row bsc-tag-row--' . esc_attr(str_replace('bsc_', '', $taxonomy)) . '" style="margin:0.5em 0;">';
echo '<span class="bsc-tag-row-label">' . esc_html($label) . ':</span> ';
$links = array_map(
static fn($term) => '<a class="bsc-tag-chip" href="' . esc_url(get_term_link($term)) . '">' . esc_html($term->name) . '</a>',
$terms
);
echo implode(' ', $links);
echo '</div>';
}
}
@@ -0,0 +1,57 @@
<?php
namespace Bookstore\Core\Taxonomy;
defined('ABSPATH') || exit;
/**
* Four flat taxonomies (like post_tag, not category — no hierarchy) mirror
* how AO3 lets a reader click any tag and see an archive of everything
* carrying it. Sourced from Hardcover's per-book genres/moods/
* content_warnings/cached_tags (see HardcoverAdapter) — tag wrangling
* (canonical/synonym merging) is deliberately not built here; add only if
* duplicate tags actually become a visible problem.
*/
class TagTaxonomies
{
public const GENRE = 'bsc_genre';
public const MOOD = 'bsc_mood';
public const CONTENT_WARNING = 'bsc_content_warning';
public const TAG = 'bsc_tag';
public static function register(): void
{
self::register_one(self::GENRE, 'Genre', 'Genres');
self::register_one(self::MOOD, 'Mood', 'Moods');
self::register_one(self::CONTENT_WARNING, 'Content Warning', 'Content Warnings');
self::register_one(self::TAG, 'Tag', 'Tags');
}
private static function register_one(string $key, string $singular, string $plural): void
{
register_taxonomy($key, ['product'], [
'labels' => [
'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)],
]);
}
}