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.
309 lines
10 KiB
PHP
309 lines
10 KiB
PHP
<?php
|
|
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
/**
|
|
* Database management class
|
|
*
|
|
* @package Umbra_Bookshelf
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class Umbra_Database {
|
|
|
|
/**
|
|
* Get table names with prefix
|
|
*/
|
|
public static function table($name) {
|
|
global $wpdb;
|
|
return $wpdb->prefix . 'bd_' . $name;
|
|
}
|
|
|
|
/**
|
|
* Create all database tables
|
|
*/
|
|
public function create_tables() {
|
|
global $wpdb;
|
|
|
|
$charset_collate = $wpdb->get_charset_collate();
|
|
|
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
|
|
|
// Books table
|
|
$sql_books = "CREATE TABLE " . self::table('books') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
title varchar(255) NOT NULL,
|
|
slug varchar(255) NOT NULL,
|
|
description longtext,
|
|
cover_image varchar(500),
|
|
isbn_ebook varchar(20),
|
|
isbn_paperback varchar(20),
|
|
isbn_hardcover varchar(20),
|
|
isbn_audio varchar(20),
|
|
asin_ebook varchar(20),
|
|
asin_paperback varchar(20),
|
|
asin_hardcover varchar(20),
|
|
asin_audio varchar(20),
|
|
amazon_asin varchar(20),
|
|
goodreads_id varchar(20),
|
|
rating_amazon decimal(3,2) DEFAULT 0.00,
|
|
rating_goodreads decimal(3,2) DEFAULT 0.00,
|
|
last_sync datetime,
|
|
series_id bigint(20) unsigned,
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uk_slug (slug),
|
|
KEY idx_series_id (series_id)
|
|
) $charset_collate;";
|
|
|
|
// Book authors table
|
|
$sql_authors = "CREATE TABLE " . self::table('book_authors') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
book_id bigint(20) unsigned NOT NULL,
|
|
author_name varchar(255) NOT NULL,
|
|
author_id bigint(20) unsigned,
|
|
goodreads_author_id varchar(20),
|
|
is_primary tinyint(1) DEFAULT 0,
|
|
sort_order int(11) DEFAULT 0,
|
|
PRIMARY KEY (id),
|
|
KEY idx_book_id (book_id),
|
|
KEY idx_author_id (author_id)
|
|
) $charset_collate;";
|
|
|
|
// Series table
|
|
$sql_series = "CREATE TABLE " . self::table('series') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
name varchar(255) NOT NULL,
|
|
description longtext,
|
|
cover_image varchar(500),
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id)
|
|
) $charset_collate;";
|
|
|
|
// Retail sites table
|
|
$sql_sites = "CREATE TABLE " . self::table('retail_sites') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
name varchar(100) NOT NULL,
|
|
url_pattern text NOT NULL,
|
|
icon varchar(255),
|
|
affiliate_param varchar(50),
|
|
affiliate_code varchar(100),
|
|
is_custom tinyint(1) DEFAULT 0,
|
|
is_active tinyint(1) DEFAULT 1,
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id)
|
|
) $charset_collate;";
|
|
|
|
// Retail links table
|
|
$sql_links = "CREATE TABLE " . self::table('retail_links') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
book_id bigint(20) unsigned NOT NULL,
|
|
site_id bigint(20) unsigned NOT NULL,
|
|
format varchar(20) NOT NULL,
|
|
url text NOT NULL,
|
|
is_custom tinyint(1) DEFAULT 0,
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_book_id (book_id),
|
|
KEY idx_site_id (site_id),
|
|
KEY idx_format (format)
|
|
) $charset_collate;";
|
|
|
|
// Custom shortcodes table
|
|
$sql_shortcodes = "CREATE TABLE " . self::table('shortcodes') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
slug varchar(100) NOT NULL,
|
|
target_url text NOT NULL,
|
|
redirect_type varchar(10) DEFAULT '301',
|
|
description text,
|
|
click_count bigint(20) DEFAULT 0,
|
|
is_active tinyint(1) DEFAULT 1,
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uk_slug (slug)
|
|
) $charset_collate;";
|
|
|
|
// Shortcode clicks table
|
|
$sql_clicks = "CREATE TABLE " . self::table('shortcode_clicks') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
shortcode_id bigint(20) unsigned NOT NULL,
|
|
clicked_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
ip_address varchar(45) NOT NULL,
|
|
user_agent text,
|
|
referer varchar(500),
|
|
PRIMARY KEY (id),
|
|
KEY idx_shortcode_id (shortcode_id),
|
|
KEY idx_clicked_at (clicked_at)
|
|
) $charset_collate;";
|
|
|
|
// Book SEO table
|
|
$sql_seo = "CREATE TABLE " . self::table('book_seo') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
book_id bigint(20) unsigned NOT NULL,
|
|
seo_title varchar(255),
|
|
seo_description text,
|
|
seo_keywords varchar(500),
|
|
og_title varchar(255),
|
|
og_description text,
|
|
og_image varchar(500),
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uk_book_id (book_id)
|
|
) $charset_collate;";
|
|
|
|
// Authors table
|
|
$sql_authors_table = "CREATE TABLE " . self::table('authors') . " (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
display_name varchar(255) NOT NULL,
|
|
email varchar(100),
|
|
website varchar(500),
|
|
bio text,
|
|
goodreads_author_id varchar(20),
|
|
photo_url varchar(500),
|
|
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id)
|
|
) $charset_collate;";
|
|
|
|
dbDelta($sql_books);
|
|
dbDelta($sql_authors);
|
|
dbDelta($sql_series);
|
|
dbDelta($sql_sites);
|
|
dbDelta($sql_links);
|
|
dbDelta($sql_shortcodes);
|
|
dbDelta($sql_clicks);
|
|
dbDelta($sql_seo);
|
|
dbDelta($sql_authors_table);
|
|
|
|
// Migration: add asin columns if missing
|
|
$this->maybe_add_columns();
|
|
|
|
// Insert default retail sites
|
|
$this->insert_default_sites();
|
|
|
|
// Store schema version
|
|
update_option('bd_bookshelf_db_version', UMBRA_BOOKSHELF_VERSION);
|
|
}
|
|
|
|
/**
|
|
* Add missing columns to existing tables
|
|
*/
|
|
private function maybe_add_columns() {
|
|
global $wpdb;
|
|
|
|
$table = self::table('books');
|
|
$columns = $wpdb->get_col("DESCRIBE {$table}", 0);
|
|
|
|
$missing = [
|
|
'asin_ebook' => "ALTER TABLE {$table} ADD COLUMN asin_ebook varchar(20) AFTER isbn_audio",
|
|
'asin_paperback' => "ALTER TABLE {$table} ADD COLUMN asin_paperback varchar(20) AFTER asin_ebook",
|
|
'asin_hardcover' => "ALTER TABLE {$table} ADD COLUMN asin_hardcover varchar(20) AFTER asin_paperback",
|
|
'asin_audio' => "ALTER TABLE {$table} ADD COLUMN asin_audio varchar(20) AFTER asin_hardcover",
|
|
];
|
|
|
|
foreach ($missing as $col => $sql) {
|
|
if (!in_array($col, $columns)) {
|
|
$wpdb->query($sql);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert default retail sites
|
|
*/
|
|
private function insert_default_sites() {
|
|
global $wpdb;
|
|
|
|
$table = self::table('retail_sites');
|
|
|
|
$default_sites = [
|
|
[
|
|
'name' => 'Amazon',
|
|
'url_pattern' => 'https://www.amazon.com/dp/{ISBN}',
|
|
'icon' => 'amazon',
|
|
'affiliate_param' => 'tag',
|
|
'affiliate_code' => '',
|
|
'is_custom' => 0,
|
|
'is_active' => 1,
|
|
],
|
|
[
|
|
'name' => 'Barnes & Noble',
|
|
'url_pattern' => 'https://www.barnesandnoble.com/w/?ean={ISBN}',
|
|
'icon' => 'barnes-noble',
|
|
'affiliate_param' => '',
|
|
'affiliate_code' => '',
|
|
'is_custom' => 0,
|
|
'is_active' => 1,
|
|
],
|
|
[
|
|
'name' => 'Apple Books',
|
|
'url_pattern' => 'https://books.apple.com/book/{ISBN}',
|
|
'icon' => 'apple',
|
|
'affiliate_param' => '',
|
|
'affiliate_code' => '',
|
|
'is_custom' => 0,
|
|
'is_active' => 1,
|
|
],
|
|
[
|
|
'name' => 'Google Play',
|
|
'url_pattern' => 'https://play.google.com/store/search?q={ISBN}&c=books',
|
|
'icon' => 'google',
|
|
'affiliate_param' => '',
|
|
'affiliate_code' => '',
|
|
'is_custom' => 0,
|
|
'is_active' => 1,
|
|
],
|
|
[
|
|
'name' => 'Kobo',
|
|
'url_pattern' => 'https://www.kobo.com/us/en/ebook/{ISBN}',
|
|
'icon' => 'kobo',
|
|
'affiliate_param' => '',
|
|
'affiliate_code' => '',
|
|
'is_custom' => 0,
|
|
'is_active' => 1,
|
|
],
|
|
];
|
|
|
|
foreach ($default_sites as $site) {
|
|
$exists = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*) FROM {$table} WHERE name = %s",
|
|
$site['name']
|
|
)
|
|
);
|
|
|
|
if (!$exists) {
|
|
$wpdb->insert($table, $site);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop all plugin tables
|
|
*/
|
|
public function drop_tables() {
|
|
global $wpdb;
|
|
|
|
$tables = [
|
|
'books',
|
|
'book_authors',
|
|
'authors',
|
|
'series',
|
|
'retail_sites',
|
|
'retail_links',
|
|
'shortcodes',
|
|
'shortcode_clicks',
|
|
'book_seo',
|
|
];
|
|
|
|
foreach ($tables as $table) {
|
|
$wpdb->query("DROP TABLE IF EXISTS " . self::table($table));
|
|
}
|
|
}
|
|
}
|