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.
1230 lines
55 KiB
PHP
1230 lines
55 KiB
PHP
<?php
|
|
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
/**
|
|
* Admin Class
|
|
*
|
|
* @package Umbra_Bookshelf_Helper
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class Umbra_Bookshelf_Helper_Admin {
|
|
private static $instance = null;
|
|
|
|
/**
|
|
* Get instance
|
|
*
|
|
* @return Umbra_Bookshelf_Helper_Admin
|
|
*/
|
|
public static function instance() {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct() {
|
|
add_action('admin_menu', array($this, 'add_menu_pages'));
|
|
add_action('admin_enqueue_scripts', array($this, 'enqueue_assets'));
|
|
add_action('add_meta_boxes_book', array($this, 'add_meta_boxes'));
|
|
add_action('save_post_book', array($this, 'save_meta_boxes'));
|
|
add_action('wp_ajax_bdm_update_links', array($this, 'ajax_update_links'));
|
|
add_action('wp_ajax_bdm_autosave', array($this, 'ajax_autosave'));
|
|
}
|
|
|
|
/**
|
|
* Add admin menu pages
|
|
*/
|
|
public function add_menu_pages() {
|
|
// The top-level "Umbra Bookshelf" menu is registered once by the plugin
|
|
// bootstrap (Umbra_Bookshelf::register_admin_menu); this module only
|
|
// contributes submenus beneath it.
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Books', 'umbra-bookshelf'),
|
|
esc_html__('Book Library', 'umbra-bookshelf'),
|
|
'edit_posts',
|
|
'edit.php?post_type=book'
|
|
);
|
|
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Add Book', 'umbra-bookshelf'),
|
|
esc_html__('Add Book', 'umbra-bookshelf'),
|
|
'edit_posts',
|
|
'post-new.php?post_type=book'
|
|
);
|
|
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Book Data Entry', 'umbra-bookshelf'),
|
|
esc_html__('Data Entry', 'umbra-bookshelf'),
|
|
'edit_posts',
|
|
'umbra-bookshelf-data-entry',
|
|
array($this, 'data_entry_page')
|
|
);
|
|
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Series', 'umbra-bookshelf'),
|
|
esc_html__('Series Terms', 'umbra-bookshelf'),
|
|
'manage_categories',
|
|
'edit-tags.php?taxonomy=series&post_type=book'
|
|
);
|
|
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Pen Names', 'umbra-bookshelf'),
|
|
esc_html__('Pen Names', 'umbra-bookshelf'),
|
|
'manage_options',
|
|
'umbra-bookshelf-pen-names',
|
|
array($this, 'pen_names_page')
|
|
);
|
|
|
|
// Distinct slug from Umbra_Admin's "umbra-bookshelf-settings": these are
|
|
// two different settings screens (library/publishing vs. catalogue).
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Library Settings', 'umbra-bookshelf'),
|
|
esc_html__('Library Settings', 'umbra-bookshelf'),
|
|
'manage_options',
|
|
'umbra-bookshelf-library-settings',
|
|
array($this, 'settings_page')
|
|
);
|
|
|
|
add_submenu_page(
|
|
'umbra-bookshelf',
|
|
esc_html__('Migrate Books', 'umbra-bookshelf'),
|
|
esc_html__('Migrate', 'umbra-bookshelf'),
|
|
'manage_options',
|
|
'umbra-bookshelf-migrate',
|
|
array($this, 'migrate_page')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enqueue admin assets
|
|
*
|
|
* @param string $hook Current admin page.
|
|
*/
|
|
public function enqueue_assets($hook) {
|
|
$screen = get_current_screen();
|
|
|
|
if ($screen && 'book' === $screen->post_type) {
|
|
wp_enqueue_media();
|
|
}
|
|
|
|
// Scoped to this module's own submenu slugs. A bare strpos('umbra-bookshelf')
|
|
// would also match every books-module and email-module screen.
|
|
$library_hooks = array(
|
|
'umbra-bookshelf-data-entry',
|
|
'umbra-bookshelf-pen-names',
|
|
'umbra-bookshelf-library-settings',
|
|
'umbra-bookshelf-migrate',
|
|
);
|
|
|
|
$is_library_screen = false;
|
|
foreach ($library_hooks as $library_hook) {
|
|
if (strpos($hook, $library_hook) !== false) {
|
|
$is_library_screen = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($is_library_screen) {
|
|
wp_enqueue_style(
|
|
'umbra-bookshelf-library-admin',
|
|
UMBRA_BOOKSHELF_URL . 'library/assets/css/admin.css',
|
|
array(),
|
|
UMBRA_BOOKSHELF_LIBRARY_VERSION
|
|
);
|
|
|
|
wp_enqueue_script(
|
|
'umbra-bookshelf-library-admin',
|
|
UMBRA_BOOKSHELF_URL . 'library/assets/js/admin.js',
|
|
array('jquery'),
|
|
UMBRA_BOOKSHELF_LIBRARY_VERSION,
|
|
true
|
|
);
|
|
|
|
wp_localize_script('umbra-bookshelf-library-admin', 'umbraBookshelfLibraryAdmin', array(
|
|
'ajaxUrl' => admin_url('admin-ajax.php'),
|
|
'nonce' => wp_create_nonce('bd_bookshelf_admin'),
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add meta boxes
|
|
*/
|
|
public function add_meta_boxes() {
|
|
add_meta_box(
|
|
'bd_book_details',
|
|
esc_html__('Book Details', 'umbra-bookshelf'),
|
|
array($this, 'book_details_meta_box'),
|
|
'book',
|
|
'normal',
|
|
'high'
|
|
);
|
|
|
|
add_meta_box(
|
|
'bd_book_seo',
|
|
esc_html__('SEO Settings', 'umbra-bookshelf'),
|
|
array($this, 'book_seo_meta_box'),
|
|
'book',
|
|
'side',
|
|
'low'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Book details meta box content
|
|
*
|
|
* @param WP_Post $post Post object.
|
|
*/
|
|
public function book_details_meta_box($post) {
|
|
$languages = Umbra_Bookshelf_Helper::get_supported_languages();
|
|
|
|
wp_nonce_field('bd_book_meta', 'bd_book_nonce');
|
|
|
|
echo '<div class="bd-book-meta-box">';
|
|
|
|
// Core Fields
|
|
echo '<h3>' . esc_html__('Core Fields', 'umbra-bookshelf') . '</h3>';
|
|
|
|
// Pen Name
|
|
echo '<p><label>' . esc_html__('Pen Name', 'umbra-bookshelf') . '</label>';
|
|
echo '<select name="pen_name_id">';
|
|
echo '<option value="">' . esc_html__('B.D. Murphy (Default)', 'umbra-bookshelf') . '</option>';
|
|
|
|
global $wpdb;
|
|
$table_name = $wpdb->prefix . 'bd_pen_names';
|
|
$pen_names = $wpdb->get_results("SELECT id, pen_name FROM {$table_name} WHERE active = 1 ORDER BY pen_name");
|
|
$pen_name_id = get_post_meta($post->ID, 'pen_name_id', true);
|
|
|
|
if ($pen_names) {
|
|
foreach ($pen_names as $pn) {
|
|
echo '<option value="' . esc_attr($pn->id) . '"' . selected($pen_name_id, $pn->id, false) . '>' . esc_html($pn->pen_name) . '</option>';
|
|
}
|
|
}
|
|
echo '</select></p>';
|
|
|
|
// ASIN and ISBN
|
|
echo '<p><label>' . esc_html__('ASIN (Amazon)', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="asin" value="' . esc_attr(get_post_meta($post->ID, 'asin', true)) . '" class="regular-text"></p>';
|
|
|
|
echo '<p><label>' . esc_html__('ISBN-13 (Print)', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="isbn" value="' . esc_attr(get_post_meta($post->ID, 'isbn', true)) . '" class="regular-text"></p>';
|
|
|
|
// Short Link
|
|
echo '<p><label>' . esc_html__('Short Link Slug', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="short_link_slug" value="' . esc_attr(get_post_meta($post->ID, 'short_link_slug', true)) . '" class="regular-text">';
|
|
echo '<p class="description">' . esc_html__('Auto-generated if empty. E.g., "ph" for "Pandemic Hacker"', 'umbra-bookshelf') . '</p></p>';
|
|
|
|
// Series
|
|
$series_terms = get_terms(array('taxonomy' => 'series', 'hide_empty' => false));
|
|
|
|
if ($series_terms) {
|
|
echo '<p><label>' . esc_html__('Series', 'umbra-bookshelf') . '</label>';
|
|
echo '<select name="series_id">';
|
|
echo '<option value="">' . esc_html__('None / Standalone', 'umbra-bookshelf') . '</option>';
|
|
|
|
$current_series = get_the_terms($post->ID, 'series');
|
|
$current_series_id = ($current_series && !is_wp_error($current_series)) ? $current_series[0]->term_id : 0;
|
|
|
|
foreach ($series_terms as $term) {
|
|
printf('<option value="%s"%s>%s</option>', esc_attr($term->term_id), selected($current_series_id, $term->term_id, false), esc_html($term->name));
|
|
}
|
|
echo '</select></p>';
|
|
|
|
if ($current_series_id) {
|
|
echo '<p><label>' . esc_html__('Volume Number', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="number" name="volume_number" value="' . esc_attr(get_post_meta($post->ID, 'volume_number', true)) . '" min="1"></p>';
|
|
}
|
|
}
|
|
|
|
// Language Content
|
|
echo '<h3>' . esc_html__('Language Content', 'umbra-bookshelf') . '</h3>';
|
|
|
|
foreach ($languages as $code => $name) {
|
|
echo '<fieldset class="bd-lang-fieldset">';
|
|
echo '<legend>' . esc_html($name) . ' (' . strtoupper($code) . ')</legend>';
|
|
|
|
echo '<p><label>' . esc_html__('Title', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="title_' . esc_attr($code) . '" value="' . esc_attr(get_post_meta($post->ID, 'title_' . $code, true)) . '" class="large-text"></p>';
|
|
|
|
echo '<p><label>' . esc_html__('Back Cover Blurb', 'umbra-bookshelf') . '</label>';
|
|
echo '<textarea name="blurb_' . esc_attr($code) . '" rows="4" class="large-text">' . esc_textarea(get_post_meta($post->ID, 'blurb_' . $code, true)) . '</textarea></p>';
|
|
|
|
echo '<p><label>' . esc_html__('Preview Excerpt', 'umbra-bookshelf') . '</label>';
|
|
echo '<textarea name="preview_excerpt_' . esc_attr($code) . '" rows="3" class="large-text">' . esc_textarea(get_post_meta($post->ID, 'preview_excerpt_' . $code, true)) . '</textarea></p>';
|
|
|
|
echo '<p><label>' . esc_html__('Pull Quotes (1-5)', 'umbra-bookshelf') . '</label>';
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
printf('<input type="text" name="pull_quote_%s_%d" value="%s" class="large-text" placeholder="%s">',
|
|
esc_attr($code), $i,
|
|
esc_attr(get_post_meta($post->ID, 'pull_quote_' . $code . '_' . $i, true)),
|
|
esc_attr__('Quote ' . $i, 'umbra-bookshelf')
|
|
);
|
|
}
|
|
echo '</p>';
|
|
|
|
echo '</fieldset>';
|
|
}
|
|
|
|
// Retailer Links
|
|
echo '<h3>' . esc_html__('Retailer Links', 'umbra-bookshelf') . '</h3>';
|
|
|
|
$retailer_fields = array(
|
|
'ebook_url_amazon' => 'Amazon (Ebook)',
|
|
'ebook_url_apple' => 'Apple Books',
|
|
'ebook_url_google' => 'Google Play',
|
|
'ebook_url_barnes' => 'Barnes & Noble (Ebook)',
|
|
'ebook_url_kobo' => 'Kobo',
|
|
'paperback_url_barnes' => 'Barnes & Noble (Paperback)',
|
|
'hardback_url_barnes' => 'Barnes & Noble (Hardback)',
|
|
);
|
|
|
|
foreach ($retailer_fields as $field => $label) {
|
|
printf('<p><label>%s</label><input type="url" name="%s" value="%s" class="large-text"></p>',
|
|
esc_html($label), esc_attr($field), esc_url(get_post_meta($post->ID, $field, true))
|
|
);
|
|
}
|
|
|
|
// Ratings
|
|
echo '<h3>' . esc_html__('Ratings', 'umbra-bookshelf') . '</h3>';
|
|
|
|
echo '<p><label>' . esc_html__('Amazon Rating', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="amazon_rating" value="' . esc_attr(get_post_meta($post->ID, 'amazon_rating', true)) . '" placeholder="4.5"></p>';
|
|
|
|
echo '<p><label>' . esc_html__('Goodreads Rating', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="goodreads_rating" value="' . esc_attr(get_post_meta($post->ID, 'goodreads_rating', true)) . '" placeholder="4.2"></p>';
|
|
|
|
echo '</div>';
|
|
}
|
|
|
|
/**
|
|
* SEO meta box content
|
|
*
|
|
* @param WP_Post $post Post object.
|
|
*/
|
|
public function book_seo_meta_box($post) {
|
|
echo '<p><label>' . esc_html__('SEO Title', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" name="seo_title" value="' . esc_attr(get_post_meta($post->ID, 'seo_title', true)) . '" class="large-text"></p>';
|
|
|
|
echo '<p><label>' . esc_html__('SEO Description', 'umbra-bookshelf') . '</label>';
|
|
echo '<textarea name="seo_description" rows="4" class="large-text">' . esc_textarea(get_post_meta($post->ID, 'seo_description', true)) . '</textarea></p>';
|
|
}
|
|
|
|
/**
|
|
* Data Entry page - Enhanced interface per spec 5.2.1
|
|
*/
|
|
public function data_entry_page() {
|
|
$post_id = isset($_GET['post_id']) ? absint($_GET['post_id']) : 0;
|
|
$languages = Umbra_Bookshelf_Helper::get_supported_languages();
|
|
|
|
// Get all books for selection
|
|
global $wpdb;
|
|
$books = $wpdb->get_results(
|
|
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type = 'book' AND post_status IN ('publish', 'draft', 'pending') ORDER BY post_title"
|
|
);
|
|
|
|
// If no post_id provided and no books exist, show selection
|
|
if (!$post_id && empty($books)) {
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html__('Book Data Entry', 'umbra-bookshelf') . '</h1>';
|
|
echo '<div class="bd-status-message warning">' . esc_html__('No books found. Please create a book first.', 'umbra-bookshelf') . '</div>';
|
|
echo '<p><a href="' . esc_url(admin_url('post-new.php?post_type=book')) . '" class="button button-primary">' . esc_html__('Create New Book', 'umbra-bookshelf') . '</a></p>';
|
|
echo '</div>';
|
|
return;
|
|
}
|
|
|
|
// Handle post selection
|
|
if (isset($_POST['bd_select_book']) && isset($_POST['selected_book_id'])) {
|
|
$post_id = absint($_POST['selected_book_id']);
|
|
}
|
|
|
|
// If still no post_id, show selection
|
|
if (!$post_id) {
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html__('Book Data Entry', 'umbra-bookshelf') . '</h1>';
|
|
echo '<form method="post">';
|
|
echo '<table class="form-table">';
|
|
echo '<tr><th>' . esc_html__('Select Book', 'umbra-bookshelf') . '</th>';
|
|
echo '<td><select name="selected_book_id">';
|
|
echo '<option value="">' . esc_html__('Select a book...', 'umbra-bookshelf') . '</option>';
|
|
foreach ($books as $book) {
|
|
echo '<option value="' . esc_attr($book->ID) . '">' . esc_html($book->post_title) . '</option>';
|
|
}
|
|
echo '</select></td></tr>';
|
|
echo '</table>';
|
|
submit_button(esc_html__('Select Book', 'umbra-bookshelf'), 'primary', 'bd_select_book');
|
|
echo '</form>';
|
|
echo '</div>';
|
|
return;
|
|
}
|
|
|
|
$post = get_post($post_id);
|
|
if (!$post || 'book' !== $post->post_type) {
|
|
echo '<div class="wrap">';
|
|
echo '<div class="bd-status-message error">' . esc_html__('Invalid book selected.', 'umbra-bookshelf') . '</div>';
|
|
echo '</div>';
|
|
return;
|
|
}
|
|
|
|
wp_nonce_field('bd_data_entry', 'bd_data_entry_nonce');
|
|
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . sprintf(esc_html__('Data Entry: %s', 'umbra-bookshelf'), esc_html($post->post_title)) . '</h1>';
|
|
|
|
// Language tabs
|
|
echo '<div class="bd-language-tabs">';
|
|
foreach ($languages as $code => $name) {
|
|
printf('<button type="button" class="bd-lang-tab%s" data-lang="%s">%s (%s)</button>',
|
|
'en' === $code ? ' active' : '',
|
|
esc_attr($code),
|
|
esc_html($name),
|
|
strtoupper($code)
|
|
);
|
|
}
|
|
echo '</div>';
|
|
|
|
echo '<form id="bd-data-entry-form" method="post">';
|
|
echo '<input type="hidden" name="post_id" value="' . esc_attr($post_id) . '">';
|
|
wp_nonce_field('bd_data_entry_save', 'bd_data_entry_save_nonce');
|
|
|
|
// Section 1: Basic Information
|
|
$this->render_section_header('basic', __('Basic Information', 'umbra-bookshelf'), true);
|
|
echo '<div class="bd-section-content" id="bd-section-basic">';
|
|
|
|
echo '<div class="bd-field-row">';
|
|
echo '<div class="bd-field required">';
|
|
echo '<label for="post_title">' . esc_html__('Book Title', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="post_title" name="post_title" value="' . esc_attr($post->post_title) . '" class="large-text" required>';
|
|
echo '<span class="bd-hint">' . esc_html__('The full title of the book as it appears on the cover.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="pen_name_id">' . esc_html__('Pen Name', 'umbra-bookshelf') . '</label>';
|
|
echo '<select id="pen_name_id" name="pen_name_id">';
|
|
echo '<option value="">' . esc_html__('B.D. Murphy (Default)', 'umbra-bookshelf') . '</option>';
|
|
|
|
$pen_names = $wpdb->get_results("SELECT id, pen_name FROM {$wpdb->prefix}bd_pen_names WHERE active = 1 ORDER BY pen_name");
|
|
$pen_name_id = get_post_meta($post_id, 'pen_name_id', true);
|
|
|
|
if ($pen_names) {
|
|
foreach ($pen_names as $pn) {
|
|
echo '<option value="' . esc_attr($pn->id) . '"' . selected($pen_name_id, $pn->id, false) . '>' . esc_html($pn->pen_name) . '</option>';
|
|
}
|
|
}
|
|
echo '</select>';
|
|
echo '<span class="bd-hint">' . esc_html__('The author name to display with this book.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field-row">';
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="publish_date">' . esc_html__('Publication Date', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="date" id="publish_date" name="publish_date" value="' . esc_attr(get_post_meta($post_id, 'publish_date', true)) . '">';
|
|
echo '<span class="bd-hint">' . esc_html__('Format: YYYY-MM-DD (e.g., 2024-01-15)', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="sort_order">' . esc_html__('Sort Order', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="number" id="sort_order" name="sort_order" value="' . esc_attr(get_post_meta($post_id, 'sort_order', true)) . '" min="0">';
|
|
echo '<span class="bd-hint">' . esc_html__('Manual sort position (lower numbers appear first).', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End Basic Information
|
|
|
|
// Section 2: Identifiers
|
|
$this->render_section_header('identifiers', __('Identifiers', 'umbra-bookshelf'), true);
|
|
echo '<div class="bd-section-content" id="bd-section-identifiers">';
|
|
|
|
echo '<div class="bd-field-row">';
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="isbn">' . esc_html__('ISBN-13 (Print)', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="isbn" name="isbn" value="' . esc_attr(get_post_meta($post_id, 'isbn', true)) . '" placeholder="9781234567890">';
|
|
echo '<span class="bd-hint">' . esc_html__('13 digits, no dashes (e.g., 9781234567890)', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="asin">' . esc_html__('ASIN (Amazon)', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="asin" name="asin" value="' . esc_attr(get_post_meta($post_id, 'asin', true)) . '" placeholder="B001234567">';
|
|
echo '<span class="bd-hint">' . esc_html__('10 alphanumeric characters (e.g., B001234567)', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End Identifiers
|
|
|
|
// Section 3: Series & Order
|
|
$this->render_section_header('series', __('Series & Order', 'umbra-bookshelf'), true);
|
|
echo '<div class="bd-section-content" id="bd-section-series">';
|
|
|
|
$series_terms = get_terms(array('taxonomy' => 'series', 'hide_empty' => false));
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="series_id">' . esc_html__('Series', 'umbra-bookshelf') . '</label>';
|
|
echo '<select id="series_id" name="series_id">';
|
|
echo '<option value="">' . esc_html__('None / Standalone', 'umbra-bookshelf') . '</option>';
|
|
|
|
$current_series = get_the_terms($post_id, 'series');
|
|
$current_series_id = ($current_series && !is_wp_error($current_series)) ? $current_series[0]->term_id : 0;
|
|
|
|
if ($series_terms) {
|
|
foreach ($series_terms as $term) {
|
|
printf('<option value="%s"%s>%s</option>', esc_attr($term->term_id), selected($current_series_id, $term->term_id, false), esc_html($term->name));
|
|
}
|
|
}
|
|
echo '</select>';
|
|
echo '<span class="bd-hint">' . esc_html__('Select the series this book belongs to, or leave blank for standalone.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="volume_number">' . esc_html__('Volume Number', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="number" id="volume_number" name="volume_number" value="' . esc_attr(get_post_meta($post_id, 'volume_number', true)) . '" min="1">';
|
|
echo '<span class="bd-hint">' . esc_html__('Order within the series (e.g., 1 for Book 1).', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End Series
|
|
|
|
// Section 4: Short Link
|
|
$this->render_section_header('shortlink', __('Short Link', 'umbra-bookshelf'), false);
|
|
echo '<div class="bd-section-content" id="bd-section-shortlink">';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="short_link_slug">' . esc_html__('Short Link Slug', 'umbra-bookshelf') . '</label>';
|
|
$current_slug = get_post_meta($post_id, 'short_link_slug', true);
|
|
if (empty($current_slug)) {
|
|
$current_slug = $this->generate_short_slug($post->post_title);
|
|
}
|
|
echo '<code>' . esc_url(home_url('/')) . '</code>';
|
|
echo '<input type="text" id="short_link_slug" name="short_link_slug" value="' . esc_attr($current_slug) . '">';
|
|
echo '<span class="bd-hint">' . esc_html__('Auto-generated from title if empty. E.g., "ph" for "Pandemic Hacker"', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End Short Link
|
|
|
|
// Section 5: Language Content (tabs for each language)
|
|
$this->render_section_header('content', __('Content (by Language)', 'umbra-bookshelf'), true);
|
|
echo '<div class="bd-section-content" id="bd-section-content">';
|
|
|
|
foreach ($languages as $code => $name) {
|
|
echo '<div class="bd-language-content' . ('en' === $code ? ' active' : '') . '" id="bd-lang-' . esc_attr($code) . '">';
|
|
echo '<h3>' . esc_html($name) . ' (' . strtoupper($code) . ')</h3>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="title_' . esc_attr($code) . '">' . esc_html__('Title', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="title_' . esc_attr($code) . '" name="title_' . esc_attr($code) . '" value="' . esc_attr(get_post_meta($post_id, 'title_' . $code, true)) . '" class="large-text">';
|
|
echo '<span class="bd-hint">' . esc_html__('Title in this language (if different from default).', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="blurb_' . esc_attr($code) . '">' . esc_html__('Back Cover Blurb', 'umbra-bookshelf') . '</label>';
|
|
echo '<textarea id="blurb_' . esc_attr($code) . '" name="blurb_' . esc_attr($code) . '" rows="6" class="large-text">' . esc_textarea(get_post_meta($post_id, 'blurb_' . $code, true)) . '</textarea>';
|
|
echo '<span class="bd-hint">' . esc_html__('The book description shown on the back cover and product pages.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="preview_excerpt_' . esc_attr($code) . '">' . esc_html__('Preview Excerpt', 'umbra-bookshelf') . '</label>';
|
|
echo '<textarea id="preview_excerpt_' . esc_attr($code) . '" name="preview_excerpt_' . esc_attr($code) . '" rows="4" class="large-text">' . esc_textarea(get_post_meta($post_id, 'preview_excerpt_' . $code, true)) . '</textarea>';
|
|
echo '<span class="bd-hint">' . esc_html__('A short excerpt (50-100 words) for previews and "Look Inside" feature.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label>' . esc_html__('Pull Quotes (up to 5)', 'umbra-bookshelf') . '</label>';
|
|
echo '<div class="bd-pull-quotes">';
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
printf('<input type="text" name="pull_quote_%s_%d" value="%s" placeholder="%s">',
|
|
esc_attr($code), $i,
|
|
esc_attr(get_post_meta($post_id, 'pull_quote_' . $code . '_' . $i, true)),
|
|
esc_attr(sprintf(__('Quote %d', 'umbra-bookshelf'), $i))
|
|
);
|
|
}
|
|
echo '</div>';
|
|
echo '<span class="bd-hint">' . esc_html__('Notable quotes or reviews to display on the book page.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field-row">';
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="genre_tags_' . esc_attr($code) . '">' . esc_html__('Genre Tags', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="genre_tags_' . esc_attr($code) . '" name="genre_tags_' . esc_attr($code) . '" value="' . esc_attr(get_post_meta($post_id, 'genre_tags_' . $code, true)) . '">';
|
|
echo '<span class="bd-hint">' . esc_html__('Comma-separated (e.g., Thriller, Sci-Fi, Mystery) - minimum 3 recommended', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="amazon_search_terms_' . esc_attr($code) . '">' . esc_html__('Amazon Search Terms', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="amazon_search_terms_' . esc_attr($code) . '" name="amazon_search_terms_' . esc_attr($code) . '" value="' . esc_attr(get_post_meta($post_id, 'amazon_search_terms_' . $code, true)) . '">';
|
|
echo '<span class="bd-hint">' . esc_html__('Keywords for Amazon search optimization, comma-separated.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End language content
|
|
}
|
|
|
|
echo '</div>'; // End Content
|
|
|
|
// Section 6: Retailer Links
|
|
$this->render_section_header('retailers', __('Retailer Links', 'umbra-bookshelf'), true);
|
|
echo '<div class="bd-section-content" id="bd-section-retailers">';
|
|
|
|
echo '<div class="bd-retailer-grid">';
|
|
|
|
$retailers = array(
|
|
'ebook_url_amazon' => array('label' => 'Amazon (Ebook)', 'hint' => 'Direct link to ebook product page'),
|
|
'ebook_url_apple' => array('label' => 'Apple Books', 'hint' => 'Link with your affiliate code'),
|
|
'ebook_url_google' => array('label' => 'Google Play', 'hint' => 'Link with your partner code'),
|
|
'ebook_url_barnes' => array('label' => 'Barnes & Noble (Ebook)', 'hint' => 'Link with affiliate parameter'),
|
|
'ebook_url_kobo' => array('label' => 'Kobo', 'hint' => 'Link with affiliate parameter'),
|
|
'paperback_url_amazon' => array('label' => 'Amazon (Paperback)', 'hint' => 'Direct link to paperback'),
|
|
'paperback_url_barnes' => array('label' => 'Barnes & Noble (Paperback)', 'hint' => 'Link with affiliate parameter'),
|
|
'hardback_url_barnes' => array('label' => 'Barnes & Noble (Hardback)', 'hint' => 'Link with affiliate parameter'),
|
|
);
|
|
|
|
foreach ($retailers as $field => $info) {
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="' . esc_attr($field) . '">' . esc_html($info['label'], 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="url" id="' . esc_attr($field) . '" name="' . esc_attr($field) . '" value="' . esc_url(get_post_meta($post_id, $field, true)) . '" placeholder="https://">';
|
|
echo '<span class="bd-hint">' . esc_html($info['hint'], 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
}
|
|
|
|
echo '</div>';
|
|
echo '</div>'; // End Retailer Links
|
|
|
|
// Section 7: Reviews
|
|
$this->render_section_header('reviews', __('Reviews', 'umbra-bookshelf'), false);
|
|
echo '<div class="bd-section-content" id="bd-section-reviews">';
|
|
|
|
$reviews_table = $wpdb->prefix . 'bd_reviews';
|
|
$reviews = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$reviews_table} WHERE book_id = %d ORDER BY display_order", $post_id));
|
|
|
|
echo '<p>' . esc_html__('Reviews are managed via the Reviews tab on the book editing screen.', 'umbra-bookshelf') . '</p>';
|
|
|
|
if ($reviews) {
|
|
echo '<table class="widefat">';
|
|
echo '<thead><tr><th>Source</th><th>Comment</th><th>Order</th></tr></thead>';
|
|
echo '<tbody>';
|
|
foreach ($reviews as $review) {
|
|
echo '<tr>';
|
|
echo '<td>' . esc_html($review->source_name) . '</td>';
|
|
echo '<td>' . esc_html(wp_trim_words($review->comment_text, 20)) . '</td>';
|
|
echo '<td>' . esc_html($review->display_order) . '</td>';
|
|
echo '</tr>';
|
|
}
|
|
echo '</tbody></table>';
|
|
}
|
|
|
|
echo '</div>'; // End Reviews
|
|
|
|
// Section 8: Ratings
|
|
$this->render_section_header('ratings', __('Ratings', 'umbra-bookshelf'), false);
|
|
echo '<div class="bd-section-content" id="bd-section-ratings">';
|
|
|
|
echo '<div class="bd-field-row">';
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="amazon_rating">' . esc_html__('Amazon Rating', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="amazon_rating" name="amazon_rating" value="' . esc_attr(get_post_meta($post_id, 'amazon_rating', true)) . '" placeholder="4.5">';
|
|
echo '<span class="bd-hint">' . esc_html__('Average rating out of 5 (e.g., 4.5)', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="goodreads_rating">' . esc_html__('Goodreads Rating', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="goodreads_rating" name="goodreads_rating" value="' . esc_attr(get_post_meta($post_id, 'goodreads_rating', true)) . '" placeholder="4.2">';
|
|
echo '<span class="bd-hint">' . esc_html__('Average rating out of 5 from Goodreads.', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End Ratings
|
|
|
|
// Section 9: SEO
|
|
$this->render_section_header('seo', __('SEO Settings', 'umbra-bookshelf'), false);
|
|
echo '<div class="bd-section-content" id="bd-section-seo">';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="seo_title">' . esc_html__('SEO Title', 'umbra-bookshelf') . '</label>';
|
|
echo '<input type="text" id="seo_title" name="seo_title" value="' . esc_attr(get_post_meta($post_id, 'seo_title', true)) . '" class="large-text">';
|
|
echo '<span class="bd-hint">' . esc_html__('Custom title for search engines (defaults to book title if empty).', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '<div class="bd-field">';
|
|
echo '<label for="seo_description">' . esc_html__('SEO Description', 'umbra-bookshelf') . '</label>';
|
|
echo '<textarea id="seo_description" name="seo_description" rows="4" class="large-text">' . esc_textarea(get_post_meta($post_id, 'seo_description', true)) . '</textarea>';
|
|
echo '<span class="bd-hint">' . esc_html__('Meta description for search results (150-160 characters recommended).', 'umbra-bookshelf') . '</span>';
|
|
echo '</div>';
|
|
|
|
echo '</div>'; // End SEO
|
|
|
|
// Action buttons
|
|
echo '<div class="bd-data-entry-actions">';
|
|
echo '<a href="' . esc_url(admin_url('post.php?post=' . $post_id . '&action=edit')) . '" class="button">' . esc_html__('Edit in Standard Editor', 'umbra-bookshelf') . '</a>';
|
|
submit_button(esc_html__('Save Book Data', 'umbra-bookshelf'), 'primary large', 'bd_save_data_entry', false);
|
|
echo '<button type="button" id="bd-preview-btn" class="button button-large">' . esc_html__('Preview', 'umbra-bookshelf') . '</button>';
|
|
echo '</div>';
|
|
|
|
echo '</form>';
|
|
|
|
// Auto-save indicator
|
|
echo '<div class="bd-autosave-indicator"></div>';
|
|
|
|
echo '</div>'; // End wrap
|
|
|
|
// Handle form submission
|
|
if (isset($_POST['bd_save_data_entry']) && isset($_POST['bd_data_entry_save_nonce'])) {
|
|
$this->save_data_entry($post_id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render section header with collapse toggle
|
|
*/
|
|
private function render_section_header($id, $title, $default_open = true) {
|
|
printf('<h2 class="bd-section-header%s" data-section="%s">', $default_open ? '' : ' collapsed', esc_attr($id));
|
|
echo '<span>' . esc_html($title) . '</span>';
|
|
echo '<span class="dashicons dashicons-arrow-down-alt2"></span>';
|
|
echo '</h2>';
|
|
}
|
|
|
|
/**
|
|
* Save data entry form
|
|
*/
|
|
private function save_data_entry($post_id) {
|
|
if (!isset($_POST['bd_data_entry_save_nonce']) || !wp_verify_nonce($_POST['bd_data_entry_save_nonce'], 'bd_data_entry_save')) {
|
|
echo '<div class="bd-status-message error">' . esc_html__('Security check failed.', 'umbra-bookshelf') . '</div>';
|
|
return;
|
|
}
|
|
|
|
if (!current_user_can('edit_post', $post_id)) {
|
|
echo '<div class="bd-status-message error">' . esc_html__('Permission denied.', 'umbra-bookshelf') . '</div>';
|
|
return;
|
|
}
|
|
|
|
// Update post title
|
|
if (isset($_POST['post_title'])) {
|
|
wp_update_post(array(
|
|
'ID' => $post_id,
|
|
'post_title' => sanitize_text_field($_POST['post_title']),
|
|
));
|
|
}
|
|
|
|
// Core fields
|
|
$fields = array(
|
|
'pen_name_id', 'isbn', 'asin', 'short_link_slug', 'volume_number',
|
|
'publish_date', 'sort_order', 'amazon_rating', 'goodreads_rating',
|
|
'seo_title', 'seo_description'
|
|
);
|
|
|
|
foreach ($fields as $field) {
|
|
if (isset($_POST[$field])) {
|
|
$value = $_POST[$field];
|
|
if (in_array($field, array('isbn', 'asin'))) {
|
|
$value = sanitize_text_field($value);
|
|
} elseif (in_array($field, array('seo_title', 'seo_description'))) {
|
|
$value = sanitize_text_field($value);
|
|
} else {
|
|
$value = sanitize_text_field($value);
|
|
}
|
|
update_post_meta($post_id, $field, $value);
|
|
}
|
|
}
|
|
|
|
// Language content
|
|
$languages = Umbra_Bookshelf_Helper::get_supported_languages();
|
|
foreach ($languages as $code => $name) {
|
|
$title_key = 'title_' . $code;
|
|
$blurb_key = 'blurb_' . $code;
|
|
$preview_key = 'preview_excerpt_' . $code;
|
|
$genre_key = 'genre_tags_' . $code;
|
|
$search_key = 'amazon_search_terms_' . $code;
|
|
|
|
foreach (array($title_key, $blurb_key, $preview_key, $genre_key, $search_key) as $key) {
|
|
if (isset($_POST[$key])) {
|
|
update_post_meta($post_id, $key, sanitize_text_field($_POST[$key]));
|
|
}
|
|
}
|
|
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$quote_key = 'pull_quote_' . $code . '_' . $i;
|
|
if (isset($_POST[$quote_key])) {
|
|
update_post_meta($post_id, $quote_key, sanitize_text_field($_POST[$quote_key]));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Retailer links
|
|
$retailer_fields = array(
|
|
'ebook_url_amazon', 'ebook_url_apple', 'ebook_url_google',
|
|
'ebook_url_barnes', 'ebook_url_kobo', 'paperback_url_amazon',
|
|
'paperback_url_barnes', 'hardback_url_barnes'
|
|
);
|
|
|
|
foreach ($retailer_fields as $field) {
|
|
if (isset($_POST[$field])) {
|
|
update_post_meta($post_id, $field, esc_url_raw($_POST[$field]));
|
|
}
|
|
}
|
|
|
|
// Series
|
|
if (isset($_POST['series_id'])) {
|
|
$series_id = absint($_POST['series_id']);
|
|
if ($series_id > 0) {
|
|
wp_set_post_terms($post_id, array($series_id), 'series');
|
|
} else {
|
|
wp_set_post_terms($post_id, array(), 'series');
|
|
}
|
|
}
|
|
|
|
echo '<div class="bd-status-message success">' . esc_html__('Book data saved successfully!', 'umbra-bookshelf') . '</div>';
|
|
}
|
|
|
|
/**
|
|
* AJAX handler for auto-save
|
|
*/
|
|
public function ajax_autosave() {
|
|
check_ajax_referer('bd_bookshelf_admin', 'nonce');
|
|
|
|
$post_id = absint($_POST['post_id']);
|
|
if (!$post_id || !current_user_can('edit_post', $post_id)) {
|
|
wp_send_json_error(array('message' => 'Permission denied'));
|
|
}
|
|
|
|
// Save basic fields
|
|
$text_fields = array(
|
|
'pen_name_id', 'isbn', 'asin', 'short_link_slug', 'volume_number',
|
|
'publish_date', 'sort_order', 'amazon_rating', 'goodreads_rating',
|
|
'seo_title', 'seo_description'
|
|
);
|
|
|
|
foreach ($text_fields as $field) {
|
|
if (isset($_POST[$field]) && $_POST[$field] !== '') {
|
|
update_post_meta($post_id, $field, sanitize_text_field($_POST[$field]));
|
|
}
|
|
}
|
|
|
|
// Save post title
|
|
if (isset($_POST['post_title']) && $_POST['post_title'] !== '') {
|
|
wp_update_post(array(
|
|
'ID' => $post_id,
|
|
'post_title' => sanitize_text_field($_POST['post_title']),
|
|
));
|
|
}
|
|
|
|
// Save series
|
|
if (isset($_POST['series_id'])) {
|
|
$series_id = absint($_POST['series_id']);
|
|
if ($series_id > 0) {
|
|
wp_set_post_terms($post_id, array($series_id), 'series');
|
|
} else {
|
|
wp_set_post_terms($post_id, array(), 'series');
|
|
}
|
|
}
|
|
|
|
// Save language content
|
|
$languages = Umbra_Bookshelf_Helper::get_supported_languages();
|
|
foreach ($languages as $code => $name) {
|
|
$title_key = 'title_' . $code;
|
|
$blurb_key = 'blurb_' . $code;
|
|
$preview_key = 'preview_excerpt_' . $code;
|
|
$genre_key = 'genre_tags_' . $code;
|
|
$search_key = 'amazon_search_terms_' . $code;
|
|
|
|
foreach (array($title_key, $blurb_key, $preview_key, $genre_key, $search_key) as $key) {
|
|
if (isset($_POST[$key]) && $_POST[$key] !== '') {
|
|
update_post_meta($post_id, $key, sanitize_text_field($_POST[$key]));
|
|
}
|
|
}
|
|
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$quote_key = 'pull_quote_' . $code . '_' . $i;
|
|
if (isset($_POST[$quote_key]) && $_POST[$quote_key] !== '') {
|
|
update_post_meta($post_id, $quote_key, sanitize_text_field($_POST[$quote_key]));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save retailer links
|
|
$retailer_fields = array(
|
|
'ebook_url_amazon', 'ebook_url_apple', 'ebook_url_google',
|
|
'ebook_url_barnes', 'ebook_url_kobo', 'paperback_url_amazon',
|
|
'paperback_url_barnes', 'hardback_url_barnes'
|
|
);
|
|
|
|
foreach ($retailer_fields as $field) {
|
|
if (isset($_POST[$field]) && $_POST[$field] !== '') {
|
|
update_post_meta($post_id, $field, esc_url_raw($_POST[$field]));
|
|
}
|
|
}
|
|
|
|
wp_send_json_success(array('message' => 'Auto-saved'));
|
|
}
|
|
|
|
/**
|
|
* Save meta boxes
|
|
*
|
|
* @param int $post_id Post ID.
|
|
*/
|
|
public function save_meta_boxes($post_id) {
|
|
if (!isset($_POST['bd_book_nonce']) || !wp_verify_nonce($_POST['bd_book_nonce'], 'bd_book_meta')) {
|
|
return $post_id;
|
|
}
|
|
|
|
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
|
|
return $post_id;
|
|
}
|
|
|
|
if (!current_user_can('edit_post', $post_id)) {
|
|
return $post_id;
|
|
}
|
|
|
|
$fields = array(
|
|
'pen_name_id', 'asin', 'isbn', 'short_link_slug', 'volume_number',
|
|
'amazon_rating', 'goodreads_rating', 'seo_title', 'seo_description'
|
|
);
|
|
|
|
foreach ($fields as $field) {
|
|
if (isset($_POST[$field])) {
|
|
update_post_meta($post_id, $field, sanitize_text_field($_POST[$field]));
|
|
}
|
|
}
|
|
|
|
$languages = Umbra_Bookshelf_Helper::get_supported_languages();
|
|
|
|
foreach ($languages as $code => $name) {
|
|
$title_key = 'title_' . $code;
|
|
$blurb_key = 'blurb_' . $code;
|
|
$preview_key = 'preview_excerpt_' . $code;
|
|
|
|
if (isset($_POST[$title_key])) {
|
|
update_post_meta($post_id, $title_key, sanitize_text_field($_POST[$title_key]));
|
|
}
|
|
if (isset($_POST[$blurb_key])) {
|
|
update_post_meta($post_id, $blurb_key, sanitize_textarea_field($_POST[$blurb_key]));
|
|
}
|
|
if (isset($_POST[$preview_key])) {
|
|
update_post_meta($post_id, $preview_key, sanitize_textarea_field($_POST[$preview_key]));
|
|
}
|
|
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$quote_key = 'pull_quote_' . $code . '_' . $i;
|
|
if (isset($_POST[$quote_key])) {
|
|
update_post_meta($post_id, $quote_key, sanitize_text_field($_POST[$quote_key]));
|
|
}
|
|
}
|
|
}
|
|
|
|
$retailer_fields = array(
|
|
'ebook_url_amazon', 'ebook_url_apple', 'ebook_url_google',
|
|
'ebook_url_barnes', 'ebook_url_kobo', 'paperback_url_barnes', 'hardback_url_barnes'
|
|
);
|
|
|
|
foreach ($retailer_fields as $field) {
|
|
if (isset($_POST[$field])) {
|
|
update_post_meta($post_id, $field, esc_url_raw($_POST[$field]));
|
|
}
|
|
}
|
|
|
|
if (isset($_POST['series_id'])) {
|
|
$series_id = absint($_POST['series_id']);
|
|
if ($series_id > 0) {
|
|
wp_set_post_terms($post_id, array($series_id), 'series');
|
|
} else {
|
|
wp_set_post_terms($post_id, array(), 'series');
|
|
}
|
|
}
|
|
|
|
// Auto-generate short slug if empty
|
|
if (empty($_POST['short_link_slug'])) {
|
|
$title = get_the_title($post_id);
|
|
$slug = $this->generate_short_slug($title);
|
|
update_post_meta($post_id, 'short_link_slug', $slug);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate short slug from title
|
|
*
|
|
* @param string $title Book title.
|
|
* @return string
|
|
*/
|
|
private function generate_short_slug($title) {
|
|
$title = preg_replace('/[^a-zA-Z0-9\s]/', '', $title);
|
|
$words = explode(' ', $title);
|
|
$stop_words = array('the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for');
|
|
|
|
$significant_words = array_filter($words, function($word) use ($stop_words) {
|
|
return !in_array(strtolower($word), $stop_words) && strlen($word) > 2;
|
|
});
|
|
|
|
$slug = '';
|
|
foreach ($significant_words as $word) {
|
|
$slug .= strtolower(substr($word, 0, 1));
|
|
}
|
|
|
|
if (strlen($slug) < 2) {
|
|
$slug = substr(implode('', $significant_words), 0, 4);
|
|
}
|
|
|
|
global $wpdb;
|
|
$original_slug = $slug;
|
|
$counter = 0;
|
|
|
|
while (true) {
|
|
$check_slug = $counter > 0 ? $original_slug . $counter : $slug;
|
|
$exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'short_link_slug' AND meta_value = %s",
|
|
$check_slug
|
|
));
|
|
|
|
if (!$exists) {
|
|
return $check_slug;
|
|
}
|
|
|
|
$counter++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pen names admin page
|
|
*/
|
|
public function pen_names_page() {
|
|
global $wpdb;
|
|
$table_name = $wpdb->prefix . 'bd_pen_names';
|
|
|
|
if (isset($_POST['bd_add_pen_name']) && check_admin_referer('bd_add_pen_name')) {
|
|
$pen_name = sanitize_text_field($_POST['new_pen_name']);
|
|
$notes = sanitize_textarea_field($_POST['new_notes']);
|
|
|
|
if ($pen_name) {
|
|
$wpdb->insert($table_name, array(
|
|
'pen_name' => $pen_name,
|
|
'notes' => $notes,
|
|
'active' => 1,
|
|
'created_at' => current_time('mysql'),
|
|
));
|
|
echo '<div class="updated"><p>' . esc_html__('Pen name added.', 'umbra-bookshelf') . '</p></div>';
|
|
}
|
|
}
|
|
|
|
if (isset($_POST['bd_update_pen_status'])) {
|
|
$id = absint($_POST['pen_name_id']);
|
|
$active = isset($_POST['pen_active']) ? 1 : 0;
|
|
$wpdb->update($table_name, array('active' => $active), array('id' => $id));
|
|
}
|
|
|
|
if (isset($_POST['bd_delete_pen_name'])) {
|
|
$id = absint($_POST['pen_name_id']);
|
|
$wpdb->delete($table_name, array('id' => $id));
|
|
}
|
|
|
|
$pen_names = $wpdb->get_results("SELECT * FROM {$table_name} ORDER BY pen_name");
|
|
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html__('Pen Names', 'umbra-bookshelf') . '</h1>';
|
|
|
|
// Add new form
|
|
echo '<form method="post">';
|
|
wp_nonce_field('bd_add_pen_name');
|
|
echo '<h2>' . esc_html__('Add New Pen Name', 'umbra-bookshelf') . '</h2>';
|
|
echo '<table class="form-table">';
|
|
echo '<tr><th>' . esc_html__('Pen Name', 'umbra-bookshelf') . '</th>';
|
|
echo '<td><input type="text" name="new_pen_name" class="regular-text" required>';
|
|
echo '<p class="description">' . esc_html__('e.g., J.K. Murphy', 'umbra-bookshelf') . '</p></td></tr>';
|
|
echo '<tr><th>' . esc_html__('Notes', 'umbra-bookshelf') . '</th>';
|
|
echo '<td><textarea name="new_notes" rows="3" class="regular-text"></textarea></td></tr>';
|
|
echo '</table>';
|
|
submit_button(esc_html__('Add Pen Name', 'umbra-bookshelf'), 'primary', 'bd_add_pen_name');
|
|
echo '</form>';
|
|
|
|
// Existing list
|
|
echo '<h2>' . esc_html__('Existing Pen Names', 'umbra-bookshelf') . '</h2>';
|
|
echo '<table class="widefat">';
|
|
echo '<thead><tr><th>ID</th><th>Pen Name</th><th>Notes</th><th>Status</th><th>Actions</th></tr></thead>';
|
|
echo '<tbody>';
|
|
|
|
if ($pen_names) {
|
|
foreach ($pen_names as $pn) {
|
|
echo '<tr>';
|
|
printf('<td>%d</td>', esc_html($pn->id));
|
|
printf('<td>%s</td>', esc_html($pn->pen_name));
|
|
printf('<td>%s</td>', esc_html($pn->notes));
|
|
printf('<td>%s</td>', $pn->active ? esc_html__('Active', 'umbra-bookshelf') : esc_html__('Inactive', 'umbra-bookshelf'));
|
|
echo '<td>';
|
|
printf('<form method="post" style="display:inline;">%s', wp_nonce_field('bd_update_pen_status', '_wpnonce', true, false));
|
|
printf('<input type="hidden" name="pen_name_id" value="%d">', esc_attr($pn->id));
|
|
printf('<input type="checkbox" name="pen_active" value="1"%s>', checked($pn->active, 1, false));
|
|
submit_button(esc_html__('Update', 'umbra-bookshelf'), 'secondary', 'bd_update_pen_status', false);
|
|
echo '</form> ';
|
|
printf('<form method="post" style="display:inline;">%s', wp_nonce_field('bd_delete_pen_name', '_wpnonce', true, false));
|
|
printf('<input type="hidden" name="pen_name_id" value="%d">', esc_attr($pn->id));
|
|
submit_button(esc_html__('Delete', 'umbra-bookshelf'), 'delete', 'bd_delete_pen_name', false, array('onclick' => 'return confirm("Delete this pen name?");'));
|
|
echo '</form>';
|
|
echo '</td></tr>';
|
|
}
|
|
} else {
|
|
echo '<tr><td colspan="5">' . esc_html__('No pen names found.', 'umbra-bookshelf') . '</td></tr>';
|
|
}
|
|
|
|
echo '</tbody></table></div>';
|
|
}
|
|
|
|
/**
|
|
* Settings admin page
|
|
*/
|
|
public function settings_page() {
|
|
if (isset($_POST['bd_save_settings'])) {
|
|
check_admin_referer('bd_settings');
|
|
|
|
$affiliates = array(
|
|
'amazon' => sanitize_text_field($_POST['affiliate_amazon'] ?? ''),
|
|
'apple' => sanitize_text_field($_POST['affiliate_apple'] ?? ''),
|
|
'barnes' => sanitize_text_field($_POST['affiliate_barnes'] ?? ''),
|
|
'kobo' => sanitize_text_field($_POST['affiliate_kobo'] ?? ''),
|
|
'google' => sanitize_text_field($_POST['affiliate_google'] ?? ''),
|
|
);
|
|
update_option('bdm_affiliate_codes', $affiliates);
|
|
|
|
$goodreads_id = sanitize_text_field($_POST['goodreads_author_id'] ?? '');
|
|
update_option('bdm_goodreads_author_id', $goodreads_id);
|
|
|
|
echo '<div class="updated"><p>' . esc_html__('Settings saved.', 'umbra-bookshelf') . '</p></div>';
|
|
}
|
|
|
|
$affiliates = get_option('bdm_affiliate_codes', array());
|
|
$goodreads_id = get_option('bdm_goodreads_author_id', '30469651');
|
|
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html__('BD BookHelper Settings', 'umbra-bookshelf') . '</h1>';
|
|
|
|
echo '<form method="post">';
|
|
wp_nonce_field('bd_settings');
|
|
|
|
echo '<h2>' . esc_html__('Affiliate Codes', 'umbra-bookshelf') . '</h2>';
|
|
echo '<p>' . esc_html__('Enter your affiliate IDs for each retailer. Links include these IDs when available.', 'umbra-bookshelf') . '</p>';
|
|
|
|
echo '<table class="form-table">';
|
|
$affiliate_fields = array(
|
|
'amazon' => 'Amazon',
|
|
'apple' => 'Apple Books',
|
|
'barnes' => 'Barnes & Noble',
|
|
'kobo' => 'Kobo',
|
|
'google' => 'Google Play',
|
|
);
|
|
|
|
foreach ($affiliate_fields as $key => $label) {
|
|
printf('<tr><th>%s</th>', esc_html($label));
|
|
printf('<td><input type="text" name="affiliate_%s" value="%s" class="regular-text"></td></tr>',
|
|
esc_attr($key), esc_attr($affiliates[$key] ?? '')
|
|
);
|
|
}
|
|
echo '</table>';
|
|
|
|
echo '<h2>' . esc_html__('Goodreads Integration', 'umbra-bookshelf') . '</h2>';
|
|
echo '<table class="form-table">';
|
|
printf('<tr><th>' . esc_html__('Goodreads Author ID', 'umbra-bookshelf') . '</th>');
|
|
printf('<td><input type="text" name="goodreads_author_id" value="%s" class="regular-text">', esc_attr($goodreads_id));
|
|
echo '<p class="description">' . esc_html__('Default: 30469651 (B_D_Murphy)', 'umbra-bookshelf') . '</p></td></tr>';
|
|
echo '</table>';
|
|
|
|
echo '<p class="description">' . esc_html__('Ratings are refreshed every 12 hours from Goodreads. Amazon ratings are not scraped.', 'umbra-bookshelf') . '</p>';
|
|
|
|
submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings');
|
|
echo '</form></div>';
|
|
}
|
|
|
|
/**
|
|
* Migration page - Import from BD Books plugin
|
|
*/
|
|
public function migrate_page() {
|
|
global $wpdb;
|
|
|
|
$old_table = $wpdb->prefix . 'bd_books';
|
|
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$old_table'") === $old_table;
|
|
|
|
if (isset($_POST['bd_run_migration']) && check_admin_referer('bd_migrate_nonce')) {
|
|
$result = Umbra_Bookshelf_Helper::migrate_from_bd_books();
|
|
if ($result['success']) {
|
|
echo '<div class="updated"><p>' . esc_html($result['message']) . '</p></div>';
|
|
} else {
|
|
echo '<div class="error"><p>' . esc_html($result['message']) . '</p></div>';
|
|
}
|
|
}
|
|
|
|
echo '<div class="wrap">';
|
|
echo '<h1>' . esc_html__('Migrate from BD Books', 'umbra-bookshelf') . '</h1>';
|
|
|
|
if (!$table_exists) {
|
|
echo '<div class="notice notice-warning">';
|
|
echo '<p>' . esc_html__('The old BD Books table was not found. No migration needed - the plugin data does not exist.', 'umbra-bookshelf') . '</p>';
|
|
echo '</div>';
|
|
echo '<p>' . esc_html__('This tool migrates books from the original "BD Books" (BD Book Listing) plugin to the new BookHelper system using custom post types.', 'umbra-bookshelf') . '</p>';
|
|
echo '</div>';
|
|
return;
|
|
}
|
|
|
|
$old_books = $wpdb->get_results("SELECT * FROM {$old_table}");
|
|
$old_count = count($old_books);
|
|
|
|
$new_books = get_posts(array(
|
|
'post_type' => 'book',
|
|
'post_status' => 'any',
|
|
'numberposts' => -1,
|
|
));
|
|
|
|
echo '<div class="notice notice-info">';
|
|
echo '<p>' . sprintf(esc_html__('Found %d books in the old BD Books table.', 'umbra-bookshelf'), $old_count) . '</p>';
|
|
echo '<p>' . sprintf(esc_html__('Current BookHelper has %d books.', 'umbra-bookshelf'), count($new_books)) . '</p>';
|
|
echo '</div>';
|
|
|
|
if ($old_count > 0) {
|
|
echo '<h2>' . esc_html__('Books to Migrate', 'umbra-bookshelf') . '</h2>';
|
|
echo '<table class="widefat">';
|
|
echo '<thead><tr><th>Title</th><th>Author</th><th>Cover</th></tr></thead>';
|
|
echo '<tbody>';
|
|
foreach ($old_books as $book) {
|
|
$has_cover = !empty($book->cover_image_id) || !empty($book->cover_image_url);
|
|
echo '<tr>';
|
|
echo '<td>' . esc_html($book->title) . '</td>';
|
|
echo '<td>' . esc_html($book->author) . '</td>';
|
|
echo '<td>' . ($has_cover ? esc_html__('Yes', 'umbra-bookshelf') : esc_html__('No', 'umbra-bookshelf')) . '</td>';
|
|
echo '</tr>';
|
|
}
|
|
echo '</tbody></table>';
|
|
|
|
echo '<p style="margin-top: 20px;"><strong>' . esc_html__('What gets migrated:', 'umbra-bookshelf') . '</strong></p>';
|
|
echo '<ul>';
|
|
echo '<li>' . esc_html__('Book titles and authors (as pen names)', 'umbra-bookshelf') . '</li>';
|
|
echo '<li>' . esc_html__('Descriptions become blurbs in English', 'umbra-bookshelf') . '</li>';
|
|
echo '<li>' . esc_html__('Cover images (WordPress media or external URLs)', 'umbra-bookshelf') . '</li>';
|
|
echo '<li>' . esc_html__('Purchase links to retailer fields', 'umbra-bookshelf') . '</li>';
|
|
echo '</ul>';
|
|
|
|
echo '<form method="post" style="margin-top: 20px;">';
|
|
wp_nonce_field('bd_migrate_nonce');
|
|
echo '<input type="submit" name="bd_run_migration" class="button button-primary" value="' . esc_attr__('Run Migration', 'umbra-bookshelf') . '" onclick="return confirm(\'' . esc_js(__('This will create new book entries for any books not already migrated. Continue?', 'umbra-bookshelf')) . '\');">';
|
|
echo '</form>';
|
|
} else {
|
|
echo '<p>' . esc_html__('No books found in the old table to migrate.', 'umbra-bookshelf') . '</p>';
|
|
}
|
|
|
|
echo '</div>';
|
|
}
|
|
|
|
/**
|
|
* AJAX handler for updating links
|
|
*/
|
|
public function ajax_update_links() {
|
|
check_ajax_referer('bd_update_links');
|
|
|
|
$book_id = absint($_POST['book_id']);
|
|
|
|
wp_send_json_success(array('message' => esc_html__('Links updated.', 'umbra-bookshelf')));
|
|
}
|
|
}
|