Merge BD Bookshelf (0.1.0), BD BookShelfHelper (2.0.0) and BD SimpleEmail
(2.0.0) into a single plugin released as Umbra Bookshelf 1.0.0, with the
BD Bookshelf theme left as a separate, unrenamed artifact.
Modules
includes/books/ catalogue, ISBN/ASIN, series, retail, ratings, shortcodes,
SEO, GDPR, scrapers, admin, CSV/post import
includes/library/ book post type + series taxonomy, pen names, data entry,
shortlinks, reviews, migrate
includes/email/ subscribers, newsletters, composition, sending
Renamed: plugin headers, text domains (all three -> umbra-bookshelf), classes
(BD_*/BDM_* -> Umbra_*), constants (-> UMBRA_BOOKSHELF_*), asset handles, JS
globals and admin slugs.
Deliberately not renamed -- persisted contract with live installs and with
published post content: table names, option keys, post meta keys, transients,
nonce actions, cron hooks, AJAX actions, shortcodes ([bd-book], [bd-books],
[bdm_signup]), post type `book`, taxonomy `series`, cookies and CSS classes.
BD_BookShelfHelper survives only as a class_alias for the unmodified theme.
Collisions resolved
* class-umbra-theme-compatibility.php declared BD_BookShelfHelper, the same
name as the library module's helper. Latent while the plugins were
separate (only fired when both were active), but an unconditional fatal
once merged. Renamed to Umbra_Theme_Compatibility with a class_alias.
* All three modules called add_menu_page. One top-level menu is now
registered by the bootstrap at admin_menu priority 9.
* BD Bookshelf and BookShelfHelper both claimed bd-bookshelf-settings, leaving
one Settings screen unreachable -> umbra-bookshelf-settings and
umbra-bookshelf-library-settings.
* Catalogue and library both enqueued bd-bookshelf-admin; the second enqueue
was silently discarded -> unique handles per module.
* Umbra_Admin and Umbra_Migration both registered bd_bookshelf_import_csv, so
one handler silently replaced the other. The import page now lives only in
Umbra_Admin (the variant wired to the admin JS); Umbra_Migration keeps only
the preview/run endpoints it uniquely provides.
* Modules were constructed from an init callback, so their own init
registrations raced the hook being dispatched. Now constructed on
plugins_loaded behind a re-entrancy guard.
Also
* Missing assets created: assets/images/default-cover.png (referenced but
never committed, so every cover-less book 404'd) and email/assets/js/
admin.js (enqueued on every email admin page but never committed).
* Library module's strpos($hook, 'bd-bookshelf') asset check matched every
catalogue and email screen once slugs shared the umbra-bookshelf- prefix;
now matched against its own slug list.
* Consolidated uninstall deduplicates the double drop of bd_books/bd_series.
* Specs aligned to the new name; pre-merge specs retained under
spec/archive/ with provenance notes.
* Tests added: tests/verify_structure.py (static) and tests/smoke_runtime.php
(WordPress-stub runtime boot, front-end and admin contexts).
Deferred and recorded in docs/KNOWN-ISSUES.md: the catalogue and library
modules still create bd_books and bd_series with two incompatible schemas
(pre-existing, now unconditional), and Goodreads/Amazon scraping is still
implemented twice on shared cron hooks.
319 lines
10 KiB
PHP
319 lines
10 KiB
PHP
<?php
|
|
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
/**
|
|
* 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.0.0
|
|
* Requires at least: 6.0
|
|
* Requires PHP: 7.4
|
|
* Author: Umbra Tools
|
|
* Author URI: https://umbratools.com
|
|
* License: Apache-2.0
|
|
* License URI: https://www.apache.org/licenses/LICENSE-2.0
|
|
* 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.0.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
|
|
*/
|
|
|
|
// 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-amazon-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']);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// 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
|
|
*
|
|
* Runs each module's installer. See docs/KNOWN-ISSUES.md item 1: the
|
|
* catalogue and library modules both create bd_books / bd_series with
|
|
* different schemas, which is a pre-existing conflict carried over from
|
|
* the separate plugins and deliberately not resolved in this release.
|
|
*/
|
|
public function activate() {
|
|
$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();
|