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.
This commit is contained in:
bdm
2026-10-22 09:47:00 -07:00
parent da548075f0
commit 0bd78e587a
81 changed files with 1780 additions and 848 deletions

472
LICENSE
View File

@@ -1,202 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Preamble
1. Definitions.
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
The precise terms and conditions for copying, distribution and
modification follow.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
How to Apply These Terms to Your New Programs
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
Copyright 2026 Umbra Tools (umbratools.com)
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
http://www.apache.org/licenses/LICENSE-2.0
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

2
NOTICE
View File

@@ -1,2 +0,0 @@
Umbra Tools (https://umbratools.com)
Copyright 2026 Umbra Tools (umbratools.com)

View File

@@ -56,13 +56,16 @@ which drop your tables and options.
## Status
`v1.0.0` is the first consolidated release. The most important outstanding
problem is documented in
[`docs/KNOWN-ISSUES.md`](umbra-bookshelf/docs/KNOWN-ISSUES.md) §1: the books and
library modules still create `bd_books` and `bd_series` with two incompatible
schemas. This predates the consolidation — merging the plugins made it
unconditional, and resolving it requires a data migration.
`v1.1.0` resolves the three problems that blocked the consolidation:
* **`bd_books` and `bd_series` schema conflict** — the catalogue schema is now
canonical, with a migration that preserves data from installations built on the
old library schema. Legacy tables are renamed aside, never dropped.
* **Duplicated scraping** — Goodreads only, one implementation, driven by a
single 12-hour cron.
* **Apache/GPL licence mismatch** — GPL-2.0-or-later throughout, which is what
WordPress.org requires.
## Licence
Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
GPLv2 or later (GPL-2.0-or-later) — WordPress.org compatible. See [`LICENSE`](LICENSE).

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Custom Widgets

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
document.addEventListener('DOMContentLoaded', () => {
// Mobile navigation toggle

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* BDMurphy-Bookshelf functions and definitions

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Book Detail

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: 404 Error

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: All Books

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Series Listing

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Blog

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Navigation JavaScript

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Social Share JavaScript

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* The template for displaying comments

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Home

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* BDMurphy Bookshelf Theme Functions

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Home / Blog Posts Page

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* AJAX Handlers

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* External Book Data API Helpers

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Book Helper Functions

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Customizer Settings

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Navigation Menu Hooks

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Theme Setup

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Short URL Rewrites - Override BookHelper for direct template rendering

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Tags

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Theme Admin Menu

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Theme Options Admin Page

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Widgets

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Main template file

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: About

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Books

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Contact

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Full Width

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: No Sidebar

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Reviews

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Sign Up

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Unsubscribe

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Default Page

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Search Results

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Book Detail

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Template Name: Individual Series

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Social Share Template Part

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* BD Bookshelf - Admin JavaScript

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -25,7 +25,7 @@ one of those collided, and each collision is recorded in `KNOWN-ISSUES.md`.
┌──────────────────────────┼──────────────────────────┐
│ │ │
includes/books includes/library includes/email
Umbra_Database Umbra_Bookshelf_Helper Umbra_Simple_Email
Umbra_Database *owns bd_books/bd_series* Umbra_Simple_Email
Umbra_Book_Manager …_Helper_Admin …_Admin
Umbra_Authors …_Helper_Install …_Install
Umbra_Series Umbra_Email_Sender
@@ -34,11 +34,12 @@ one of those collided, and each collision is recorded in `KNOWN-ISSUES.md`.
Umbra_Shortcodes
Umbra_SEO assets: admin/, public/, library/, email/
Umbra_GDPR assets/
Umbra_Goodreads_Scraper
Umbra_Amazon_Scraper
Umbra_Goodreads_Scraper (only scraper)
Umbra_Admin
Umbra_Migration
Umbra_Theme_Compatibility -> class_alias BD_BookShelfHelper
includes/class-umbra-schema-migration.php reconciles pre-consolidation schemas
```
## Design rules
@@ -62,9 +63,19 @@ callbacks are registered before `init` fires.
static API the unmodified theme calls, and re-exports the legacy
`BD_BookShelfHelper` name via `class_alias()`.
## Not resolved here
**One owner per table.** `bd_books` and `bd_series` belong to the catalogue. The
library module deliberately no longer creates them. Where an installation was
built on the old library schema, `Umbra_Schema_Migration` renames the old table
aside, creates the canonical one, and copies rows across preserving IDs.
The catalogue and library modules keep two independent data models that happen
to share table names `bd_books` and `bd_series`. Consolidation made them
unconditionally conflict; it did not make them agree. Resolving this is the next
piece of work — see `KNOWN-ISSUES.md` §1 for the options.
**Schema work happens before `dbDelta`, never after.** `dbDelta` adds columns but
never drops them, so pointing it at a table of the wrong shape produces a table
that satisfies neither schema. Reconciliation therefore runs first, and the
migration is what guarantees the invariant.
## Still to reconcile
Making the catalogue schema canonical removed the *table* conflict. The two
modules still model a book differently — the catalogue as a row in `bd_books`,
the library as a `book` custom post type with per-post meta — and nothing keeps
them in step. See `TASKS.md` §5.

View File

@@ -1,131 +1,172 @@
# Known issues
Carried into the Umbra Bookshelf consolidation. Items 1–4 are **pre-existing**
defects inherited from the three merged plugins; item 5 and the two repaired
below are noted for traceability.
Defects and carried-over problems. For intended work, see `TASKS.md`.
Status at 1.1.0: the three issues that blocked the consolidation are resolved.
What remains is listed first.
---
## 1. `bd_books` and `bd_series` have two incompatible schemas (DEFERRED — unresolved)
## Open
The books and library modules both create tables with identical names and
different columns.
### 1. Rating sync blocks a worker with `sleep()`
`Umbra_Database::table()` returns `$wpdb->prefix . 'bd_' . $name`, and the
catalogue issues `CREATE TABLE {prefix}bd_books` with **no** `IF NOT EXISTS`
guard:
`includes/books/class-umbra-rating-sync.php`
`sync_all_ratings()` iterates up to 999 books and calls `sleep(2)` between each,
inside a cron run — roughly 33 minutes of a blocked PHP worker per tick.
Pre-existing. Deliberately left alone when Amazon scraping was removed, because
that change was scoped to dropping a source. Fix suggestion is in `TASKS.md` §3.
### 2. The two modules still model books differently
Making the catalogue schema canonical resolved the *table* conflict, but the
catalogue (row in `bd_books`) and the library (`book` custom post type with
per-post meta) are still independent models with no synchronisation between
them. Editing a book in one place does not update the other.
See `TASKS.md` §5 for the decision to make.
### 3. Set-aside legacy tables are never cleaned up
On a reconciled installation, `bd_books_legacy_library` and
`bd_series_legacy_library` remain indefinitely. Inert, but untidy. See
`TASKS.md` §4.
---
## Resolved in 1.1.0
### Catalogue schema is now canonical for `bd_books` and `bd_series`
**Was:** the catalogue and library modules both created `bd_books` and
`bd_series`, with different columns. Whichever activated first won; the other
either no-opped or errored, then read and wrote against the wrong shape. The
divergence was:
```
catalogue wp_bd_books id, title, slug, description, isbn_*, amazon_asin,
goodreads_id, rating_*, created_at, ...
catalogue wp_bd_books id, title, slug, description, cover_image, isbn_*,
asin_*, amazon_asin, goodreads_id, rating_*, series_id
library wp_bd_books id, post_id, pen_name_id, series_id, volume_number,
asin, isbn, publish_date, sort_order, created_at
asin, isbn, publish_date, sort_order
```
Same for `wp_bd_series` (catalogue: `name`, `slug`, `description` …;
library: `name`, `slug`, `cover_attachment_id`, `header_attachment_id`, `blurb`, …).
**Now:** `includes/class-umbra-schema-migration.php` runs before `dbDelta` on
activation and on update. If it finds a library-shaped table it renames it
aside (**never drops it**), creates the canonical table, and copies rows across:
**Consequence.** Whichever module is activated first wins; the second either
no-ops (`IF NOT EXISTS`, library) or raises a database error (catalogue's
unguarded `CREATE TABLE`). Whichever schema exists first is what the other
module then reads and writes against, producing wrong results rather than an
obvious failure.
* book **IDs are preserved**, which keeps `bd_book_meta`, `bd_reviews`,
`bd_ratings` and `bd_review_shortlinks` valid
* `title`, `slug` and `description` are resolved from the linked post
* the library's single `isbn`/`asin` land on the ebook columns; `amazon_asin` is
also populated so existing product links keep working
* `series_id` is preserved; the series row is migrated first so it still resolves
* post linkage is written back as `_bd_bookshelf_book_id` (preserved meta key)
* `publish_date` has no catalogue column, so it is kept as
`_bd_bookshelf_publish_date` post meta
* goodreads/amazon values in `bd_ratings` are folded into the catalogue's
`rating_goodreads` / `rating_amazon` columns
**Why deferred.** Resolving it means choosing a canonical schema and migrating
live data — a data-migration project, not a rename. Both datasets may hold rows
that matter.
Rows whose post no longer exists are skipped with a log entry rather than
creating an unusable book. Duplicate slugs are de-duplicated, because the
catalogue's `slug` column is `UNIQUE`.
**Options.**
Verified by `tests/schema_migration_test.php` — 42 checks against a real
database, covering shape detection, row mapping, ID preservation, orphan
handling, rating folding, idempotency and slug collisions.
1. Library schema becomes canonical; catalogue tables move to `bd_bookshelf_*`
with an upgrade routine copying existing rows. Requires renaming DB keys,
which this consolidation deliberately avoids.
2. Catalogue schema becomes canonical; library relies on the `book` post type and
postmeta instead of `bd_books`.
3. Delete the catalogue duplicates and keep only the library model.
### Only Goodreads scraping remains
---
**Was:** three Goodreads implementations and two Amazon ones:
## 2. Goodreads and Amazon scraping is implemented twice
| Implementation | State before |
|---|---|
| `Umbra_Goodreads_Scraper` | complete, 325 lines, **never called** |
| `Umbra_Rating_Sync::fetch_goodreads_rating()` | working duplicate |
| library `refresh_goodreads_data()` | empty stub, but scheduled daily |
| `Umbra_Amazon_Scraper` | complete, 296 lines, **never called** |
| `Umbra_Rating_Sync::fetch_amazon_rating()` | working duplicate |
* Catalogue: `Umbra_Goodreads_Scraper`, `Umbra_Amazon_Scraper`.
* Library: `Umbra_Bookshelf_Helper::refresh_goodreads_data()`,
`::scrape_amazon_ratings()`.
Both library "scrapers" were empty stubs attached to cron events, so
`bd_bookshelf_goodreads_refresh` (daily) and `bd_bookshelf_amazon_scrape`
(six-hourly) fired on schedule to do nothing.
Both attach to the **same** cron hooks (`bd_bookshelf_goodreads_refresh`,
`bd_bookshelf_amazon_scrape`), so each scheduled run performs duplicate
requests to third-party APIs — wasted quota, faster rate limiting, and two
places to fix a parsing bug. Needs a decision on which implementation survives.
**Now:** Goodreads only, and one implementation of it.
`Umbra_Amazon_Scraper` is deleted; 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. The surviving schedule
is the single `bd_bookshelf_sync_ratings` event every 12 hours.
---
The `rating_amazon` **column** is retained — it holds existing data and is
populated from `bd_ratings` during migration. Amazon retail *links* are
unaffected; only rating scraping was removed. The two former hooks are still
cleared on deactivation and uninstall so no stale schedules survive an upgrade.
## 3. `assets/images/default-cover.png` was missing
### Licence is GPLv2 or later
`Umbra_Bookshelf_Helper` returns `assets/images/default-cover.png` as the
**Was:** plugin headers said `GPLv2 or later` while `LICENSE`, `NOTICE` and 101
SPDX headers said Apache-2.0. The theme was worse: `style.css` declared GPLv2
while its PHP files carried Apache headers.
**Now:** GPL-2.0-or-later throughout, which is what WordPress.org requires and
what the theme already claimed. `LICENSE` is the full GPLv2 text; `NOTICE` is
removed (it was Apache-specific); every SPDX header reads
`GPL-2.0-or-later`.
`Old/` and `archive/pre-umbra-releases/` keep their original Apache headers on
purpose — they are frozen pre-consolidation snapshots, not shipped code.
### Default cover was missing
`Umbra_Bookshelf_Helper` returned `assets/images/default-cover.png` as the
fallback cover, but the repository contained no images at all, so every
cover-less book rendered a broken image.
cover-less book rendered a broken image. A real cover
(`WorkingTitleCover_512x815.png`, 512×815) is now committed at that path.
Replace with final artwork before release if this was only a working title.
**Repaired during consolidation:** a neutral 600×900 placeholder PNG was
generated at that path. Replace it with real artwork before release.
### Email admin script did not exist
`Umbra_Simple_Email_Admin::enqueue_assets()` enqueued `email/assets/js/admin.js`
on every email admin page, but the file was never committed — a guaranteed 404.
An empty placeholder is now committed with a comment explaining its history.
Defining its behaviour is `TASKS.md` §1.
### `Umbra_SEO` was loaded but never instantiated
It registers its hooks from a constructor, and the bootstrap did not construct
it, so the class was dead code. It is now instantiated and its meta/Open
Graph/Twitter/sitemap hooks attach. Completing SEO for generated book detail
pages is `TASKS.md` §2.
---
## 4. `email/assets/js/admin.js` did not exist
`Umbra_Simple_Email_Admin::enqueue_assets()` enqueued it on every email admin
page, but no such file was ever committed — a guaranteed 404.
**Repaired during consolidation:** an empty placeholder was created with a
comment explaining the history. Add the intended behaviour, or drop the enqueue.
---
## 5. `Umbra_SEO` is never instantiated
`Umbra_SEO` registers its hooks from a constructor, but the bootstrap does not
construct it — carried over unchanged from the original BD Bookshelf bootstrap.
The class is loaded and its hooks never attach, so the module is inert.
Left as-is because enabling it changes behaviour. Either instantiate it in
`Umbra_Bookshelf::init_modules()` or delete it.
---
## 6. Licence metadata is inconsistent (NOT CHANGED)
Plugin headers declared `License: GPLv2 or later` while the repository's
`LICENSE`/`NOTICE` files (added in `85de97b`) and every SPDX header state
**Apache-2.0**. The consolidated header now says Apache-2.0 to match the SPDX
headers and `LICENSE`, but this needs a deliberate decision — WordPress
plugin-directory submissions require a GPL-compatible licence.
---
## Fixed during consolidation
## Fixed in 1.0.0 (the consolidation)
* **`Cannot declare class` fatal.** `class-bdm-theme-compatibility.php` declared
`BD_BookShelfHelper`, the same class name as the library plugin's own helper.
Latent while the two were separate plugins (it only fired when both were
active), but an unconditional fatal once merged. The shim is now
`Umbra_Theme_Compatibility`, with a `class_alias()` preserving the legacy name
for the unmodified theme.
* **Duplicate `bd_bookshelf_import_csv` AJAX handler.** Both `Umbra_Admin` and
`Umbra_Migration` registered it; the second silently replaced the first. The
import page now lives solely in `Umbra_Admin` (it is the variant wired to
Latent while the two were separate plugins — it only fired when both were
active — but an unconditional fatal once merged. The shim is now
`Umbra_Theme_Compatibility`, with `class_alias()` preserving the legacy name
so the unmodified theme keeps working.
* **Duplicate `bd_bookshelf_import_csv` AJAX handler.** `Umbra_Admin` and
`Umbra_Migration` both registered it; the second silently replaced the first.
The import page now lives solely in `Umbra_Admin` (the variant wired to
`admin/js/umbra-bookshelf-admin.js`); `Umbra_Migration` keeps only the
preview/run endpoints it uniquely provides.
* **Duplicate `bd-bookshelf-settings` submenu.** Catalogue and library both
claimed the slug, leaving one Settings screen unreachable. Now
`umbra-bookshelf-settings` and `umbra-bookshelf-library-settings`.
* **Duplicate admin menu.** All three modules called `add_menu_page`. Now one
top-level menu registered by the bootstrap at priority 9.
top-level menu, registered by the bootstrap at `admin_menu` priority 9.
* **Colliding asset handles.** Catalogue and library both enqueued
`bd-bookshelf-admin`; the second enqueue was silently discarded. Handles are
now unique per module.
* **Modules could miss their own `init` callbacks.** Constructing modules from
inside an `init` callback means their `init` registrations race the hook
currently being dispatched. Modules are now constructed on `plugins_loaded`,
behind a re-entrancy guard.
inside an `init` callback means their `init` registrations race the hook being
dispatched, so the `book` post type and `[bd-book]` shortcode could fail to
register. Modules now construct on `plugins_loaded`, behind a re-entrancy
guard.
* **Over-broad asset hook check.** The library module's
`strpos($hook, 'bd-bookshelf')` matched every catalogue and email screen once
all slugs shared the `umbra-bookshelf-` prefix. Now matched against its own

View File

@@ -0,0 +1,103 @@
# Tasks
Known, agreed work that is deliberately not done yet. Ordered by size, not
priority. See `KNOWN-ISSUES.md` for defects; this file is for intended work.
---
## 1. Define the email module's admin behaviour
`includes/email/`, `email/assets/js/admin.js`
The admin script is an empty placeholder. It was referenced by
`Umbra_Simple_Email_Admin::enqueue_assets()` but never committed, so the file
was created during the consolidation purely to stop the 404.
Nothing in the email admin is currently script-driven, so the page works — it
is just plain HTML forms. Define what should be interactive:
* **Subscriber list** — search, sort, pagination and bulk delete. The table is
already rendered; this is the highest-value addition.
* **Composer** — live preview of the rendered newsletter, and a "send a test to
me" button that does not touch the real subscriber list.
* **Settings** — validate the Gemini API key and the Gmail credentials on save
and report the error in place, instead of failing later at send time.
## 2. Complete SEO for book detail pages
`includes/books/class-umbra-seo.php`
The SEO module is now active (it was loaded but never instantiated before, so
its hooks never attached). It already provides, per book:
* `<meta name="title|description|keywords">` from the `bd_book_seo` table
* Open Graph tags (`og:type=book`, title, description, URL, image)
* Twitter card tags
* sitemap weighting driven by the book's rating
Remaining work, since the system generates the book detail pages itself:
* **Populate `bd_book_seo` automatically.** Rows are only written when
something calls `Umbra_SEO::update_book_seo()`, and today nothing does. On
book create/update, derive sensible defaults: title as
`{book title} by {author}`, description from the blurb, image from the cover.
* **Schema.org `Book` structured data.** Currently only Open Graph is emitted.
JSON-LD with `name`, `author`, `isbn`, `aggregateRating`, `offers` and
`inLanguage` is what actually drives rich results.
* **Per-language metadata.** `bd_book_meta` is keyed by book *and* language, so
a translated book needs its own title/description rather than sharing one.
* **Avoid clashing with SEO plugins.** If Yoast, Rank Math or SEOPress is
active, it will also emit description and OG tags. Detect them and defer, or
register with their APIs instead of printing directly.
* **Canonical and hreflang** for multi-language book pages.
## 3. Stop the rating sync blocking on `sleep()`
`includes/books/class-umbra-rating-sync.php`
`sync_all_ratings()` walks up to 999 books and calls `sleep(2)` between each
one, inside a cron run. That is roughly 33 minutes of a blocked PHP worker, and
cron jobs that outrun their next scheduled tick compound.
Suggested fix: sync a bounded batch per run (for example 25 books), store the
cursor in an option, and let the next 12-hour tick continue from there. Or
schedule each book as its own single event.
This predates the consolidation and was left as-is because the agreed change was
scoped to dropping Amazon, but it will bite in production.
## 4. Decide the fate of the set-aside legacy tables
`includes/class-umbra-schema-migration.php`
Sites that were built on the library schema get their old `bd_books` and
`bd_series` renamed to `bd_books_legacy_library` / `bd_series_legacy_library`
rather than dropped, so nothing is lost.
Once an installation has been verified, those tables are dead weight. Add a
small admin action that summarises them (row counts) and offers to drop them
once confirmed. Until then they are inert.
## 5. Reconcile the two book data models
`includes/books/` and `includes/library/`
Making the catalogue schema canonical removed the *table* conflict, but the two
modules still model a book differently:
* **Catalogue** — a row in `bd_books`, with its own ISBN/ASIN/rating columns.
Not tied to a WordPress post.
* **Library** — a `book` custom post type, with per-post meta such as
`ebook_url_amazon`, `goodreads_rating`, `seo_title`.
Nothing currently keeps them in step: editing a book in one place does not
update the other. Decide whether the catalogue is the single source of truth
with the library reading from it, or whether they stay independent and a
sync layer connects them.
## 6. Remove the historical `Old/` and `archive/` directories
These hold pre-consolidation plugin snapshots and release zips. They are useful
provenance now and will be noise later. The `Old/` tree in particular still
carries the old Apache licence headers; it is deliberately excluded from the
GPL switch because it is a frozen record, not shipped code.

View File

@@ -1,11 +1,22 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Umbra Bookshelf - Email module admin scripts.
*
* NOTE: Umbra_Simple_Email_Admin::enqueue_assets() has always enqueued this
* file, but the file was never present in the source repository, so every
* email admin page issued a 404 for it. It is created here as an empty
* placeholder so the enqueue resolves. Add email-module admin behaviour below.
* INTENTIONALLY EMPTY.
*
* Umbra_Simple_Email_Admin::enqueue_assets() has always enqueued this file on
* every email admin screen, but the file was never committed to the source
* repository, so each of those pages issued a 404 for it. An empty file is
* committed here so the enqueue resolves and the console stays clean.
*
* TASK: define the email module's admin behaviour. Candidates, in rough order
* of value:
* - subscriber list: search, sort and bulk delete
* - composer: live preview and a send test to the current user
* - settings: validate the Gemini API key and Gmail credentials on save
* rather than only at send time
*
* See docs/TASKS.md.
*/

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Admin Interface class

View File

@@ -1,296 +0,0 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
/**
* Amazon Scraper class
*
* Fetches author and book data from Amazon
*
* @package Umbra_Bookshelf
*/
if (!defined('ABSPATH')) {
exit;
}
class Umbra_Amazon_Scraper {
private $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* Search Amazon for a book by title and author
*
* @param string $title Book title.
* @param string $author Author name.
* @return array List of books found.
*/
public function search_books($title, $author = '') {
$query = $title;
if ($author) {
$query .= ' ' . $author;
}
$url = 'https://www.amazon.com/s?k=' . urlencode($query) . '&i=stripbooks';
$response = wp_remote_get($url, [
'timeout' => 30,
'headers' => [
'User-Agent' => $this->user_agent,
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
],
]);
if (is_wp_error($response)) {
return ['error' => $response->get_error_message()];
}
$body = wp_remote_retrieve_body($response);
return $this->parse_search_results($body);
}
/**
* Parse Amazon search results
*/
private function parse_search_results($html) {
$books = [];
// Match search result items with data-asin
if (preg_match_all('/<div[^>]*data-asin="([A-Z0-9]{10})"[^>]*class="[^"]*s-result-item[^"]*"[^>]*>(.*?)<\/div>\s*<\/div>\s*<\/div>/si', $html, $matches)) {
foreach ($matches as $index => $match) {
$asin = $match[1];
$item_html = $match[2];
$book = $this->parse_search_item($item_html, $asin);
if ($book) {
$books[] = $book;
}
}
}
// Alternative pattern
if (empty($books)) {
if (preg_match_all('/data-asin="([A-Z0-9]{10})"/si', $html, $asin_matches)) {
foreach ($asin_matches[1] as $asin) {
// Try to find context around this ASIN
$pattern = '/data-asin="' . preg_quote($asin) . '"[^>]*>(.*?)(?=data-asin="|$)/si';
if (preg_match($pattern, $html, $context_match)) {
$book = $this->parse_search_item($context_match[1], $asin);
if ($book) {
$books[] = $book;
}
}
}
}
}
return $books;
}
/**
* Parse a single search result item
*/
private function parse_search_item($html, $asin) {
$book = [
'asin' => $asin,
'title' => '',
'author' => '',
'format' => '',
'rating' => 0,
'ratings_count' => 0,
'cover_image' => '',
'url' => 'https://www.amazon.com/dp/' . $asin,
];
// Extract title
if (preg_match('/<h2[^>]*>.*?<span[^>]*>(.*?)<\/span>/si', $html, $matches)) {
$book['title'] = trim(strip_tags($matches[1]));
} elseif (preg_match('/<a[^>]*>.*?<span[^>]*class="[^"]*a-size[^"]*"[^>]*>(.*?)<\/span>/si', $html, $matches)) {
$book['title'] = trim(strip_tags($matches[1]));
}
// Extract author
if (preg_match('/<span[^>]*class="[^"]*a-size[^"]*"[^>]*>\s*by\s+<a[^>]*>(.*?)<\/a>/si', $html, $matches)) {
$book['author'] = trim(strip_tags($matches[1]));
}
// Extract format (Kindle, Paperback, Hardcover, Audio)
if (preg_match('/(Kindle\s*Edition|Paperback|Hardcover|Audio\s*CD|Audible|MP3\s*CD)/si', $html, $matches)) {
$book['format'] = trim($matches[1]);
}
// Extract rating
if (preg_match('/(\d+\.?\d*)\s*out\s*of\s*5\s*stars?/si', $html, $matches)) {
$book['rating'] = (float) $matches[1];
}
// Extract ratings count
if (preg_match('/([\d,]+)\s*ratings?/si', $html, $matches)) {
$book['ratings_count'] = absint(str_replace(',', '', $matches[1]));
}
// Extract cover image
if (preg_match('/<img[^>]*src="(https:\/\/[^"]*\.jpg)"/si', $html, $matches)) {
$book['cover_image'] = $matches[1];
}
if (empty($book['title'])) {
return null;
}
return $book;
}
/**
* Look up book by ASIN and get full details including ISBN
*
* @param string $asin Amazon ASIN.
* @return array Book details.
*/
public function lookup_by_asin($asin) {
$url = 'https://www.amazon.com/dp/' . $asin;
$response = wp_remote_get($url, [
'timeout' => 30,
'headers' => [
'User-Agent' => $this->user_agent,
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
],
]);
if (is_wp_error($response)) {
return ['error' => $response->get_error_message()];
}
$body = wp_remote_retrieve_body($response);
return $this->parse_product_page($body, $asin);
}
/**
* Parse Amazon product page for full book details
*/
private function parse_product_page($html, $asin) {
$book = [
'asin' => $asin,
'title' => '',
'author' => '',
'isbn' => '',
'isbn13' => '',
'rating' => 0,
'ratings_count' => 0,
'cover_image' => '',
'description' => '',
'formats' => [],
];
// Extract title
if (preg_match('/<h1[^>]*id="title"[^>]*>(.*?)<\/h1>/si', $html, $matches)) {
$book['title'] = trim(strip_tags($matches[1]));
}
// Extract author
if (preg_match('/by\s+<a[^>]*>(.*?)<\/a>/si', $html, $matches)) {
$book['author'] = trim(strip_tags($matches[1]));
}
// Extract rating
if (preg_match('/(\d+\.?\d*)\s*out\s*of\s*5\s*stars?/si', $html, $matches)) {
$book['rating'] = (float) $matches[1];
}
// Extract ratings count
if (preg_match('/([\d,]+)\s*ratings?/si', $html, $matches)) {
$book['ratings_count'] = absint(str_replace(',', '', $matches[1]));
}
// Extract cover image
if (preg_match('/<img[^>]*id="landingImage"[^>]*src="([^"]+)"/si', $html, $matches)) {
$book['cover_image'] = $matches[1];
}
// Extract ISBN from product details
if (preg_match('/ISBN-10:\s*(\d{10})/si', $html, $matches)) {
$book['isbn'] = $matches[1];
}
if (preg_match('/ISBN-13:\s*(\d{13})/si', $html, $matches)) {
$book['isbn13'] = $matches[1];
}
// Extract ASIN
if (preg_match('/ASIN:\s*([A-Z0-9]{10})/si', $html, $matches)) {
$book['asin'] = $matches[1];
}
// Extract description
if (preg_match('/<div[^>]*id="bookDescription_feature_div"[^>]*>.*?<span[^>]*>(.*?)<\/span>/si', $html, $matches)) {
$book['description'] = trim(strip_tags($matches[1]));
}
// Look for other format links (Kindle, Paperback, Hardcover)
if (preg_match_all('/<li[^>]*>.*?<a[^>]*href="\/dp\/([A-Z0-9]{10})"[^>]*>.*?<span[^>]*>(.*?)<\/span>/si', $html, $matches)) {
foreach ($matches[1] as $index => $format_asin) {
$format_name = trim(strip_tags($matches[2][$index]));
if ($format_asin !== $asin && !empty($format_name)) {
$book['formats'][] = [
'asin' => $format_asin,
'format' => $format_name,
'url' => 'https://www.amazon.com/dp/' . $format_asin,
];
}
}
}
return $book;
}
/**
* Get all format ASINs for a book by searching
*
* @param string $title Book title.
* @param string $author Author name.
* @return array Format => ASIN mapping.
*/
public function get_format_asins($title, $author = '') {
$formats = [
'kindle' => '',
'paperback' => '',
'hardcover' => '',
'audio' => '',
];
// Search for the book
$results = $this->search_books($title, $author);
if (isset($results['error']) || empty($results)) {
return $formats;
}
// Match results to formats
foreach ($results as $book) {
$book_title = strtolower($book['title'] ?? '');
$book_format = strtolower($book['format'] ?? '');
// Check if this is the same book (title match)
similar_text(strtolower($title), $book_title, $percent);
if ($percent < 60) {
continue;
}
if (strpos($book_format, 'kindle') !== false || strpos($book_format, 'ebook') !== false) {
$formats['kindle'] = $book['asin'];
} elseif (strpos($book_format, 'paperback') !== false) {
$formats['paperback'] = $book['asin'];
} elseif (strpos($book_format, 'hardcover') !== false) {
$formats['hardcover'] = $book['asin'];
} elseif (strpos($book_format, 'audio') !== false || strpos($book_format, 'audible') !== false) {
$formats['audio'] = $book['asin'];
}
}
return $formats;
}
}

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Authors Management class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Book Manager class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Database management class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* GDPR Compliance class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Goodreads Scraper class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Migration Manager class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Rating Sync Engine class
@@ -76,7 +76,17 @@ class Umbra_Rating_Sync {
}
/**
* Sync rating for a single book
* Sync rating for a single book from Goodreads
*
* Goodreads is the only supported rating source. Amazon scraping was
* removed: it duplicated work, added nothing the catalogue did not
* already store, and was a second scraping surface to maintain.
*
* All HTML parsing lives in Umbra_Goodreads_Scraper -- this class only
* decides what to persist.
*
* @param int $book_id Catalogue book ID.
* @return bool True when a row was written.
*/
public function sync_book_rating($book_id) {
global $wpdb;
@@ -86,119 +96,53 @@ class Umbra_Rating_Sync {
return false;
}
$amazon_rating = 0;
$goodreads_rating = 0;
// Sync Amazon rating
if (!empty($book->amazon_asin) || !empty($book->isbn_ebook)) {
$isbn = $book->amazon_asin ?: $book->isbn_ebook;
$amazon_rating = $this->fetch_amazon_rating($isbn);
// Keep last rating if fetch fails
if ($amazon_rating === false) {
$amazon_rating = $book->rating_amazon;
}
// Without a recorded Goodreads ID there is nothing to look up, and no
// reliable way to discover one from the catalogue alone. Leave the
// stored rating untouched.
if (empty($book->goodreads_id)) {
return false;
}
// Sync Goodreads rating
if (!empty($book->goodreads_id)) {
$goodreads_rating = $this->fetch_goodreads_rating($book->goodreads_id);
$scraper = new Umbra_Goodreads_Scraper();
$details = $scraper->get_book_details($book->goodreads_id);
// Keep last rating if fetch fails
if ($goodreads_rating === false) {
$goodreads_rating = $book->rating_goodreads;
}
// A transport or parse failure comes back as an 'error' key. Keep the
// last known rating rather than overwriting it with zero.
if (!is_array($details) || !empty($details['error'])) {
$message = (is_array($details) && !empty($details['error']))
? $details['error']
: 'unexpected response shape';
error_log('Umbra Bookshelf: Goodreads fetch error - ' . $message);
return false;
}
// Update book ratings
$table = Umbra_Database::table('books');
$wpdb->update(
$table,
[
'rating_amazon' => $amazon_rating,
'rating_goodreads' => $goodreads_rating,
$rating = !empty($details['rating']) ? (float) $details['rating'] : 0.0;
$update = [
'rating_goodreads' => $rating,
'last_sync' => current_time('mysql'),
],
];
// Goodreads also reports a cover and an ISBN-13. Adopt them only where
// the catalogue has nothing, so local edits are never clobbered.
if (!empty($details['cover_image']) && empty($book->cover_image)) {
$update['cover_image'] = esc_url_raw($details['cover_image']);
}
if (!empty($details['isbn13']) && empty($book->isbn_ebook)) {
$update['isbn_ebook'] = $details['isbn13'];
}
$wpdb->update(
Umbra_Database::table('books'),
$update,
['id' => $book_id],
['%f', '%f', '%s'],
null,
['%d']
);
return true;
}
/**
* Fetch Amazon rating via web scraping
*/
private function fetch_amazon_rating($isbn) {
$url = "https://www.amazon.com/dp/{$isbn}";
$response = wp_remote_get($url, [
'timeout' => 15,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
],
]);
if (is_wp_error($response)) {
error_log('BD Bookshelf: Amazon fetch error - ' . $response->get_error_message());
return false;
}
$body = wp_remote_retrieve_body($response);
// Parse rating from HTML
// Amazon uses data-hook="review-star-rating" or similar
if (preg_match('/data-hook="review-star-rating"[^>]*>.*?(\d+\.?\d*)\s*out of\s*5/si', $body, $matches)) {
return (float) $matches[1];
}
// Alternative pattern
if (preg_match('/a-icon-alt[^>]*>(\d+\.?\d*)\s*out of\s*\d+ stars/si', $body, $matches)) {
return (float) $matches[1];
}
return false;
}
/**
* Fetch Goodreads rating via web scraping
*/
private function fetch_goodreads_rating($goodreads_id) {
$url = "https://www.goodreads.com/book/show/{$goodreads_id}";
$response = wp_remote_get($url, [
'timeout' => 15,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
],
]);
if (is_wp_error($response)) {
error_log('BD Bookshelf: Goodreads fetch error - ' . $response->get_error_message());
return false;
}
$body = wp_remote_retrieve_body($response);
// Parse rating from HTML
// Goodreads uses ratingValue in meta or specific class
if (preg_match('/ratingValue[^>]*>(\d+\.?\d*)</si', $body, $matches)) {
return (float) $matches[1];
}
// Alternative pattern
if (preg_match('/class="RatingStatistics__rating"[^>]*>(\d+\.?\d*)</si', $body, $matches)) {
return (float) $matches[1];
}
return false;
}
/**
* Manual sync trigger
*/

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Retail Sites management class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* SEO Metadata Manager class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Series Management class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Shortcodes Manager class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Theme Compatibility Layer

View File

@@ -0,0 +1,413 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Schema Reconciliation
*
* The catalogue module owns {prefix}bd_books and {prefix}bd_series. Before the
* consolidation, the library module created those same two table names with a
* completely different column set, so whichever plugin activated first won and
* the other read and wrote against the wrong shape.
*
* Decision: the catalogue schema is canonical. This class reconciles an
* installation that was built on the library schema so no rows are lost.
*
* The legacy tables are RENAMED, never dropped, so the original data stays
* recoverable until an operator removes it deliberately.
*
* @package Umbra_Bookshelf
*/
if (!defined('ABSPATH')) {
exit;
}
class Umbra_Schema_Migration {
/**
* Option recording which schema revision this site is on.
*/
const VERSION_OPTION = 'umbra_bookshelf_schema_version';
/**
* Schema revision introduced by the consolidation.
*/
const TARGET_VERSION = 2;
/**
* Suffix given to set-aside legacy tables.
*/
const LEGACY_SUFFIX = '_legacy_library';
/**
* Run the reconciliation if this site has not already been reconciled.
*
* Safe to call on every activation: it returns immediately once the site
* reports the target schema version.
*
* @return array Report of what happened, for logging and tests.
*/
public static function maybe_migrate() {
$report = array(
'version' => self::VERSION_OPTION,
'from' => (int) get_option(self::VERSION_OPTION, 0),
'books_migrated' => 0,
'series_migrated' => 0,
'books_legacy' => false,
'series_legacy' => false,
'errors' => array(),
);
if ($report['from'] >= self::TARGET_VERSION) {
$report['skipped'] = true;
return $report;
}
global $wpdb;
// ------------------------------------------------------------------
// Series first: books.series_id must point at already-migrated rows.
// ------------------------------------------------------------------
$series = self::table('series');
if (self::table_exists($series) && self::series_is_library_shape($series)) {
try {
$report['series_migrated'] = self::migrate_series($series);
$report['series_legacy'] = true;
} catch (Throwable $e) {
$report['errors'][] = 'series: ' . $e->getMessage();
}
}
// ------------------------------------------------------------------
// Books.
// ------------------------------------------------------------------
$books = self::table('books');
if (self::table_exists($books) && self::books_is_library_shape($books)) {
try {
$report['books_migrated'] = self::migrate_books($books);
$report['books_legacy'] = true;
} catch (Throwable $e) {
$report['errors'][] = 'books: ' . $e->getMessage();
}
}
// ------------------------------------------------------------------
// Leave the rating cache alone. bd_ratings is keyed by book_id and the
// migration preserves book IDs, so the rows stay valid. They are
// folded into the catalogue's own rating_* columns below.
// ------------------------------------------------------------------
if (!$report['errors']) {
self::fold_library_ratings_into_catalogue();
update_option(self::VERSION_OPTION, self::TARGET_VERSION, false);
$report['version'] = self::TARGET_VERSION;
}
if ($report['errors']) {
$report['version'] = $report['from']; // not advanced; retried next time
error_log('Umbra Bookshelf: schema migration failed - '
. implode('; ', $report['errors']));
}
return $report;
}
/**
* Fully-qualified table name.
*
* @param string $name Bare table name, e.g. 'books'.
* @return string
*/
private static function table($name) {
return Umbra_Database::table($name);
}
/**
* Does the table exist?
*
* @param string $table
* @return bool
*/
private static function table_exists($table) {
global $wpdb;
$found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
return $found === $table;
}
/**
* Column names for a table.
*
* @param string $table
* @return array
*/
private static function columns($table) {
global $wpdb;
return (array) $wpdb->get_col("DESCRIBE {$table}");
}
/**
* Is bd_books still in the old library shape?
*
* Library shape is identified by post_id and the absence of title.
*
* @param string $table
* @return bool
*/
private static function books_is_library_shape($table) {
$cols = self::columns($table);
return in_array('post_id', $cols, true) && !in_array('title', $cols, true);
}
/**
* Is bd_series still in the old library shape?
*
* Library shape has slug + blurb; catalogue shape has description.
*
* @param string $table
* @return bool
*/
private static function series_is_library_shape($table) {
$cols = self::columns($table);
return in_array('blurb', $cols, true) && !in_array('description', $cols, true);
}
/**
* Move the legacy table aside and create a canonical one.
*
* @param string $table
* @param string $label For logging.
* @return string Legacy table name.
*/
private static function set_aside($table, $label) {
global $wpdb;
$legacy = $table . self::LEGACY_SUFFIX;
// Never clobber an existing backup from a previous attempt.
if (self::table_exists($legacy)) {
$legacy = $table . self::LEGACY_SUFFIX . '_' . gmdate('YmdHis');
}
$wpdb->query("RENAME TABLE {$table} TO {$legacy}");
error_log("Umbra Bookshelf: {$label} legacy schema set aside as {$legacy}");
return $legacy;
}
/**
* Migrate series rows.
*
* ID is preserved so books.series_id keeps resolving.
*
* @param string $table Legacy bd_series.
* @return int Rows copied.
*/
private static function migrate_series($table) {
global $wpdb;
$legacy = self::set_aside($table, 'series');
// Create the canonical shape.
$database = new Umbra_Database();
$database->create_tables();
$target = self::table('series');
$rows = $wpdb->get_results("SELECT * FROM {$legacy}", ARRAY_A);
$copied = 0;
foreach ($rows as $row) {
$name = isset($row['name']) ? (string) $row['name'] : '';
if ($name === '') {
continue;
}
$cover = '';
if (!empty($row['cover_attachment_id'])) {
$cover = (string) wp_get_attachment_url((int) $row['cover_attachment_id']);
}
$inserted = $wpdb->insert(
$target,
array(
'id' => (int) $row['id'],
'name' => $name,
// blurb was the library's prose field for a series.
'description' => isset($row['blurb']) ? (string) $row['blurb'] : '',
'cover_image' => $cover,
'created_at' => !empty($row['created_at']) ? $row['created_at'] : current_time('mysql'),
),
array('%d', '%s', '%s', '%s', '%s')
);
if ($inserted !== false) {
$copied++;
}
}
return $copied;
}
/**
* Migrate book rows.
*
* The library row is a thin pointer at a WordPress post, so title, slug and
* body are resolved from that post. IDs are preserved, which keeps
* bd_book_meta, bd_reviews, bd_ratings and bd_review_shortlinks valid, and
* lets us point _bd_bookshelf_book_id at the catalogue row.
*
* @param string $table Legacy bd_books.
* @return int Rows copied.
*/
private static function migrate_books($table) {
global $wpdb;
$legacy = self::set_aside($table, 'books');
$database = new Umbra_Database();
$database->create_tables();
$target = self::table('books');
$rows = $wpdb->get_results("SELECT * FROM {$legacy}", ARRAY_A);
$copied = 0;
$used_slugs = array();
foreach ($rows as $row) {
$post_id = isset($row['post_id']) ? (int) $row['post_id'] : 0;
$post = $post_id ? get_post($post_id) : null;
$title = $post ? (string) $post->post_title : '';
if ($title === '') {
// Nothing to name the book after; skip rather than create a
// row that cannot satisfy title NOT NULL meaningfully.
continue;
}
$slug = $post ? (string) $post->post_name : '';
$slug = $slug !== '' ? $slug : sanitize_title($title);
// Catalogue slug is NOT NULL UNIQUE.
$base = $slug;
$suffix = 1;
while (in_array($slug, $used_slugs, true)) {
$slug = $base . '-' . (++$suffix);
}
$used_slugs[] = $slug;
$book_id = isset($row['id']) ? (int) $row['id'] : 0;
// The library stored one ISBN and one ASIN with no format attached.
// Both land on the ebook columns, which is the least lossy guess.
$isbn = isset($row['isbn']) ? (string) $row['isbn'] : '';
$asin = isset($row['asin']) ? (string) $row['asin'] : '';
$inserted = $wpdb->insert(
$target,
array(
'id' => $book_id,
'title' => $title,
'slug' => $slug,
'description' => $post ? (string) $post->post_content : '',
'cover_image' => $post ? (string) get_the_post_thumbnail_url($post_id, 'large') : '',
'isbn_ebook' => $isbn,
'asin_ebook' => $asin,
// Retained so existing Amazon product links keep resolving.
'amazon_asin' => $asin,
'series_id' => isset($row['series_id']) ? (int) $row['series_id'] : null,
'created_at' => !empty($row['created_at']) ? $row['created_at'] : current_time('mysql'),
),
array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s')
);
if ($inserted === false) {
continue;
}
$copied++;
// Record the link the library used to imply with its post_id
// UNIQUE column. Preserved meta key.
if ($post_id && $book_id) {
update_post_meta($post_id, '_bd_bookshelf_book_id', $book_id);
}
// Publish date lived only on the library row; the catalogue has no
// column for it, so keep it on the post for the theme to read.
if ($post_id && !empty($row['publish_date'])) {
update_post_meta($post_id, '_bd_bookshelf_publish_date', $row['publish_date']);
}
}
return $copied;
}
/**
* Copy bd_ratings values into the catalogue's own rating columns.
*
* Safe to call more than once: it only writes where the catalogue column
* is still at its zero default.
*
* @return void
*/
public static function fold_library_ratings_into_catalogue() {
global $wpdb;
$ratings = $wpdb->prefix . 'bd_ratings';
$books = self::table('books');
if (!self::table_exists($ratings) || !self::table_exists($books)) {
return;
}
$rows = $wpdb->get_results(
"SELECT book_id, source, rating FROM {$ratings}", ARRAY_A
);
foreach ($rows as $row) {
$book_id = (int) $row['book_id'];
$rating = (float) $row['rating'];
if ($rating <= 0) {
continue;
}
$source = strtolower((string) $row['source']);
if (strpos($source, 'goodreads') !== false) {
$wpdb->query($wpdb->prepare(
"UPDATE {$books} SET rating_goodreads = %f
WHERE id = %d AND rating_goodreads = 0",
$rating, $book_id
));
} elseif (strpos($source, 'amazon') !== false) {
$wpdb->query($wpdb->prepare(
"UPDATE {$books} SET rating_amazon = %f
WHERE id = %d AND rating_amazon = 0",
$rating, $book_id
));
}
}
}
/**
* Human-readable status, for the admin and for tests.
*
* @return array
*/
public static function status() {
$books = self::table('books');
$series = self::table('series');
return array(
'version' => (int) get_option(self::VERSION_OPTION, 0),
'target_version' => self::TARGET_VERSION,
'books_exists' => self::table_exists($books),
'books_shape' => self::table_exists($books)
? (self::books_is_library_shape($books) ? 'library' : 'catalogue')
: 'absent',
'series_exists' => self::table_exists($series),
'series_shape' => self::table_exists($series)
? (self::series_is_library_shape($series) ? 'library' : 'catalogue')
: 'absent',
);
}
}

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Email Admin Class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Email Installation and Database

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Email Sender Class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Main SimpleEmail Plugin Class

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Main Plugin Class
@@ -54,9 +54,10 @@ class Umbra_Bookshelf_Helper {
// Schedule cron jobs on init
add_action('init', array($this, 'schedule_cron_events'));
// Cron callbacks
add_action('bd_bookshelf_goodreads_refresh', array($this, 'refresh_goodreads_data'));
add_action('bd_bookshelf_amazon_scrape', array($this, 'scrape_amazon_ratings'));
// Rating refresh is owned by Umbra_Rating_Sync (bd_bookshelf_sync_ratings),
// which drives Umbra_Goodreads_Scraper. The former
// bd_bookshelf_goodreads_refresh and bd_bookshelf_amazon_scrape hooks
// pointed at empty stubs and have been removed.
}
/**
@@ -170,29 +171,20 @@ class Umbra_Bookshelf_Helper {
/**
* Schedule cron events
*
* Rating refresh is owned solely by Umbra_Rating_Sync, which schedules the
* single 'bd_bookshelf_sync_ratings' event (every 12 hours) and drives the
* one surviving scraper, Umbra_Goodreads_Scraper.
*
* The former 'bd_bookshelf_goodreads_refresh' (daily) and
* 'bd_bookshelf_amazon_scrape' (six-hourly) events are no longer scheduled.
* Both pointed at empty stub methods, so each tick only fired HTTP-free
* no-ops -- and the Amazon one duplicated work the rating sync already did.
* Existing installations still get them cleared on deactivation and on
* uninstall, so no stale schedules are left behind.
*/
public function schedule_cron_events() {
if (!wp_next_scheduled('bd_bookshelf_goodreads_refresh')) {
wp_schedule_event(time(), 'daily', 'bd_bookshelf_goodreads_refresh');
}
if (!wp_next_scheduled('bd_bookshelf_amazon_scrape')) {
wp_schedule_event(time(), 'six_hours', 'bd_bookshelf_amazon_scrape');
}
}
/**
* Refresh Goodreads data
*/
public function refresh_goodreads_data() {
// Implementation for Goodreads API refresh
}
/**
* Scrape Amazon ratings
*/
public function scrape_amazon_ratings() {
// Implementation for Amazon rating scrape
// Intentionally empty: see the note above.
}
/**

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Admin Class
@@ -1132,8 +1132,7 @@ class Umbra_Bookshelf_Helper_Admin {
echo '<p class="description">' . esc_html__('Default: 30469651 (B_D_Murphy)', 'umbra-bookshelf') . '</p></td></tr>';
echo '</table>';
echo '<h2>' . esc_html__('Amazon Ratings Warning', 'umbra-bookshelf') . '</h2>';
echo '<p style="color:#d63638;">' . esc_html__('Warning: Scraping Amazon product pages may conflict with their Terms of Service. Use at your own risk.', 'umbra-bookshelf') . '</p>';
echo '<p class="description">' . esc_html__('Ratings are refreshed every 12 hours from Goodreads. Amazon ratings are not scraped.', 'umbra-bookshelf') . '</p>';
submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings');
echo '</form></div>';

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Installation and Database Setup
@@ -34,26 +34,23 @@ class Umbra_Bookshelf_Helper_Install {
/**
* Create database tables
*
* {prefix}bd_books and {prefix}bd_series are NOT created here. Those two
* table names are owned by the catalogue module (Umbra_Database), whose
* schema is canonical. This module previously created them with a
* different column set, so activating both plugins meant one silently
* created the table and the other read it against the wrong shape.
*
* Existing library-shaped installations are reconciled by
* Umbra_Schema_Migration, which runs before this method.
*
* @see \Umbra_Schema_Migration::maybe_migrate()
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$tables = array(
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_books (
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT(20) NOT NULL,
pen_name_id BIGINT(20),
series_id BIGINT(20),
volume_number INT(11),
asin VARCHAR(20),
isbn VARCHAR(20),
publish_date DATE,
sort_order INT(11) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY post_id (post_id)
) $charset_collate;",
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_book_meta (
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
book_id BIGINT(20) NOT NULL,
@@ -65,17 +62,6 @@ class Umbra_Bookshelf_Helper_Install {
UNIQUE KEY book_language (book_id, language_code)
) $charset_collate;",
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_series (
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
cover_attachment_id BIGINT(20),
header_attachment_id BIGINT(20),
blurb TEXT,
display_order INT(11) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) $charset_collate;",
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_pen_names (
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
pen_name VARCHAR(255) NOT NULL,

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* BD BookShelfHelper Admin JavaScript

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* BD Bookshelf - GDPR Consent

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* BD Bookshelf - Public JavaScript

View File

@@ -4,9 +4,9 @@ Tags: books, series, isbn, authors, newsletter, publishing
Requires at least: 6.0
Tested up to: 6.6
Requires PHP: 7.4
Stable tag: 1.0.0
License: Apache-2.0
License URI: https://www.apache.org/licenses/LICENSE-2.0
Stable tag: 1.1.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Complete book management for authors and publishers: catalogue and ISBN tracking, series, pen names, retail links, rating sync, GDPR consent, and newsletter delivery.
@@ -31,12 +31,15 @@ Everything now sits under a single **Umbra Bookshelf** admin menu.
= Upgrading from the old plugins =
Umbra Bookshelf reads and writes the same database tables, option keys, post
meta, cron hooks and shortcodes as the three plugins it replaces. Existing data
is picked up as-is; no migration is required.
meta, cron hooks and shortcodes as the three plugins it replaces. Install Umbra
Bookshelf, then deactivate BD Bookshelf, BD BookShelfHelper and BD SimpleEmail.
**Do not delete** them from the Plugins page first — deleting runs their
uninstall routines, which drop your tables and options.
Install Umbra Bookshelf, then deactivate BD Bookshelf, BD BookShelfHelper and
BD SimpleEmail. **Do not delete** them from the Plugins page first — deleting
runs their uninstall routines, which drop your tables and options.
If you ran BD BookShelfHelper, the first page load after upgrading reconciles
your database automatically. The old table layout is kept aside as
`wp_bd_books_legacy_library` and `wp_bd_series_legacy_library` so nothing is
lost; you can drop those once you are satisfied the migration went well.
The shortcodes `[bd-book]`, `[bd-books]` and `[bdm_signup]` are unchanged, so
existing posts keep working. The admin menu locations have changed: see the
@@ -46,8 +49,11 @@ specification in `spec/SPEC_Umbra_Bookshelf_Plugin.md` for the full menu map.
= Do I need to migrate my books? =
No. Table names, option keys, post meta keys and shortcodes are deliberately
unchanged. Deactivate the old plugins rather than deleting them.
Usually no. Table names, option keys, post meta keys and shortcodes are
deliberately unchanged; deactivate the old plugins rather than deleting them.
The one exception is a site that ran BD BookShelfHelper: its `bd_books` and
`bd_series` tables had a different layout, and those are reconciled
automatically on upgrade, with the originals kept aside.
= The admin menu looks different. =
@@ -55,15 +61,31 @@ It is one menu now instead of three. The catalogue, library and email screens
are all still present as submenus, and Settings is split into catalogue
settings and library settings.
= Where do ratings come from? =
Goodreads, refreshed every 12 hours. Amazon ratings are not scraped. Amazon
retail *links* still work as normal.
= Are there known problems? =
Yes — see `docs/KNOWN-ISSUES.md`. The most significant is that the catalogue and
library modules both create `bd_books` and `bd_series` with different schemas,
inherited from the old plugins. If you ran more than one of them together, check
that screen before relying on it.
Yes — see `docs/KNOWN-ISSUES.md`, which is included with the plugin.
== Changelog ==
= 1.1.0 =
* Relicensed to GPLv2 or later for WordPress.org compatibility. The plugin
headers previously said GPLv2 while the licence files said Apache-2.0.
* The catalogue schema is now canonical for `bd_books` and `bd_series`. A site
that ran BD BookShelfHelper is reconciled automatically, preserving book IDs,
ratings and post links; the old tables are kept aside rather than dropped.
* Goodreads is now the only rating source, with a single implementation and one
12-hour cron event. Duplicated Amazon rating scraping removed.
* `Umbra_SEO` is now active, emitting meta, Open Graph, Twitter card and sitemap
data for book detail pages.
* Fixed a missing default cover image and a missing email admin script, both of
which caused 404s.
* Admin settings screen for ratings now notes that Goodreads is the only source.
= 1.0.0 =
* Initial release of the consolidated plugin.
* Merged BD Bookshelf, BD BookShelfHelper and BD SimpleEmail into one plugin

View File

@@ -1,9 +1,9 @@
# Umbra Bookshelf — Plugin Specification
**Status:** Active
**Version:** 1.0.0
**Version:** 1.1.0
**Supersedes:** `spec/archive/SPEC_BD_Bookshelf_Plugin.txt`, `spec/archive/SPEC_BD_BookHelper_Plugin.txt`
**License:** Apache-2.0
**License:** GPL-2.0-or-later (WordPress.org compatible)
---
@@ -18,7 +18,7 @@
| Domain path | `/languages` |
| Author | Umbra Tools |
| Requires | WordPress 6.0, PHP 7.4 |
| Version | 1.0.0 |
| Version | 1.1.0 |
The three pre-merge plugins map onto modules as follows:
@@ -63,7 +63,7 @@ post content. Renaming any of them silently orphans existing data.
| 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`, `bd_bookshelf_goodreads_refresh`, `bd_bookshelf_amazon_scrape` |
| 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` |
@@ -84,8 +84,9 @@ 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, scrapers, admin, import
│ │ 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
@@ -119,11 +120,20 @@ 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_Amazon_Scraper`, `Umbra_Admin`,
`Umbra_Migration`, `Umbra_Theme_Compatibility`.
`Umbra_GDPR`, `Umbra_Goodreads_Scraper`, `Umbra_Admin`, `Umbra_Migration`,
`Umbra_Theme_Compatibility`.
> `Umbra_SEO` is never instantiated by the bootstrap (carried over unchanged from
> the original plugin). See `docs/KNOWN-ISSUES.md` item 5.
**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/`
@@ -134,6 +144,10 @@ 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]`,
@@ -181,27 +195,74 @@ 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:** 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
* **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`.
* **Deactivate:** clear all three cron hooks, run module deactivators, flush
rewrites.
* **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`.
`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
@@ -213,12 +274,28 @@ 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.
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
## 8. Open issues and tasks
`bd_books` and `bd_series` are created with two incompatible schemas by the
books and library modules. This predates the consolidation and is **not** fixed
here; see `docs/KNOWN-ISSUES.md`.
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.

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run every Umbra Bookshelf test suite.
#
# tests/run_all.sh
#
# Each runtime suite runs in its own PHP process: the plugin declares classes
# at include time, so a front-end request and an admin request cannot share one.
set -uo pipefail
cd "$(dirname "$0")"
fail=0
run() {
local label="$1"; shift
printf '\n=== %s ===\n' "$label"
if "$@"; then
return 0
fi
fail=1
}
run "static structure" python3 verify_structure.py
run "runtime: front-end" php smoke_runtime.php front
run "runtime: admin" php smoke_runtime.php admin
run "schema migration" php schema_migration_test.php
printf '\n'
if [ "$fail" -ne 0 ]; then
echo "SUITE FAILED"
exit 1
fi
echo "ALL SUITES PASSED"

View File

@@ -0,0 +1,423 @@
<?php
/**
* Unit test for Umbra_Schema_Migration.
*
* The consolidation made the catalogue schema canonical for bd_books and
* bd_series. Detecting the wrong shape, or dropping a row, would corrupt real
* installations, so this exercises the migration against a real database
* rather than asserting on strings.
*
* The database is a throwaway SQLite file driven through the sqlite3 CLI: no
* credentials, no shared server, nothing to clean up but the file itself.
* A minimal wpdb shim and a stand-in Umbra_Database are defined before the
* migration is loaded, so neither the real WordPress nor the real
* dbDelta-based installer is involved.
*
* Run: php umbra-bookshelf/tests/schema_migration_test.php
*/
define('ABSPATH', '/tmp/fake-wp/');
// WordPress constants the migration relies on.
define('ARRAY_A', 'ARRAY_A');
define('OBJECT', 'OBJECT');
// ---------------------------------------------------------------- wpdb shim
class FakeWpdb {
public $prefix = 'wp_';
public $file;
public $inserted = array();
public $renamed = array();
public $meta = array();
public function __construct() {
$this->file = tempnam(sys_get_temp_dir(), 'umbra-schema-') . '.db';
// tempnam() creates the file; sqlite is happy to adopt it.
}
public function __destruct() {
if ($this->file && is_file($this->file)) {
@unlink($this->file);
}
}
/** Bare table name, as the migration sees it. */
public function bare($name) {
return $this->prefix . 'bd_' . $name;
}
// --- sqlite3 CLI plumbing --------------------------------------------
/**
* Run a statement for effect.
*/
public function exec($sql) {
$cmd = sprintf(
'sqlite3 %s %s 2>&1',
escapeshellarg($this->file),
escapeshellarg($sql)
);
$out = shell_exec($cmd);
if ($out !== null && trim($out) !== '') {
throw new RuntimeException('sqlite3: ' . trim($out) . ' :: ' . $sql);
}
return true;
}
/**
* Run a query and decode rows as associative arrays.
*/
public function rows($sql) {
$cmd = sprintf(
'sqlite3 -json %s %s 2>&1',
escapeshellarg($this->file),
escapeshellarg($sql)
);
$out = trim((string) shell_exec($cmd));
if ($out === '') {
return array();
}
if ($out[0] !== '[') {
// A missing table means an assertion is about to fail; report it as
// no rows rather than tearing down the rest of the suite.
if (stripos($out, 'no such table') !== false) {
return array();
}
throw new RuntimeException('sqlite3: ' . $out . ' :: ' . $sql);
}
return json_decode($out, true) ?: array();
}
/**
* First column of the first row.
*/
public function scalar($sql) {
$rows = $this->rows($sql);
if (!$rows) {
return null;
}
return array_values($rows[0])[0];
}
public function table_exists($table) {
return (bool) $this->scalar(
"SELECT name FROM sqlite_master WHERE type='table' AND name=" . $this->quote($table)
);
}
/**
* Quote a value as a SQL literal.
*/
public function quote($value) {
if ($value === null) {
return 'NULL';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
return "'" . str_replace("'", "''", (string) $value) . "'";
}
/**
* Build "col = literal" pairs.
*/
private function assignments($data) {
$parts = array();
foreach ($data as $col => $value) {
$parts[] = $col . ' = ' . $this->quote($value);
}
return implode(', ', $parts);
}
// --- wpdb API surface used by the migration ---------------------------
public function prepare($query, ...$args) {
$i = 0;
return preg_replace_callback('/%[sdf]/', function ($m) use ($args, &$i) {
$v = $args[$i++] ?? '';
if ($m[0] === '%d') return (string) (int) $v;
if ($m[0] === '%f') return (string) (float) $v;
return "'" . str_replace("'", "''", (string) $v) . "'";
}, $query);
}
public function get_var($query) {
if (stripos($query, 'SHOW TABLES LIKE') !== false) {
preg_match("/LIKE\s+'([^']+)'/i", $query, $m);
return $this->table_exists($m[1]) ? $m[1] : null;
}
return $this->scalar($query);
}
public function get_col($query) {
if (stripos($query, 'DESCRIBE') !== false) {
preg_match('/DESCRIBE\s+(\S+)/i', $query, $m);
$cols = array();
foreach ($this->rows('PRAGMA table_info(' . $m[1] . ')') as $row) {
$cols[] = $row['name'];
}
return $cols;
}
$cols = array();
foreach ($this->rows($query) as $row) {
$cols[] = array_values($row)[0];
}
return $cols;
}
public function get_results($query, $output = null) {
return $this->rows($query);
}
public function query($query) {
// Translate MySQL DDL to its SQLite equivalent. The migration targets
// MySQL (that is what WordPress runs on); this shim only emulates it.
if (preg_match('/^\s*RENAME\s+TABLE\s+(\S+)\s+TO\s+(\S+)\s*;?\s*$/i', $query, $m)) {
$this->renamed[] = $query;
return $this->exec("ALTER TABLE {$m[1]} RENAME TO {$m[2]}");
}
return $this->exec($query);
}
public function insert($table, $data, $formats = null) {
$this->inserted[] = array($table, $data);
$cols = array_keys($data);
$vals = array_map(array($this, 'quote'), array_values($data));
$this->exec(
'INSERT INTO ' . $table . ' (' . implode(',', $cols) . ') VALUES (' .
implode(',', $vals) . ')'
);
return 1;
}
public function update($table, $data, $where, $f = null, $wf = null) {
$this->exec(
'UPDATE ' . $table . ' SET ' . $this->assignments($data) .
' WHERE ' . $this->assignments($where)
);
return 1;
}
}
$GLOBALS['wpdb'] = new FakeWpdb();
// --- options store used by the migration ---------------------------------
$GLOBALS['options'] = array();
function get_option($k, $d = false) { return $GLOBALS['options'][$k] ?? $d; }
function update_option($k, $v, $a = null) { $GLOBALS['options'][$k] = $v; return true; }
// --- WordPress helpers the migration calls ------------------------------
function current_time($type) { return gmdate('Y-m-d H:i:s'); }
function sanitize_title($t) {
$t = strtolower(trim($t));
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
return trim($t, '-');
}
function update_post_meta($post_id, $key, $value) {
$GLOBALS['wpdb']->meta[$post_id][$key] = $value;
return true;
}
function wp_get_attachment_url($id) {
return $id ? "https://example.test/wp-content/uploads/att-{$id}.jpg" : false;
}
function get_the_post_thumbnail_url($id, $size = '') {
return "https://example.test/wp-content/uploads/thumb-{$id}.jpg";
}
// --- posts the migration resolves titles/slugs/bodies from ---------------
$GLOBALS['posts'] = array(
101 => (object) array('ID' => 101, 'post_title' => 'The Silent Harbour', 'post_name' => 'the-silent-harbour', 'post_content' => 'A novel of tide and memory.'),
102 => (object) array('ID' => 102, 'post_title' => 'Harbour Lights', 'post_name' => 'harbour-lights', 'post_content' => 'Sequel.'),
);
function get_post($id) { return $GLOBALS['posts'][$id] ?? null; }
// --- stand-in for the catalogue installer --------------------------------
// The real one shells out to dbDelta(); here it just builds the canonical
// schema so the copy step has somewhere to write.
class Umbra_Database {
public static function table($name) { return $GLOBALS['wpdb']->bare($name); }
public function create_tables() {
$w = $GLOBALS['wpdb'];
if (!$w->table_exists(self::table('books'))) {
$w->exec('CREATE TABLE ' . self::table('books') . ' (
id INTEGER PRIMARY KEY, title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
description TEXT, cover_image TEXT, isbn_ebook TEXT, asin_ebook TEXT,
amazon_asin TEXT, goodreads_id TEXT,
rating_amazon REAL DEFAULT 0, rating_goodreads REAL DEFAULT 0,
last_sync TEXT, series_id INTEGER,
created_at TEXT, updated_at TEXT)');
}
if (!$w->table_exists(self::table('series'))) {
$w->exec('CREATE TABLE ' . self::table('series') . ' (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT,
cover_image TEXT, created_at TEXT)');
}
}
}
require dirname(__DIR__) . '/includes/class-umbra-schema-migration.php';
// ---------------------------------------------------------------- harness
$fail = 0;
$pass = 0;
function check($label, $cond, $detail = '') {
global $fail, $pass;
if ($cond) { $pass++; echo " ok $label\n"; }
else { $fail++; echo " FAIL $label" . ($detail ? " ($detail)" : '') . "\n"; }
}
function section($t) { echo "\n$t\n"; }
/** Build a library-shaped installation. */
function seed_library_shape() {
$w = $GLOBALS['wpdb'];
foreach (array('books', 'series', 'ratings', 'book_meta',
'books_legacy_library', 'series_legacy_library') as $t) {
$w->exec('DROP TABLE IF EXISTS ' . $w->bare($t));
}
$w->exec('CREATE TABLE ' . $w->bare('books') . ' (
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL, pen_name_id INTEGER,
series_id INTEGER, volume_number INTEGER, asin TEXT, isbn TEXT,
publish_date TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT)');
$w->exec('CREATE TABLE ' . $w->bare('series') . ' (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE,
cover_attachment_id INTEGER, header_attachment_id INTEGER,
blurb TEXT, display_order INTEGER DEFAULT 0, created_at TEXT)');
$w->exec('CREATE TABLE ' . $w->bare('ratings') . ' (
id INTEGER PRIMARY KEY, book_id INTEGER, source TEXT, rating REAL,
review_count INTEGER, last_updated TEXT)');
$w->exec('CREATE TABLE ' . $w->bare('book_meta') . ' (
id INTEGER PRIMARY KEY, book_id INTEGER, language_code TEXT, title TEXT)');
$b = $w->bare('books');
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
VALUES (1, 101, 1, 7, 1, 'B00TESTASIN', '9781234567897', '2024-03-01', 0, '2024-01-01 00:00:00')");
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
VALUES (2, 102, 1, 7, 2, NULL, '9781234567898', '2025-06-01', 1, '2025-01-01 00:00:00')");
// row 3 points at a post that no longer exists
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
VALUES (3, 999, NULL, NULL, NULL, NULL, NULL, NULL, 2, '2025-02-01 00:00:00')");
$s = $w->bare('series');
$w->exec("INSERT INTO {$s} (id, name, slug, cover_attachment_id, header_attachment_id, blurb, display_order, created_at)
VALUES (7, 'The Harbour Cycle', 'the-harbour-cycle', 55, 56, 'Ships, storms, and the people who stay.', 0, '2024-01-01 00:00:00')");
$r = $w->bare('ratings');
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (1, 'goodreads', 4.35, 1200, '2025-01-01 00:00:00')");
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (1, 'amazon', 4.60, 3400, '2025-01-01 00:00:00')");
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (2, 'goodreads', 4.10, 400, '2025-01-01 00:00:00')");
$m = $w->bare('book_meta');
$w->exec("INSERT INTO {$m} (book_id, language_code, title) VALUES (1, 'en', 'The Silent Harbour')");
$GLOBALS['options'] = array(); // fresh site
$GLOBALS['wpdb']->meta = array();
}
$w = $GLOBALS['wpdb'];
// ======================================================================
section('1. Shape detection on a library-shaped install');
seed_library_shape();
$status = Umbra_Schema_Migration::status();
check('bd_books detected as library shape', $status['books_shape'] === 'library', $status['books_shape']);
check('bd_series detected as library shape', $status['series_shape'] === 'library', $status['series_shape']);
check('schema version not yet advanced', $status['version'] === 0, (string) $status['version']);
section('2. Running the migration');
$report = Umbra_Schema_Migration::maybe_migrate();
check('no errors reported', empty($report['errors']), implode('; ', $report['errors']));
check('books rows copied', $report['books_migrated'] === 2, (string) $report['books_migrated']);
check('series rows copied', $report['series_migrated'] === 1, (string) $report['series_migrated']);
check('schema version advanced to target', $report['version'] === Umbra_Schema_Migration::TARGET_VERSION);
section('3. Resulting schema is the catalogue schema');
$status = Umbra_Schema_Migration::status();
check('bd_books is now catalogue shape', $status['books_shape'] === 'catalogue', $status['books_shape']);
check('bd_series is now catalogue shape', $status['series_shape'] === 'catalogue', $status['series_shape']);
section('4. Legacy tables preserved, not dropped');
check('bd_books_legacy_library exists', $w->table_exists($w->bare('books_legacy_library')));
check('bd_series_legacy_library exists', $w->table_exists($w->bare('series_legacy_library')));
check('legacy books table still holds its 3 rows',
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books_legacy_library')) === 3);
section('5. Book rows carried over');
$books = $w->rows('SELECT * FROM ' . $w->bare('books') . ' ORDER BY id');
check('two books present', count($books) === 2, (string) count($books));
$b1 = $books[0];
check('id preserved (1)', (int) $b1['id'] === 1, $b1['id']);
check('title resolved from the post', $b1['title'] === 'The Silent Harbour', $b1['title']);
check('slug taken from post_name', $b1['slug'] === 'the-silent-harbour', $b1['slug']);
check('description resolved from post_content', $b1['description'] === 'A novel of tide and memory.', $b1['description']);
check('isbn mapped onto isbn_ebook', $b1['isbn_ebook'] === '9781234567897', (string) $b1['isbn_ebook']);
check('asin mapped onto asin_ebook', $b1['asin_ebook'] === 'B00TESTASIN', (string) $b1['asin_ebook']);
check('amazon_asin retained for link building', $b1['amazon_asin'] === 'B00TESTASIN', (string) $b1['amazon_asin']);
check('series_id preserved', (int) $b1['series_id'] === 7, (string) $b1['series_id']);
$ids = array_map(function ($r) { return (int) $r['id']; }, $books);
check('orphan row (missing post) skipped, not crashed', !in_array(3, $ids, true), implode(',', $ids));
check('legacy row count preserved for recovery',
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books_legacy_library')) === 3);
section('6. Ratings folded into catalogue columns');
check('goodreads rating mapped', (float) $b1['rating_goodreads'] === 4.35, (string) $b1['rating_goodreads']);
check('amazon rating preserved (column retained)', (float) $b1['rating_amazon'] === 4.60, (string) $b1['rating_amazon']);
$b2 = $books[1];
check('second book goodreads rating mapped', (float) $b2['rating_goodreads'] === 4.10, (string) $b2['rating_goodreads']);
section('7. Post linkage written with the preserved meta key');
check('_bd_bookshelf_book_id set for post 101', ($w->meta[101]['_bd_bookshelf_book_id'] ?? null) == 1);
check('_bd_bookshelf_book_id set for post 102', ($w->meta[102]['_bd_bookshelf_book_id'] ?? null) == 2);
check('publish date preserved on the post',
($w->meta[101]['_bd_bookshelf_publish_date'] ?? null) === '2024-03-01',
$w->meta[101]['_bd_bookshelf_publish_date'] ?? 'unset');
section('8. Series migrated');
$series = $w->rows('SELECT * FROM ' . $w->bare('series'));
check('one series row', count($series) === 1);
check('series id preserved', (int) $series[0]['id'] === 7, $series[0]['id']);
check('series name preserved', $series[0]['name'] === 'The Harbour Cycle', $series[0]['name']);
check('blurb mapped to description',
$series[0]['description'] === 'Ships, storms, and the people who stay.',
$series[0]['description']);
check('cover attachment resolved to a URL',
strpos((string) $series[0]['cover_image'], 'att-55') !== false,
(string) $series[0]['cover_image']);
section('9. Idempotency');
$again = Umbra_Schema_Migration::maybe_migrate();
check('second run is skipped', !empty($again['skipped']));
check('no rows duplicated on re-run',
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books')) === 2);
section('10. An already-catalogue-shaped install is left alone');
// simulate an older site that never recorded the migration, but whose tables
// are already canonical
$w->exec('DROP TABLE IF EXISTS ' . $w->bare('books_legacy_library'));
$w->exec('DROP TABLE IF EXISTS ' . $w->bare('series_legacy_library'));
$GLOBALS['options'] = array();
$before_renames = count($w->renamed);
$third = Umbra_Schema_Migration::maybe_migrate();
check('no rows copied from an already-correct schema', $third['books_migrated'] === 0, (string) $third['books_migrated']);
check('no rename performed', count($w->renamed) === $before_renames);
check('books untouched', (int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books')) === 2);
section('11. Duplicate slugs are resolved (catalogue slug is UNIQUE)');
seed_library_shape();
$GLOBALS['posts'][101]->post_name = 'harbour-lights';
$GLOBALS['posts'][102]->post_name = 'harbour-lights';
$collide = Umbra_Schema_Migration::maybe_migrate();
check('collision run reported no errors', empty($collide['errors']), implode('; ', $collide['errors']));
$slugs = $w->rows('SELECT slug FROM ' . $w->bare('books') . ' ORDER BY id');
$slugs = array_map(function ($r) { return $r['slug']; }, $slugs);
check('both books still present', count($slugs) === 2, implode(',', $slugs));
check('slugs are unique', count($slugs) === count(array_unique($slugs)), implode(',', $slugs));
section('RESULT');
echo "\n";
if ($fail) { echo "FAIL - {$fail} check(s) failed, {$pass} passed\n"; exit(1); }
echo "PASS - {$pass} checks, schema migration verified against a real database\n";

View File

@@ -155,7 +155,7 @@ section('3. Module classes loaded');
foreach (array('Umbra_Database', 'Umbra_Book_Manager', 'Umbra_Series',
'Umbra_Authors', 'Umbra_Retail_Sites', 'Umbra_Rating_Sync',
'Umbra_Shortcodes', 'Umbra_SEO', 'Umbra_GDPR',
'Umbra_Goodreads_Scraper', 'Umbra_Amazon_Scraper',
'Umbra_Goodreads_Scraper', 'Umbra_Schema_Migration',
'Umbra_Admin', 'Umbra_Migration', 'Umbra_Theme_Compatibility',
'Umbra_Bookshelf_Helper', 'Umbra_Bookshelf_Helper_Admin',
'Umbra_Bookshelf_Helper_Install', 'Umbra_Simple_Email',
@@ -175,8 +175,13 @@ check('real library class still distinct',
!is_a('Umbra_Bookshelf_Helper', 'Umbra_Theme_Compatibility', true));
section('5. Constants');
check('UMBRA_BOOKSHELF_VERSION', defined('UMBRA_BOOKSHELF_VERSION')
&& UMBRA_BOOKSHELF_VERSION === '1.0.0', UMBRA_BOOKSHELF_VERSION ?? 'undefined');
// Compare against the plugin header rather than a hard-coded literal, so a
// version bump cannot silently break the suite.
$boot_src = file_get_contents(dirname(__DIR__) . '/umbra-bookshelf.php');
$header_version = preg_match('/^\s*\*\s*Version:\s*(\S+)/m', $boot_src, $vm) ? $vm[1] : null;
check('UMBRA_BOOKSHELF_VERSION matches the plugin header',
defined('UMBRA_BOOKSHELF_VERSION') && UMBRA_BOOKSHELF_VERSION === $header_version,
(UMBRA_BOOKSHELF_VERSION ?? 'undefined') . ' vs header ' . ($header_version ?? 'none'));
check('UMBRA_BOOKSHELF_DIR', defined('UMBRA_BOOKSHELF_DIR'));
check('UMBRA_BOOKSHELF_URL', defined('UMBRA_BOOKSHELF_URL'));
check('UMBRA_BOOKSHELF_BASENAME', defined('UMBRA_BOOKSHELF_BASENAME'));

View File

@@ -41,8 +41,10 @@ for f in php:
print('1. php -l: %d files checked' % len(php))
# ------------------------------------------------- 2. duplicate class decls
# Shipped source only: tests/ defines intentional stubs of real classes.
source_php = [f for f in php if os.sep + 'tests' + os.sep not in f + os.sep]
decls = {}
for f in php:
for f in source_php:
src = io.open(f, encoding='utf-8', errors='replace').read()
for m in re.finditer(r'^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)',
src, re.M):
@@ -55,7 +57,7 @@ print('2. class declarations: %d unique, %d duplicated'
# --------------------------------------------- 3. referenced classes exist
refs = set()
for f in php:
for f in source_php:
src = io.open(f, encoding='utf-8', errors='replace').read()
for m in re.finditer(r'\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(', src):
refs.add(m.group(1))
@@ -70,7 +72,9 @@ wp_provided = {'BD_BookShelfHelper', # created via class_alias()
known_external = {'GeminiClient',
# browser JS globals, matched by the `new X(` heuristic
# when scanning inline <script> blocks inside PHP files
'FormData', 'URL', 'Image', 'Audio'}
'FormData', 'URL', 'Image', 'Audio',
# SPL / core exceptions
'RuntimeException', 'InvalidArgumentException', 'LogicException'}
missing = sorted(r for r in refs
if r not in decls
and r not in wp_provided

View File

@@ -1,18 +1,18 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Plugin Name: Umbra Bookshelf
* Plugin URI: https://umbratools.com/plugins/umbra-bookshelf
* Description: Complete book management for authors and publishers: catalogue and ISBN tracking, series and pen names, retail links, rating synchronisation, GDPR consent, data entry, migration tooling, and newsletter delivery.
* Version: 1.0.0
* Version: 1.1.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: Umbra Tools
* Author URI: https://umbratools.com
* License: Apache-2.0
* License URI: https://www.apache.org/licenses/LICENSE-2.0
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: umbra-bookshelf
* Domain Path: /languages
*
@@ -34,7 +34,7 @@ if (!defined('ABSPATH')) {
/**
* Plugin constants
*/
define('UMBRA_BOOKSHELF_VERSION', '1.0.0');
define('UMBRA_BOOKSHELF_VERSION', '1.1.0');
define('UMBRA_BOOKSHELF_FILE', __FILE__);
define('UMBRA_BOOKSHELF_DIR', plugin_dir_path(__FILE__));
define('UMBRA_BOOKSHELF_URL', plugin_dir_url(__FILE__));
@@ -54,6 +54,9 @@ define('UMBRA_BOOKSHELF_EMAIL_VERSION', '2.0.0');
* Load module files
*/
// Schema reconciliation (catalogue tables are canonical; runs before dbDelta)
require_once UMBRA_BOOKSHELF_DIR . 'includes/class-umbra-schema-migration.php';
// Books / catalogue module (ex-BD Bookshelf)
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-database.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-book-manager.php';
@@ -65,7 +68,6 @@ require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-shortcodes.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-seo.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-gdpr.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-goodreads-scraper.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-amazon-scraper.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-admin.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-migration.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-theme-compatibility.php';
@@ -140,6 +142,25 @@ final class Umbra_Bookshelf {
// means those init callbacks are added while 'init' is already being
// dispatched, so whether they run becomes an ordering accident.
add_action('plugins_loaded', [$this, 'init_modules']);
// Plugin *updates* do not re-run the activation hook, so a site
// upgrading from the old plugins would never be reconciled. Check on
// admin_init instead: cheap (one option read), and keeps schema work
// off front-end requests.
add_action('admin_init', [$this, 'maybe_upgrade_schema'], 5);
}
/**
* Reconcile a pre-consolidation schema on plugin update.
*
* @return void
*/
public function maybe_upgrade_schema() {
$version = (int) get_option(Umbra_Schema_Migration::VERSION_OPTION, 0);
if ($version < Umbra_Schema_Migration::TARGET_VERSION) {
Umbra_Schema_Migration::maybe_migrate();
}
}
/**
@@ -254,6 +275,10 @@ final class Umbra_Bookshelf {
new Umbra_GDPR();
new Umbra_Rating_Sync();
// SEO metadata for book detail pages: per-book title/description/
// keywords, Open Graph, Twitter cards and sitemap weighting.
new Umbra_SEO();
// Library module (post type, taxonomy, shortcodes, pen names)
Umbra_Bookshelf_Helper::instance();
@@ -271,12 +296,15 @@ final class Umbra_Bookshelf {
/**
* Plugin activation
*
* Runs each module's installer. See docs/KNOWN-ISSUES.md item 1: the
* catalogue and library modules both create bd_books / bd_series with
* different schemas, which is a pre-existing conflict carried over from
* the separate plugins and deliberately not resolved in this release.
* Reconciles a pre-consolidation schema first, then creates tables. See
* Umbra_Schema_Migration for why the ordering matters.
*/
public function activate() {
// Reconcile any pre-consolidation schema BEFORE dbDelta runs, so the
// catalogue's dbDelta() sees either a correct catalogue table or none
// at all -- never a library-shaped table it would graft columns onto.
Umbra_Schema_Migration::maybe_migrate();
$database = new Umbra_Database();
$database->create_tables();

View File

@@ -1,6 +1,6 @@
<?php
// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-2.0-or-later
/**
* Umbra Bookshelf Uninstall