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

302 lines
14 KiB
Markdown

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