Files
Bookshelf/umbra-bookshelf/umbra-bookshelf.php
bdm dec732931c Consolidate three plugins into Umbra Bookshelf
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.
2026-10-18 19:55:36 -07:00

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();