# Umbra Bookshelf — Plugin Specification **Status:** Active **Version:** 1.1.0 **Supersedes:** `spec/archive/SPEC_BD_Bookshelf_Plugin.txt`, `spec/archive/SPEC_BD_BookHelper_Plugin.txt` **License:** GPL-2.0-or-later (WordPress.org compatible) --- ## 1. Identity | Field | Value | |---|---| | Plugin name | Umbra Bookshelf | | Slug / folder | `umbra-bookshelf` | | Entry point | `umbra-bookshelf.php` | | Text domain | `umbra-bookshelf` | | Domain path | `/languages` | | Author | Umbra Tools | | Requires | WordPress 6.0, PHP 7.4 | | Version | 1.1.0 | The three pre-merge plugins map onto modules as follows: | Former plugin | Former version | Module | Code location | |---|---|---|---| | BD Bookshelf | 0.1.0 | **Books** (catalogue) | `includes/books/` | | BD BookShelfHelper | 2.0.0 | **Library** | `includes/library/` | | BD SimpleEmail | 2.0.0 | **Email** | `includes/email/` | --- ## 2. Naming rules Two categories of identifier, treated differently on purpose. ### 2.1 Renamed to the Umbra Bookshelf identity * Plugin header fields (`Plugin Name`, `Plugin URI`, `Author`, `Author URI`, `License`). * Text domain: `bd-bookshelf`, `bd-bookshelfhelper`, `bd-simpleemail` → `umbra-bookshelf`. * PHP classes: `BD_Bookshelf`, `BDM_*`, `BD_BookShelfHelper*`, `BD_SimpleEmail*`, `BD_Email_Sender` → `Umbra_*`. * Constants: `BD_BOOKSHELF_*` → `UMBRA_BOOKSHELF_*`; `BD_BHS_VERSION` → `UMBRA_BOOKSHELF_LIBRARY_VERSION`; `BD_EMAIL_*` → `UMBRA_BOOKSHELF_EMAIL_VERSION`. `BD_BHS_DIR` / `BD_EMAIL_URI` were removed in favour of the single `UMBRA_BOOKSHELF_DIR` / `UMBRA_BOOKSHELF_URL` pair. * Asset handles, one per module so they cannot shadow each other: `umbra-bookshelf-admin`, `umbra-bookshelf-library-admin`, `umbra-bookshelf-email-admin`, `umbra-bookshelf-gdpr`. * JavaScript globals: `bdBookshelfAdmin` → `umbraBookshelfAdmin`, `bdBookshelfGdpr` → `umbraBookshelfGdpr`, `bdmAdmin` → `umbraBookshelfLibraryAdmin`. * Admin menu slugs (see §5). ### 2.2 Deliberately **not** renamed These are the persisted contract with live installations and with published post content. Renaming any of them silently orphans existing data. | Contract | Values | |---|---| | Table names | `bd_books`, `bd_series`, `bd_book_authors`, `bd_authors`, `bd_book_seo`, `bd_retail_links`, `bd_retail_sites`, `bd_shortcodes`, `bd_shortcode_clicks`, `bd_book_meta`, `bd_pen_names`, `bd_reviews`, `bd_ratings`, `bd_review_shortlinks`, `bd_subscribers`, `bd_newsletters` | | Option keys | `bd_bookshelf_db_version`, `bd_bookshelf_activation_time`, `bdm_affiliate_codes`, `bdm_goodreads_author_id`, `bdm_awards`, `bdm_gmail_address`, `bdm_gmail_password`, `bdm_sender_name`, `bdm_reply_to`, `bdm_gemini_api_key` | | Post meta | `_bd_bookshelf_book_id` | | Transient prefix | `_transient_bd_bookshelf_*` | | Nonce actions | `bd_bookshelf_admin_nonce`, `bd_bookshelf_import_posts`, `bd_bookshelf_import_csv`, `bd_bookshelf_admin`, `bd_bookshelf_gdpr_nonce` | | Cron hooks | `bd_bookshelf_sync_ratings` (active). `bd_bookshelf_goodreads_refresh` and `bd_bookshelf_amazon_scrape` are **retired** but still cleared on deactivate/uninstall so upgrades leave no stale schedules | | AJAX actions | `bd_bookshelf_*`, `bdm_update_links`, `bdm_autosave`, `bdm_ajax_signup`, `bdm_signup_submit`, `bdm_unsubscribe_submit` | | Shortcodes | `[bd-book]`, `[bd-books]`, `[bdm_signup]` | | Content types | post type `book`, taxonomy `series` | | Cookies | `bd_bookshelf_consent` | | CSS classes | `bd-bookshelf-*`, `bd-email-*` (paired HTML/CSS, may be styled by themes) | One legacy PHP class name is retained by design: `class-umbra-theme-compatibility.php` declares `Umbra_Theme_Compatibility` and then `class_alias('Umbra_Theme_Compatibility', 'BD_BookShelfHelper')` so the unmodified `theme/` (bd-bookshelf) theme's static `BD_BookShelfHelper::` calls keep working. --- ## 3. Architecture ``` umbra-bookshelf/ ├── umbra-bookshelf.php bootstrap: constants, requires, singleton, single top-level menu ├── uninstall.php drops every table and option from all three modules ├── includes/ │ ├── class-umbra-schema-migration.php reconciles pre-consolidation schemas │ ├── books/ catalogue: books, authors, series, retail, ratings, │ │ shortcodes, SEO, GDPR, Goodreads scraper, admin, import │ ├── library/ book post type + taxonomy, pen names, data entry, │ │ shortlinks, reviews, ratings, migrate │ └── email/ subscribers, newsletters, AI-assisted composition, sending ├── admin/ css, js catalogue admin assets ├── public/ css, js front-end + GDPR consent assets ├── library/ assets library admin assets ├── email/ assets email admin assets ├── assets/ images default-cover.png ├── languages/ translation catalogues ├── spec/ this document; archive/ holds pre-merge specs ├── docs/ architecture and known issues └── tests/ verify_structure.py, smoke_runtime.php ``` **Boot order.** Modules are constructed on `plugins_loaded`, not `init`. Several modules register their own `init` callbacks (post type, taxonomy, shortcodes, rewrite rules); constructing them from inside an `init` callback makes it accidental whether those callbacks run. A `$modules_initialized` flag prevents double construction. --- ## 4. Module responsibilities ### 4.1 Books (catalogue) — `includes/books/` Owns the custom-table catalogue model: books with ISBN-10/13 and ASIN variants, author records and pen-name linkage, series, retail sites and per-format retail links, Goodreads and Amazon rating synchronisation, SEO rows, shortlink click tracking, GDPR consent, and CSV/post import. Classes: `Umbra_Database`, `Umbra_Book_Manager`, `Umbra_Authors`, `Umbra_Series`, `Umbra_Retail_Sites`, `Umbra_Rating_Sync`, `Umbra_Shortcodes`, `Umbra_SEO`, `Umbra_GDPR`, `Umbra_Goodreads_Scraper`, `Umbra_Admin`, `Umbra_Migration`, `Umbra_Theme_Compatibility`. **This module owns `{prefix}bd_books` and `{prefix}bd_series`.** Its schema is canonical; the library module no longer creates either table. **Goodreads is the only rating source.** `Umbra_Goodreads_Scraper` is the single scraper; `Umbra_Rating_Sync` delegates all HTML parsing to it. Amazon rating scraping was removed. The `rating_amazon` column and Amazon retail links are unaffected. `Umbra_SEO` supplies per-book meta description, Open Graph, Twitter cards and sitemap weighting for generated book detail pages. Completing it is `docs/TASKS.md` §2. ### 4.2 Library — `includes/library/` Owns the WordPress-native model: registers post type `book` and taxonomy `series`, pen names, a focused data-entry screen, shortlink handling, reviews and ratings, and a migration screen for data created by earlier plugins. Classes: `Umbra_Bookshelf_Helper`, `Umbra_Bookshelf_Helper_Admin`, `Umbra_Bookshelf_Helper_Install`. Owns `bd_book_meta`, `bd_pen_names`, `bd_reviews`, `bd_ratings` and `bd_review_shortlinks`. It does **not** create `bd_books` or `bd_series` — see §5a. ### 4.3 Email — `includes/email/` Owns newsletter delivery: subscriber and newsletter tables, `[bdm_signup]`, signup and unsubscribe form handling (AJAX and `admin_post`), Gemini-assisted newsletter composition, and SMTP/Gmail sending. Classes: `Umbra_Simple_Email`, `Umbra_Simple_Email_Admin`, `Umbra_Simple_Email_Install`, `Umbra_Email_Sender`. --- ## 5. Admin menu One top-level menu, registered exactly once by the bootstrap at `admin_menu` priority 9. Each module contributes only submenus beneath it. **Umbra Bookshelf** (`umbra-bookshelf`, `dashicons-book-alt`, position 30) | Submenu slug | Module | Capability | |---|---|---| | `umbra-bookshelf` | bootstrap | `edit_posts` | | `umbra-bookshelf-all` | books | `manage_options` | | `umbra-bookshelf-add` | books | `manage_options` | | `umbra-bookshelf-authors` | books | `manage_options` | | `umbra-bookshelf-series` | books | `manage_options` | | `umbra-bookshelf-sites` | books | `manage_options` | | `umbra-bookshelf-shortcodes` | books | `manage_options` | | `umbra-bookshelf-sync` | books | `manage_options` | | `umbra-bookshelf-settings` | books | `manage_options` | | `umbra-bookshelf-import` | books | `manage_options` | | `edit.php?post_type=book` | library | `edit_posts` | | `post-new.php?post_type=book` | library | `edit_posts` | | `umbra-bookshelf-data-entry` | library | `edit_posts` | | `edit-tags.php?taxonomy=series&post_type=book` | library | `manage_categories` | | `umbra-bookshelf-pen-names` | library | `manage_options` | | `umbra-bookshelf-library-settings` | library | `manage_options` | | `umbra-bookshelf-migrate` | library | `manage_options` | | `umbra-bookshelf-subscribers` | email | `manage_options` | | `umbra-bookshelf-newsletter` | email | `manage_options` | | `umbra-bookshelf-email-settings` | email | `manage_options` | Catalogue and library settings are deliberately **separate** screens with separate slugs; the pre-merge plugins both claimed `bd-bookshelf-settings`, so one of the two was unreachable. --- ## 5a. Schema ownership and reconciliation `bd_books` and `bd_series` are owned by the catalogue module. Before the consolidation the library module created the same two table names with a different column set, so whichever plugin activated first won and the other read and wrote against the wrong shape. `Umbra_Schema_Migration` reconciles a library-shaped installation. It runs **before** `dbDelta`, on activation and on `admin_init` when the recorded schema version is behind, because plugin updates do not re-run activation hooks. Ordering matters: `dbDelta` does not drop columns, so pointed at a library-shaped table it would graft catalogue columns on and leave a table satisfying neither schema. The migration therefore ensures the table is either correctly catalogue-shaped or absent before `dbDelta` is allowed near it. Behaviour: | Step | Detail | |---|---| | Detect | `bd_books` is library-shaped if it has `post_id` and no `title`; `bd_series` if it has `blurb` and no `description` | | Set aside | `RENAME TABLE` to `bd_*_legacy_library`. **Never dropped** — rows stay recoverable | | Create | canonical schema via `Umbra_Database::create_tables()` | | Copy | series first, so `books.series_id` still resolves | | Preserve | book IDs (keeps `bd_book_meta`, `bd_reviews`, `bd_ratings`, `bd_review_shortlinks` valid) | | Map | `title`/`slug`/`description` from the linked post; library's `isbn`/`asin` onto the ebook columns; `amazon_asin` also set so product links keep working | | Re-link | writes `_bd_bookshelf_book_id`; keeps `publish_date` as `_bd_bookshelf_publish_date` (no catalogue column exists) | | Fold ratings | `bd_ratings` goodreads/amazon values into `rating_goodreads` / `rating_amazon`, only where still zero | | Skip | rows whose post no longer exists, with a log entry, rather than creating an unusable book | | De-duplicate | slugs, because the catalogue's `slug` column is `UNIQUE` | Tracked by the `umbra_bookshelf_schema_version` option (current target: `2`). Re-running is a no-op. A failure does not advance the version, so the next attempt retries. ## 6. Lifecycle * **Activate:** reconcile the schema (`Umbra_Schema_Migration::maybe_migrate()`), then create catalogue tables (`Umbra_Database::create_tables()`), library tables + roles (`Umbra_Bookshelf_Helper_Install::activate()`), email tables (`Umbra_Simple_Email_Install::activate()`), flush rewrites, record `bd_bookshelf_activation_time`. * **Update:** plugin updates do not re-run activation, so `admin_init` re-checks the schema version and reconciles if it is behind. * **Deactivate:** clear all cron hooks named in §2.2 — including the retired `bd_bookshelf_goodreads_refresh` and `bd_bookshelf_amazon_scrape`, so no stale schedules survive an upgrade — run module deactivators, flush rewrites. * **Uninstall:** drop every table across all three modules (deduplicated so `bd_books`/`bd_series` are dropped once), delete `bd_bookshelf_%` options and transients, delete the `bdm_*` options, clear cron. Guarded by `WP_UNINSTALL_PLUGIN`. Set-aside `bd_*_legacy_library` tables are **not** dropped; removing those is a deliberate operator action (see `docs/TASKS.md` §4). --- ## 7. Verification ```bash umbra-bookshelf/tests/run_all.sh # every suite ``` or individually: ```bash python3 umbra-bookshelf/tests/verify_structure.py # static checks php umbra-bookshelf/tests/smoke_runtime.php front # front-end boot php umbra-bookshelf/tests/smoke_runtime.php admin # admin boot + menu shape php umbra-bookshelf/tests/schema_migration_test.php # migration vs. a real database ``` The static check enforces: valid PHP syntax, no class declared twice, no reference to an undefined class, every `require_once` and enqueued asset resolves on disk, no leftover pre-merge identifiers in shipped source, and all preserved contracts of §2.2 still present. The runtime check stubs WordPress, loads the bootstrap, fires `plugins_loaded`, `init` and `admin_menu`, and asserts the post type, taxonomy and shortcodes register, that exactly one top-level menu appears with all 20 expected submenus under it, that no callback is registered twice on a hook, and that asset handles are unique per module. The declared version is checked against the plugin header rather than a literal, so a version bump cannot silently break it. The schema check drives a throwaway SQLite database through the `sqlite3` CLI — no credentials, no shared server — and verifies shape detection, row mapping, ID preservation, orphan handling, rating folding, idempotency and slug collisions against real data. MySQL `RENAME TABLE` is translated to SQLite's `ALTER TABLE ... RENAME TO` by the shim, since the migration itself targets MySQL. --- ## 8. Open issues and tasks The three problems that blocked the consolidation — the dual `bd_books` / `bd_series` schemas, duplicated scraping, and the Apache/GPL licence mismatch — are resolved as of 1.1.0. Remaining, in `docs/KNOWN-ISSUES.md`: the rating sync blocks a worker with `sleep()`; the catalogue and library still model a book differently, with no synchronisation between them; and set-aside legacy tables are never reclaimed. Intended work, in `docs/TASKS.md`: the email module's admin behaviour, completing SEO for generated book detail pages, batching the rating sync, cleaning up legacy tables, reconciling the two book models, and retiring the `Old/` archive.