Files
Umbra-Bookshelf/umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md
bdm 0bd78e587a Resolve the known issues; switch licence to GPL
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.
2026-10-22 09:47:00 -07:00

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 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

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.