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.
14 KiB
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 | 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_URIwere removed in favour of the singleUMBRA_BOOKSHELF_DIR/UMBRA_BOOKSHELF_URLpair. - 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 |
manage_options |
|
umbra-bookshelf-newsletter |
manage_options |
|
umbra-bookshelf-email-settings |
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, recordbd_bookshelf_activation_time.
- roles (
- Update: plugin updates do not re-run activation, so
admin_initre-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_refreshandbd_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_seriesare dropped once), deletebd_bookshelf_%options and transients, delete thebdm_*options, clear cron. Guarded byWP_UNINSTALL_PLUGIN. Set-asidebd_*_legacy_librarytables are not dropped; removing those is a deliberate operator action (seedocs/TASKS.md§4).
7. Verification
umbra-bookshelf/tests/run_all.sh # every suite
or individually:
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.