Closes the three problems that blocked the consolidation and records the rest
as tasks.
Licence: GPLv2 or later (WordPress.org compatible)
LICENSE is now the full GPLv2 text and NOTICE is gone (it was Apache
specific). All 101 SPDX headers read GPL-2.0-or-later and every shipped
header says "GPLv2 or later".
This also fixes an inconsistency that predated the consolidation: the theme's
style.css declared GPLv2 while its PHP files carried Apache headers.
Old/ and archive/pre-umbra-releases/ keep their original Apache headers on
purpose -- they are frozen snapshots, not shipped code.
Schema: the catalogue schema is canonical for bd_books and bd_series
Adds Umbra_Schema_Migration. The catalogue and library modules previously
both created those two tables with different columns, so whichever plugin
activated first won and the other read and wrote against the wrong shape.
The migration detects a library-shaped table, RENAMES it aside (never drops
it), creates the canonical table and copies rows across:
- book IDs preserved, keeping bd_book_meta, bd_reviews, bd_ratings and
bd_review_shortlinks valid
- title/slug/description resolved from the linked post
- the library's single isbn/asin land on the ebook columns; amazon_asin is
also set so existing product links keep working
- series migrated first so books.series_id still resolves
- post linkage rewritten as _bd_bookshelf_book_id (preserved meta key)
- publish_date kept as _bd_bookshelf_publish_date (no catalogue column)
- bd_ratings goodreads/amazon folded into rating_goodreads/rating_amazon
- rows whose post is gone are skipped with a log line, not turned into
unusable books
- duplicate slugs de-duplicated, since catalogue slug is UNIQUE
It runs before dbDelta, and on admin_init as well as activation, because
plugin updates do not re-run activation hooks. Ordering matters: dbDelta adds
columns but never drops them, so pointed at a library-shaped table it would
graft on catalogue columns and leave a table satisfying neither schema.
Umbra_Bookshelf_Helper_Install no longer creates bd_books or bd_series.
Scraping: Goodreads only, one implementation
There were three Goodreads implementations and two Amazon ones; both library
"scrapers" were empty stubs attached to cron events that fired on schedule to
do nothing, and both real scraper classes were never called at all.
Umbra_Amazon_Scraper is deleted and the duplicate HTML parsing is gone from
Umbra_Rating_Sync, which now delegates to Umbra_Goodreads_Scraper. The two
library stubs and their cron registrations are removed. One schedule
survives: bd_bookshelf_sync_ratings, every 12 hours.
The rating_amazon column is retained (it holds data and is populated during
migration) and Amazon retail links are unaffected.
A new rating fetch failure keeps the last known value instead of writing zero.
The admin ratings notice no longer warns about Amazon scraping.
Umbra_SEO is now instantiated
It registers its hooks from a constructor and the bootstrap never constructed
it, so the class was dead code and no SEO output was produced. Its meta, Open
Graph, Twitter card and sitemap hooks now attach. Completing SEO for generated
book detail pages is docs/TASKS.md #2.
Assets that 404'd
- assets/images/default-cover.png now holds a real 512x815 cover
(WorkingTitleCover_512x815.png). The helper returned this path but the
repository contained no images at all.
- email/assets/js/admin.js is committed as an empty placeholder. It was
enqueued on every email admin page but had never been committed.
Tests
- tests/schema_migration_test.php: 42 checks against a real database, driven
through the sqlite3 CLI on a throwaway file. Covers shape detection, row
mapping, ID preservation, orphan handling, rating folding, idempotency and
slug collisions.
- tests/run_all.sh runs every suite.
- The declared version is now checked against the plugin header rather than a
literal, so a version bump cannot silently break the suite.
Docs
- KNOWN-ISSUES.md rewritten: three resolved, three remaining.
- TASKS.md added: email admin behaviour, book detail page SEO, batching the
rating sync, reclaiming legacy tables, reconciling the two book models,
retiring the Old/ archive.
- SPEC and ARCHITECTURE updated for schema ownership, the migration ordering
constraint, and the single scraper.
Version 1.1.0.
836 lines
27 KiB
PHP
836 lines
27 KiB
PHP
<?php
|
|
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
/**
|
|
* Main Plugin Class
|
|
*
|
|
* @package Umbra_Bookshelf_Helper
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class Umbra_Bookshelf_Helper {
|
|
private static $instance = null;
|
|
|
|
/**
|
|
* Get instance
|
|
*
|
|
* @return Umbra_Bookshelf_Helper
|
|
*/
|
|
public static function instance() {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
private function __construct() {
|
|
$this->init_hooks();
|
|
}
|
|
|
|
/**
|
|
* Initialize hooks
|
|
*/
|
|
private function init_hooks() {
|
|
add_action('init', array($this, 'register_post_types'));
|
|
add_action('init', array($this, 'register_taxonomies'));
|
|
add_action('init', array($this, 'register_shortlinks'));
|
|
add_action('init', array($this, 'register_shortcodes'));
|
|
add_action('template_redirect', array($this, 'handle_shortlink_requests'));
|
|
|
|
// Hook for future plugin integration
|
|
add_filter('bdm_nav_menu_items', array($this, 'filter_series_menu_item'), 10, 2);
|
|
|
|
if (is_admin()) {
|
|
Umbra_Bookshelf_Helper_Admin::instance();
|
|
}
|
|
|
|
// Schedule cron jobs on init
|
|
add_action('init', array($this, 'schedule_cron_events'));
|
|
|
|
// Rating refresh is owned by Umbra_Rating_Sync (bd_bookshelf_sync_ratings),
|
|
// which drives Umbra_Goodreads_Scraper. The former
|
|
// bd_bookshelf_goodreads_refresh and bd_bookshelf_amazon_scrape hooks
|
|
// pointed at empty stubs and have been removed.
|
|
}
|
|
|
|
/**
|
|
* Register custom post type
|
|
*/
|
|
public function register_post_types() {
|
|
register_post_type('book', array(
|
|
'labels' => array(
|
|
'name' => esc_html__('Books', 'umbra-bookshelf'),
|
|
'singular_name' => esc_html__('Book', 'umbra-bookshelf'),
|
|
'add_new' => esc_html__('Add New', 'umbra-bookshelf'),
|
|
'add_new_item' => esc_html__('Add New Book', 'umbra-bookshelf'),
|
|
'edit_item' => esc_html__('Edit Book', 'umbra-bookshelf'),
|
|
'new_item' => esc_html__('New Book', 'umbra-bookshelf'),
|
|
'view_item' => esc_html__('View Book', 'umbra-bookshelf'),
|
|
'search_items' => esc_html__('Search Books', 'umbra-bookshelf'),
|
|
'not_found' => esc_html__('No books found', 'umbra-bookshelf'),
|
|
'not_found_in_trash' => esc_html__('No books found in trash', 'umbra-bookshelf'),
|
|
),
|
|
'public' => true,
|
|
'has_archive' => true,
|
|
'show_in_menu' => 'umbra-bookshelf',
|
|
'supports' => array('title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'),
|
|
'rewrite' => array('slug' => 'book'),
|
|
'menu_icon' => 'dashicons-book-alt',
|
|
'show_in_rest' => true,
|
|
'capability_type' => 'post',
|
|
'map_meta_cap' => true,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Register taxonomies
|
|
*/
|
|
public function register_taxonomies() {
|
|
register_taxonomy('series', 'book', array(
|
|
'labels' => array(
|
|
'name' => esc_html__('Series', 'umbra-bookshelf'),
|
|
'singular_name' => esc_html__('Series', 'umbra-bookshelf'),
|
|
'search_items' => esc_html__('Search Series', 'umbra-bookshelf'),
|
|
'all_items' => esc_html__('All Series', 'umbra-bookshelf'),
|
|
'edit_item' => esc_html__('Edit Series', 'umbra-bookshelf'),
|
|
'update_item' => esc_html__('Update Series', 'umbra-bookshelf'),
|
|
'add_new_item' => esc_html__('Add New Series', 'umbra-bookshelf'),
|
|
'new_item_name' => esc_html__('New Series Name', 'umbra-bookshelf'),
|
|
'not_found' => esc_html__('No series found', 'umbra-bookshelf'),
|
|
),
|
|
'hierarchical' => true,
|
|
'public' => true,
|
|
'show_admin_column' => true,
|
|
'rewrite' => array('slug' => 'series'),
|
|
'show_in_rest' => true,
|
|
'show_ui' => true,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Register rewrite rules for short links
|
|
*/
|
|
public function register_shortlinks() {
|
|
add_rewrite_rule('^([a-z0-9]{2,6})/?$', 'index.php?book_short_slug=$matches[1]', 'top');
|
|
add_rewrite_rule('^([a-z0-9]{2,6})r([agbckpl])/?$', 'index.php?book_short_slug=$matches[1]&review_site=$matches[2]', 'top');
|
|
}
|
|
|
|
/**
|
|
* Handle short link requests
|
|
*/
|
|
public function handle_shortlink_requests() {
|
|
$short_slug = get_query_var('book_short_slug');
|
|
|
|
if (empty($short_slug)) {
|
|
return;
|
|
}
|
|
|
|
global $wpdb;
|
|
$book_id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'short_link_slug' AND meta_value = %s",
|
|
$short_slug
|
|
));
|
|
|
|
if ($book_id) {
|
|
wp_redirect(get_permalink($book_id), 301);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter series menu item visibility
|
|
*
|
|
* @param string $items Menu items HTML.
|
|
* @param stdClass $args Menu args.
|
|
* @return string
|
|
*/
|
|
public function filter_series_menu_item($items, $args) {
|
|
if ($args->theme_location !== 'primary') {
|
|
return $items;
|
|
}
|
|
|
|
$series_count = self::series_count();
|
|
if ($series_count > 0) {
|
|
$series_item = '<li class="menu-item menu-item-type-taxonomy series-menu-item"><a href="' . home_url('/series/') . '">' . esc_html__('Series', 'umbra-bookshelf') . '</a></li>';
|
|
|
|
if (preg_match('/(<li[^>]*>.*?Books.*?<\/li>)/s', $items, $matches)) {
|
|
$books_item = $matches[1];
|
|
$items = str_replace($books_item, $books_item . $series_item, $items);
|
|
}
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Schedule cron events
|
|
*
|
|
* Rating refresh is owned solely by Umbra_Rating_Sync, which schedules the
|
|
* single 'bd_bookshelf_sync_ratings' event (every 12 hours) and drives the
|
|
* one surviving scraper, Umbra_Goodreads_Scraper.
|
|
*
|
|
* The former 'bd_bookshelf_goodreads_refresh' (daily) and
|
|
* 'bd_bookshelf_amazon_scrape' (six-hourly) events are no longer scheduled.
|
|
* Both pointed at empty stub methods, so each tick only fired HTTP-free
|
|
* no-ops -- and the Amazon one duplicated work the rating sync already did.
|
|
* Existing installations still get them cleared on deactivation and on
|
|
* uninstall, so no stale schedules are left behind.
|
|
*/
|
|
public function schedule_cron_events() {
|
|
// Intentionally empty: see the note above.
|
|
}
|
|
|
|
/**
|
|
* Register shortcodes
|
|
*/
|
|
public function register_shortcodes() {
|
|
add_shortcode('bd-book', array($this, 'shortcode_book'));
|
|
add_shortcode('bd-books', array($this, 'shortcode_books_list'));
|
|
}
|
|
|
|
/**
|
|
* Shortcode: [bd-book]
|
|
* Display single book by ID or title
|
|
*/
|
|
public function shortcode_book($atts) {
|
|
$atts = shortcode_atts(array(
|
|
'book' => '',
|
|
), $atts);
|
|
|
|
if (empty($atts['book'])) {
|
|
return '<p class="bd-book-error">Please specify a book ID or title in the shortcode.</p>';
|
|
}
|
|
|
|
$book_id = intval($atts['book']);
|
|
if ($book_id > 0) {
|
|
$book = get_post($book_id);
|
|
} else {
|
|
$books = get_posts(array(
|
|
'post_type' => 'book',
|
|
'title' => $atts['book'],
|
|
'posts_per_page' => 1,
|
|
));
|
|
$book = !empty($books) ? $books[0] : null;
|
|
}
|
|
|
|
if (!$book || 'book' !== $book->post_type) {
|
|
return '<p class="bd-book-error">Book not found.</p>';
|
|
}
|
|
|
|
return $this->render_book_display($book);
|
|
}
|
|
|
|
/**
|
|
* Shortcode: [bd-books]
|
|
* Display all books in a list
|
|
*/
|
|
public function shortcode_books_list($atts) {
|
|
$atts = shortcode_atts(array(
|
|
'limit' => -1,
|
|
'order' => 'title',
|
|
), $atts);
|
|
|
|
$books = get_posts(array(
|
|
'post_type' => 'book',
|
|
'posts_per_page' => intval($atts['limit']),
|
|
'post_status' => 'publish',
|
|
'orderby' => 'title',
|
|
'order' => 'ASC',
|
|
));
|
|
|
|
if (empty($books)) {
|
|
return '<p class="bd-book-error">No books available.</p>';
|
|
}
|
|
|
|
$output = '<div class="bd-books-list">';
|
|
foreach ($books as $book) {
|
|
$output .= $this->render_book_display($book);
|
|
}
|
|
$output .= '</div>';
|
|
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* Render book display HTML
|
|
*
|
|
* @param WP_Post $book Book post object.
|
|
* @return string
|
|
*/
|
|
private function render_book_display($book) {
|
|
$cover_url = Umbra_Bookshelf_Helper::get_cover_url($book->ID, 'medium');
|
|
$pen_name = Umbra_Bookshelf_Helper::get_pen_name($book->ID);
|
|
$blurb = Umbra_Bookshelf_Helper::get_localized_blurb($book->ID);
|
|
$links = Umbra_Bookshelf_Helper::get_retailer_links($book->ID);
|
|
|
|
ob_start();
|
|
?>
|
|
<div class="bd-book-listing">
|
|
<div class="book-entry" style="display: flex; gap: 2rem; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid #ccc;">
|
|
<?php if ($cover_url): ?>
|
|
<div class="book-cover" style="flex: 0 0 150px;">
|
|
<img src="<?php echo esc_url($cover_url); ?>" alt="<?php echo esc_attr($book->post_title); ?> Cover" style="width: 100%; max-width: 150px; height: auto; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
|
|
</div>
|
|
<?php endif; ?>
|
|
<div class="book-details" style="flex: 1;">
|
|
<h2 style="margin-top: 0; margin-bottom: 0.5rem;"><?php echo esc_html($book->post_title); ?></h2>
|
|
<p style="margin: 0 0 1rem 0; color: #666;">by <?php echo esc_html($pen_name); ?></p>
|
|
<?php if ($blurb): ?>
|
|
<div class="book-description" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #e0e0e0; background: #f9f9f9; border-radius: 4px;">
|
|
<?php echo wp_kses_post(wp_trim_words($blurb, 50)); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php if (!empty($links)): ?>
|
|
<div class="book-buttons" style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
|
<?php foreach ($links as $link): ?>
|
|
<a href="<?php echo esc_url($link['url']); ?>" target="_blank" rel="noopener" class="bd-book-btn" style="background-color: #FF9900; color: #000; padding: 0.5rem 1rem; text-decoration: none; border-radius: 4px; font-weight: bold;"><?php echo esc_html($link['name']); ?></a>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php
|
|
return ob_get_clean();
|
|
}
|
|
|
|
/**
|
|
* Migrate books from BD Books plugin
|
|
*
|
|
* @return array Migration result
|
|
*/
|
|
public static function migrate_from_bd_books() {
|
|
global $wpdb;
|
|
$old_table = $wpdb->prefix . 'bd_books';
|
|
|
|
// Check if old table exists
|
|
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$old_table'") === $old_table;
|
|
if (!$table_exists) {
|
|
return array('success' => false, 'message' => 'Old BD Books table not found.');
|
|
}
|
|
|
|
$old_books = $wpdb->get_results("SELECT * FROM {$old_table}");
|
|
if (empty($old_books)) {
|
|
return array('success' => true, 'message' => 'No books to migrate.', 'count' => 0);
|
|
}
|
|
|
|
$migrated = 0;
|
|
|
|
foreach ($old_books as $old_book) {
|
|
// Check if already migrated (by title)
|
|
$existing = get_posts(array(
|
|
'post_type' => 'book',
|
|
'title' => $old_book->title,
|
|
'posts_per_page' => 1,
|
|
));
|
|
|
|
if (!empty($existing)) {
|
|
continue;
|
|
}
|
|
|
|
// Create new book post
|
|
$post_id = wp_insert_post(array(
|
|
'post_type' => 'book',
|
|
'post_title' => sanitize_text_field($old_book->title),
|
|
'post_content' => '',
|
|
'post_status' => 'publish',
|
|
'post_author' => get_current_user_id(),
|
|
'post_date' => $old_book->created_at,
|
|
));
|
|
|
|
if (is_wp_error($post_id)) {
|
|
continue;
|
|
}
|
|
|
|
// Set author/pen name
|
|
if (!empty($old_book->author)) {
|
|
// Try to find or create pen name
|
|
global $wpdb;
|
|
$pen_names_table = $wpdb->prefix . 'bd_pen_names';
|
|
$pen_name_id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT id FROM {$pen_names_table} WHERE pen_name = %s",
|
|
$old_book->author
|
|
));
|
|
|
|
if ($pen_name_id) {
|
|
update_post_meta($post_id, 'pen_name_id', $pen_name_id);
|
|
} else {
|
|
// Insert new pen name
|
|
$wpdb->insert($pen_names_table, array(
|
|
'pen_name' => sanitize_text_field($old_book->author),
|
|
'active' => 1,
|
|
'created_at' => current_time('mysql'),
|
|
));
|
|
$new_pen_id = $wpdb->insert_id;
|
|
if ($new_pen_id) {
|
|
update_post_meta($post_id, 'pen_name_id', $new_pen_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set description/blurb
|
|
if (!empty($old_book->description)) {
|
|
update_post_meta($post_id, 'blurb_en', wp_kses_post($old_book->description));
|
|
}
|
|
|
|
// Set cover image
|
|
if (!empty($old_book->cover_image_id)) {
|
|
set_post_thumbnail($post_id, intval($old_book->cover_image_id));
|
|
} elseif (!empty($old_book->cover_image_url)) {
|
|
// Download and attach external image
|
|
$attachment_id = self::import_external_image($old_book->cover_image_url, $old_book->title);
|
|
if ($attachment_id) {
|
|
set_post_thumbnail($post_id, $attachment_id);
|
|
}
|
|
}
|
|
|
|
// Parse and set purchase links
|
|
$links = json_decode($old_book->purchase_links, true);
|
|
if (!empty($links) && is_array($links)) {
|
|
foreach ($links as $link) {
|
|
$label = isset($link['label']) ? strtolower($link['label']) : '';
|
|
$url = isset($link['url']) ? $link['url'] : '';
|
|
|
|
if (strpos($label, 'amazon') !== false) {
|
|
update_post_meta($post_id, 'ebook_url_amazon', esc_url_raw($url));
|
|
} elseif (strpos($label, 'apple') !== false) {
|
|
update_post_meta($post_id, 'ebook_url_apple', esc_url_raw($url));
|
|
} elseif (strpos($label, 'barnes') !== false || strpos($label, 'b&n') !== false) {
|
|
update_post_meta($post_id, 'ebook_url_barnes', esc_url_raw($url));
|
|
} elseif (strpos($label, 'kobo') !== false) {
|
|
update_post_meta($post_id, 'ebook_url_kobo', esc_url_raw($url));
|
|
} elseif (strpos($label, 'google') !== false) {
|
|
update_post_meta($post_id, 'ebook_url_google', esc_url_raw($url));
|
|
}
|
|
}
|
|
}
|
|
|
|
$migrated++;
|
|
}
|
|
|
|
return array(
|
|
'success' => true,
|
|
'message' => sprintf('Migrated %d books.', $migrated),
|
|
'count' => $migrated,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Import external image as attachment
|
|
*
|
|
* @param string $url Image URL.
|
|
* @param string $title Image title.
|
|
* @return int|false Attachment ID
|
|
*/
|
|
private static function import_external_image($url, $title) {
|
|
require_once(ABSPATH . 'wp-admin/includes/file.php');
|
|
require_once(ABSPATH . 'wp-admin/includes/media.php');
|
|
require_once(ABSPATH . 'wp-admin/includes/image.php');
|
|
|
|
$tmp = download_url($url);
|
|
if (is_wp_error($tmp)) {
|
|
return false;
|
|
}
|
|
|
|
$file_array = array(
|
|
'name' => sanitize_file_name($title) . '.jpg',
|
|
'tmp_name' => $tmp,
|
|
);
|
|
|
|
$id = media_handle_sideload($file_array, 0, $title);
|
|
@unlink($tmp);
|
|
|
|
if (is_wp_error($id)) {
|
|
return false;
|
|
}
|
|
|
|
return $id;
|
|
}
|
|
|
|
// =====================
|
|
// Static Helper Methods
|
|
// =====================
|
|
|
|
/**
|
|
* Get featured books
|
|
*
|
|
* @param int $limit Number of books to return.
|
|
* @return array
|
|
*/
|
|
public static function get_featured_books($limit = 5) {
|
|
$query = new WP_Query(array(
|
|
'post_type' => 'book',
|
|
'posts_per_page' => $limit,
|
|
'post_status' => 'publish',
|
|
'orderby' => 'meta_value',
|
|
'meta_key' => 'publish_date',
|
|
'order' => 'DESC',
|
|
));
|
|
|
|
return $query->posts;
|
|
}
|
|
|
|
/**
|
|
* Get cover URL for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $size Image size.
|
|
* @param string $lang Language code.
|
|
* @return string
|
|
*/
|
|
public static function get_cover_url($book_id, $size = 'thumbnail', $lang = 'en') {
|
|
$meta_key = 'cover_' . $lang;
|
|
$cover_id = get_post_meta($book_id, $meta_key, true);
|
|
|
|
if (!$cover_id) {
|
|
$cover_id = get_post_thumbnail_id($book_id);
|
|
}
|
|
|
|
if ($cover_id) {
|
|
$image = wp_get_attachment_image_url($cover_id, $size);
|
|
if ($image) {
|
|
return $image;
|
|
}
|
|
}
|
|
|
|
return UMBRA_BOOKSHELF_URL . 'assets/images/default-cover.png';
|
|
}
|
|
|
|
/**
|
|
* Get book URL (short link)
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @return string
|
|
*/
|
|
public static function get_book_url($book_id) {
|
|
$slug = get_post_meta($book_id, 'short_link_slug', true);
|
|
|
|
if ($slug) {
|
|
return home_url('/' . $slug);
|
|
}
|
|
|
|
return get_permalink($book_id);
|
|
}
|
|
|
|
/**
|
|
* Get ratings for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @return array
|
|
*/
|
|
public static function get_ratings($book_id) {
|
|
$ratings = array();
|
|
|
|
$amazon = get_post_meta($book_id, 'amazon_rating', true);
|
|
if ($amazon) {
|
|
$ratings['amazon'] = $amazon;
|
|
}
|
|
|
|
$goodreads = get_post_meta($book_id, 'goodreads_rating', true);
|
|
if ($goodreads) {
|
|
$ratings['goodreads'] = $goodreads;
|
|
}
|
|
|
|
return $ratings;
|
|
}
|
|
|
|
/**
|
|
* Get retailer links for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $format Format (ebook, paperback, hardback).
|
|
* @return array
|
|
*/
|
|
public static function get_retailer_links($book_id, $format = 'ebook') {
|
|
$links = array();
|
|
|
|
$retailers = array(
|
|
'amazon' => array('name' => 'Amazon', 'base_url' => 'https://www.amazon.com/dp/'),
|
|
'apple' => array('name' => 'Apple Books', 'base_url' => 'https://books.apple.com/'),
|
|
'barnes' => array('name' => 'Barnes & Noble', 'base_url' => 'https://www.barnesandnoble.com/'),
|
|
'kobo' => array('name' => 'Kobo', 'base_url' => 'https://www.kobo.com/'),
|
|
'google' => array('name' => 'Google Play', 'base_url' => 'https://play.google.com/store/apps/details?id='),
|
|
);
|
|
|
|
$affiliates = get_option('bdm_affiliate_codes', array());
|
|
|
|
foreach ($retailers as $key => $retailer) {
|
|
$url_meta = $format . '_url_' . $key;
|
|
$url = get_post_meta($book_id, $url_meta, true);
|
|
|
|
if (!$url && $key === 'amazon') {
|
|
$asin = get_post_meta($book_id, 'asin', true);
|
|
if ($asin) {
|
|
$affiliate = isset($affiliates[$key]) ? $affiliates[$key] : '';
|
|
$url = $retailer['base_url'] . $asin . '?tag=' . $affiliate;
|
|
}
|
|
}
|
|
|
|
if ($url) {
|
|
$links[] = array(
|
|
'name' => $retailer['name'],
|
|
'url' => $url,
|
|
'retailer' => $key,
|
|
);
|
|
}
|
|
}
|
|
|
|
return $links;
|
|
}
|
|
|
|
/**
|
|
* Display retailer links
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $format Format.
|
|
*/
|
|
public static function display_retailer_links($book_id, $format = 'ebook') {
|
|
$links = self::get_retailer_links($book_id, $format);
|
|
|
|
foreach ($links as $link) {
|
|
printf(
|
|
'<a href="%s" class="retailer-link retailer-%s" target="_blank" rel="noopener noreferrer">%s</a>',
|
|
esc_url($link['url']),
|
|
esc_attr($link['retailer']),
|
|
esc_html($link['name'])
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get localized title
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $lang Language code.
|
|
* @return string
|
|
*/
|
|
public static function get_localized_title($book_id, $lang = 'en') {
|
|
$title = get_post_meta($book_id, 'title_' . $lang, true);
|
|
return $title ?: get_the_title($book_id);
|
|
}
|
|
|
|
/**
|
|
* Get localized blurb
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $lang Language code.
|
|
* @return string
|
|
*/
|
|
public static function get_localized_blurb($book_id, $lang = 'en') {
|
|
return get_post_meta($book_id, 'blurb_' . $lang, true) ?: '';
|
|
}
|
|
|
|
/**
|
|
* Get preview excerpt
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $lang Language code.
|
|
* @return string
|
|
*/
|
|
public static function get_preview_excerpt($book_id, $lang = 'en') {
|
|
return get_post_meta($book_id, 'preview_excerpt_' . $lang, true) ?: '';
|
|
}
|
|
|
|
/**
|
|
* Get pull quotes
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @param string $lang Language code.
|
|
* @return array
|
|
*/
|
|
public static function get_pull_quotes($book_id, $lang = 'en') {
|
|
$quotes = array();
|
|
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$quote = get_post_meta($book_id, 'pull_quote_' . $lang . '_' . $i, true);
|
|
if ($quote) {
|
|
$quotes[] = $quote;
|
|
}
|
|
}
|
|
|
|
return $quotes;
|
|
}
|
|
|
|
/**
|
|
* Get reviews for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @return array
|
|
*/
|
|
public static function get_reviews($book_id) {
|
|
global $wpdb;
|
|
$table_name = $wpdb->prefix . 'bd_reviews';
|
|
|
|
$reviews = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT * FROM {$table_name} WHERE book_id = %d ORDER BY display_order ASC",
|
|
$book_id
|
|
));
|
|
|
|
return $reviews ?: array();
|
|
}
|
|
|
|
/**
|
|
* Get pen name for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @return string
|
|
*/
|
|
public static function get_pen_name($book_id) {
|
|
$pen_name_id = get_post_meta($book_id, 'pen_name_id', true);
|
|
|
|
if ($pen_name_id) {
|
|
global $wpdb;
|
|
$table_name = $wpdb->prefix . 'bd_pen_names';
|
|
|
|
$pen_name = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT pen_name FROM {$table_name} WHERE id = %d AND active = 1",
|
|
$pen_name_id
|
|
));
|
|
|
|
return $pen_name ?: 'B.D. Murphy';
|
|
}
|
|
|
|
return 'B.D. Murphy';
|
|
}
|
|
|
|
/**
|
|
* Get series info for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @return array|null
|
|
*/
|
|
public static function get_series_info($book_id) {
|
|
$terms = get_the_terms($book_id, 'series');
|
|
|
|
if ($terms && !is_wp_error($terms)) {
|
|
$series = $terms[0];
|
|
$volume = get_post_meta($book_id, 'volume_number', true);
|
|
|
|
return array(
|
|
'id' => $series->term_id,
|
|
'name' => $series->name,
|
|
'slug' => $series->slug,
|
|
'volume' => $volume ?: 1,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Count series
|
|
*
|
|
* @return int
|
|
*/
|
|
public static function series_count() {
|
|
$terms = get_terms(array(
|
|
'taxonomy' => 'series',
|
|
'hide_empty' => true,
|
|
'fields' => 'count',
|
|
));
|
|
|
|
return $terms ?: 0;
|
|
}
|
|
|
|
/**
|
|
* Get supported languages
|
|
*
|
|
* @return array
|
|
*/
|
|
public static function get_supported_languages() {
|
|
return array(
|
|
'en' => 'English',
|
|
'fr' => 'Français',
|
|
'de' => 'Deutsch',
|
|
'es' => 'Español',
|
|
'it' => 'Italiano',
|
|
'pt' => 'Português',
|
|
'ru' => 'Русский',
|
|
'ja' => '日本語',
|
|
'zh' => '中文',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get awards list
|
|
*
|
|
* @return array
|
|
*/
|
|
public static function get_awards() {
|
|
return get_option('bdm_awards', array());
|
|
}
|
|
|
|
/**
|
|
* Generate SEO tags for a book
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
*/
|
|
public static function output_seo_tags($book_id) {
|
|
$title = get_post_meta($book_id, 'seo_title', true) ?: get_the_title($book_id) . ' | ' . get_bloginfo('name');
|
|
$description = get_post_meta($book_id, 'seo_description', true) ?: get_post_meta($book_id, 'blurb', true);
|
|
|
|
if ($description) {
|
|
$description = substr($description, 0, 160);
|
|
}
|
|
|
|
printf('<meta name="description" content="%s">' . "\n", esc_attr($description));
|
|
printf('<meta property="og:title" content="%s">' . "\n", esc_attr($title));
|
|
printf('<meta property="og:description" content="%s">' . "\n", esc_attr($description));
|
|
printf('<meta property="og:type" content="book">' . "\n");
|
|
printf('<meta name="twitter:card" content="summary_large_image">' . "\n");
|
|
}
|
|
|
|
/**
|
|
* Generate schema.org data
|
|
*
|
|
* @param int $book_id Book post ID.
|
|
* @return array
|
|
*/
|
|
public static function generate_schema($book_id) {
|
|
$book = get_post($book_id);
|
|
|
|
$schema = array(
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'Book',
|
|
'name' => $book->post_title,
|
|
'author' => array(
|
|
'@type' => 'Person',
|
|
'name' => get_bloginfo('name'),
|
|
),
|
|
);
|
|
|
|
$asin = get_post_meta($book_id, 'asin', true);
|
|
if ($asin) {
|
|
$schema['isbn'] = $asin;
|
|
}
|
|
|
|
$cover_id = get_post_thumbnail_id($book_id);
|
|
if ($cover_id) {
|
|
$cover_url = wp_get_attachment_url($cover_id);
|
|
$schema['image'] = $cover_url;
|
|
}
|
|
|
|
$blurb = get_post_meta($book_id, 'blurb', true);
|
|
if ($blurb) {
|
|
$schema['description'] = $blurb;
|
|
}
|
|
|
|
$series_term = get_the_terms($book_id, 'series');
|
|
if ($series_term && !is_wp_error($series_term)) {
|
|
$schema['isPartOf'] = array(
|
|
'@type' => 'BookSeries',
|
|
'name' => $series_term[0]->name,
|
|
);
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
}
|