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.
347 lines
11 KiB
PHP
347 lines
11 KiB
PHP
<?php
|
|
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
/**
|
|
* Plugin Name: Umbra Bookshelf
|
|
* Plugin URI: https://umbratools.com/plugins/umbra-bookshelf
|
|
* Description: Complete book management for authors and publishers: catalogue and ISBN tracking, series and pen names, retail links, rating synchronisation, GDPR consent, data entry, migration tooling, and newsletter delivery.
|
|
* Version: 1.1.0
|
|
* Requires at least: 6.0
|
|
* Requires PHP: 7.4
|
|
* Author: Umbra Tools
|
|
* Author URI: https://umbratools.com
|
|
* License: GPLv2 or later
|
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
|
* Text Domain: umbra-bookshelf
|
|
* Domain Path: /languages
|
|
*
|
|
* Umbra Bookshelf is the consolidation of three previously separate plugins:
|
|
* - BD Bookshelf -> includes/books
|
|
* - BD BookShelfHelper -> includes/library
|
|
* - BD SimpleEmail -> includes/email
|
|
*
|
|
* The top-level admin menu is registered exactly once, here. Each module
|
|
* contributes only submenus beneath the shared "umbra-bookshelf" parent.
|
|
*
|
|
* @package Umbra_Bookshelf
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Plugin constants
|
|
*/
|
|
define('UMBRA_BOOKSHELF_VERSION', '1.1.0');
|
|
define('UMBRA_BOOKSHELF_FILE', __FILE__);
|
|
define('UMBRA_BOOKSHELF_DIR', plugin_dir_path(__FILE__));
|
|
define('UMBRA_BOOKSHELF_URL', plugin_dir_url(__FILE__));
|
|
define('UMBRA_BOOKSHELF_BASENAME', plugin_basename(__FILE__));
|
|
|
|
/**
|
|
* Module version constants
|
|
*
|
|
* Storage keys (table names, option names, meta keys, cron hooks, nonces and
|
|
* shortcodes) are deliberately NOT renamed. They are the persisted contract
|
|
* with existing installations and with published post content.
|
|
*/
|
|
define('UMBRA_BOOKSHELF_LIBRARY_VERSION', '2.0.0');
|
|
define('UMBRA_BOOKSHELF_EMAIL_VERSION', '2.0.0');
|
|
|
|
/**
|
|
* Load module files
|
|
*/
|
|
|
|
// Schema reconciliation (catalogue tables are canonical; runs before dbDelta)
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/class-umbra-schema-migration.php';
|
|
|
|
// Books / catalogue module (ex-BD Bookshelf)
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-database.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-book-manager.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-authors.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-series.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-retail-sites.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-rating-sync.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-shortcodes.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-seo.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-gdpr.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-goodreads-scraper.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-admin.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-migration.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-theme-compatibility.php';
|
|
|
|
// Library module (ex-BD BookShelfHelper)
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/library/class-umbra-bookshelf-helper.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/library/class-umbra-library-admin.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/library/class-umbra-library-install.php';
|
|
|
|
// Email module (ex-BD SimpleEmail)
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/email/class-umbra-simple-email.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/email/class-umbra-email-admin.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/email/class-umbra-email-install.php';
|
|
require_once UMBRA_BOOKSHELF_DIR . 'includes/email/class-umbra-email-sender.php';
|
|
|
|
/**
|
|
* Main plugin class
|
|
*/
|
|
final class Umbra_Bookshelf {
|
|
|
|
/**
|
|
* Single instance of the class
|
|
*
|
|
* @var Umbra_Bookshelf|null
|
|
*/
|
|
private static $instance = null;
|
|
|
|
/**
|
|
* Top-level admin menu slug. Shared by every module.
|
|
*/
|
|
const MENU_SLUG = 'umbra-bookshelf';
|
|
|
|
/**
|
|
* Guard so modules are never constructed twice in one request.
|
|
*
|
|
* @var bool
|
|
*/
|
|
private $modules_initialized = false;
|
|
|
|
/**
|
|
* Get single instance
|
|
*
|
|
* @return Umbra_Bookshelf
|
|
*/
|
|
public static function instance() {
|
|
if (is_null(self::$instance)) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
private function __construct() {
|
|
$this->init_hooks();
|
|
}
|
|
|
|
/**
|
|
* Initialize hooks
|
|
*/
|
|
private function init_hooks() {
|
|
register_activation_hook(UMBRA_BOOKSHELF_FILE, [$this, 'activate']);
|
|
register_deactivation_hook(UMBRA_BOOKSHELF_FILE, [$this, 'deactivate']);
|
|
|
|
add_action('init', [$this, 'load_textdomain']);
|
|
add_action('admin_menu', [$this, 'register_admin_menu'], 9);
|
|
|
|
// Modules MUST be constructed on plugins_loaded, not init. Several of
|
|
// them register their own 'init' callbacks (post type, taxonomy,
|
|
// shortcodes, rewrite rules). Constructing them from an init callback
|
|
// means those init callbacks are added while 'init' is already being
|
|
// dispatched, so whether they run becomes an ordering accident.
|
|
add_action('plugins_loaded', [$this, 'init_modules']);
|
|
|
|
// Plugin *updates* do not re-run the activation hook, so a site
|
|
// upgrading from the old plugins would never be reconciled. Check on
|
|
// admin_init instead: cheap (one option read), and keeps schema work
|
|
// off front-end requests.
|
|
add_action('admin_init', [$this, 'maybe_upgrade_schema'], 5);
|
|
}
|
|
|
|
/**
|
|
* Reconcile a pre-consolidation schema on plugin update.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function maybe_upgrade_schema() {
|
|
$version = (int) get_option(Umbra_Schema_Migration::VERSION_OPTION, 0);
|
|
|
|
if ($version < Umbra_Schema_Migration::TARGET_VERSION) {
|
|
Umbra_Schema_Migration::maybe_migrate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load translations
|
|
*/
|
|
public function load_textdomain() {
|
|
load_plugin_textdomain(
|
|
'umbra-bookshelf',
|
|
false,
|
|
dirname(UMBRA_BOOKSHELF_BASENAME) . '/languages'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Register the single top-level admin menu.
|
|
*
|
|
* Runs at priority 9 so the parent exists before any module appends its
|
|
* submenus at the default priority 10.
|
|
*/
|
|
public function register_admin_menu() {
|
|
add_menu_page(
|
|
__('Umbra Bookshelf', 'umbra-bookshelf'),
|
|
__('Umbra Bookshelf', 'umbra-bookshelf'),
|
|
'edit_posts',
|
|
self::MENU_SLUG,
|
|
[$this, 'render_dashboard'],
|
|
'dashicons-book-alt',
|
|
30
|
|
);
|
|
|
|
// First entry is conventionally a copy of the parent, pointing at the
|
|
// catalogue overview.
|
|
add_submenu_page(
|
|
self::MENU_SLUG,
|
|
__('Dashboard', 'umbra-bookshelf'),
|
|
__('Dashboard', 'umbra-bookshelf'),
|
|
'edit_posts',
|
|
self::MENU_SLUG,
|
|
[$this, 'render_dashboard']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render the dashboard / catalogue overview.
|
|
*/
|
|
public function render_dashboard() {
|
|
if (!current_user_can('edit_posts')) {
|
|
wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'umbra-bookshelf'));
|
|
}
|
|
|
|
$counts = array(
|
|
'books' => $this->count_books(),
|
|
'series' => $this->count_series(),
|
|
);
|
|
|
|
?>
|
|
<div class="wrap">
|
|
<h1><?php esc_html_e('Umbra Bookshelf', 'umbra-bookshelf'); ?></h1>
|
|
|
|
<div class="umbra-bookshelf-dashboard">
|
|
<div class="umbra-bookshelf-dashboard-card">
|
|
<h2><?php esc_html_e('Books', 'umbra-bookshelf'); ?></h2>
|
|
<p class="umbra-bookshelf-dashboard-count"><?php echo esc_html(number_format_i18n($counts['books'])); ?></p>
|
|
</div>
|
|
|
|
<div class="umbra-bookshelf-dashboard-card">
|
|
<h2><?php esc_html_e('Series', 'umbra-bookshelf'); ?></h2>
|
|
<p class="umbra-bookshelf-dashboard-count"><?php echo esc_html(number_format_i18n($counts['series'])); ?></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Number of books visible to the current user.
|
|
*
|
|
* @return int
|
|
*/
|
|
private function count_books() {
|
|
$counts = wp_count_posts('book');
|
|
|
|
return isset($counts->publish) ? (int) $counts->publish : 0;
|
|
}
|
|
|
|
/**
|
|
* Number of series terms.
|
|
*
|
|
* @return int
|
|
*/
|
|
private function count_series() {
|
|
$terms = get_terms(array(
|
|
'taxonomy' => 'series',
|
|
'hide_empty' => false,
|
|
'fields' => 'count',
|
|
));
|
|
|
|
return is_wp_error($terms) ? 0 : (int) $terms;
|
|
}
|
|
|
|
/**
|
|
* Initialize modules
|
|
*/
|
|
public function init_modules() {
|
|
if ($this->modules_initialized) {
|
|
return;
|
|
}
|
|
$this->modules_initialized = true;
|
|
|
|
// Catalogue module
|
|
new Umbra_Shortcodes();
|
|
new Umbra_GDPR();
|
|
new Umbra_Rating_Sync();
|
|
|
|
// SEO metadata for book detail pages: per-book title/description/
|
|
// keywords, Open Graph, Twitter cards and sitemap weighting.
|
|
new Umbra_SEO();
|
|
|
|
// Library module (post type, taxonomy, shortcodes, pen names)
|
|
Umbra_Bookshelf_Helper::instance();
|
|
|
|
// Email module (signup, unsubscribe, newsletter delivery)
|
|
Umbra_Simple_Email::instance();
|
|
|
|
if (is_admin()) {
|
|
new Umbra_Admin();
|
|
new Umbra_Migration();
|
|
Umbra_Bookshelf_Helper_Admin::instance();
|
|
Umbra_Simple_Email_Admin::instance();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Plugin activation
|
|
*
|
|
* Reconciles a pre-consolidation schema first, then creates tables. See
|
|
* Umbra_Schema_Migration for why the ordering matters.
|
|
*/
|
|
public function activate() {
|
|
// Reconcile any pre-consolidation schema BEFORE dbDelta runs, so the
|
|
// catalogue's dbDelta() sees either a correct catalogue table or none
|
|
// at all -- never a library-shaped table it would graft columns onto.
|
|
Umbra_Schema_Migration::maybe_migrate();
|
|
|
|
$database = new Umbra_Database();
|
|
$database->create_tables();
|
|
|
|
Umbra_Bookshelf_Helper_Install::activate();
|
|
Umbra_Simple_Email_Install::activate();
|
|
|
|
flush_rewrite_rules();
|
|
|
|
// Preserved option key (existing installations already have this).
|
|
update_option('bd_bookshelf_activation_time', current_time('timestamp'));
|
|
}
|
|
|
|
/**
|
|
* Plugin deactivation
|
|
*/
|
|
public function deactivate() {
|
|
// Preserved cron hook names.
|
|
wp_clear_scheduled_hook('bd_bookshelf_sync_ratings');
|
|
wp_clear_scheduled_hook('bd_bookshelf_goodreads_refresh');
|
|
wp_clear_scheduled_hook('bd_bookshelf_amazon_scrape');
|
|
|
|
Umbra_Bookshelf_Helper_Install::deactivate();
|
|
Umbra_Simple_Email_Install::deactivate();
|
|
|
|
flush_rewrite_rules();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize plugin
|
|
*
|
|
* @return Umbra_Bookshelf
|
|
*/
|
|
function umbra_bookshelf() {
|
|
return Umbra_Bookshelf::instance();
|
|
}
|
|
|
|
// Start the plugin
|
|
umbra_bookshelf();
|