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:
@@ -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=
|
||||
|
||||
@@ -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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user