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

474
LICENSE
View File

@@ -1,202 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Apache License Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
Version 2.0, January 2004 <https://fsf.org/>
http://www.apache.org/licenses/ 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, When we speak of free software, we are referring to freedom, not
and distribution as defined by Sections 1 through 9 of this document. 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 To protect your rights, we need to make restrictions that forbid
the copyright owner that is granting the License. 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 For example, if you distribute copies of such a program, whether
other entities that control, are controlled by, or are under common gratis or for a fee, you must give the recipients all the rights that
control with that entity. For the purposes of this definition, you have. You must make sure that they, too, receive or can get the
"control" means (i) the power, direct or indirect, to cause the source code. And you must show them these terms so they know their
direction or management of such entity, whether by contract or rights.
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity We protect your rights with two steps: (1) copyright the software, and
exercising permissions granted by this License. (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, Also, for each author's protection and ours, we want to make certain
including but not limited to software source code, documentation that everyone understands that there is no warranty for this free
source, and configuration files. 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 Finally, any free program is threatened constantly by software
transformation or translation of a Source form, including but patents. We wish to avoid the danger that redistributors of a free
not limited to compiled object code, generated documentation, program will individually obtain patent licenses, in effect making the
and conversions to other media types. 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 The precise terms and conditions for copying, distribution and
Object form, made available under the License, as indicated by a modification follow.
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object GNU GENERAL PUBLIC LICENSE
form, that is based on (or derived from) the Work and for which the TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
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.
"Contribution" shall mean any work of authorship, including 0. This License applies to any program or other work which contains
the original version of the Work and any modifications or additions a notice placed by the copyright holder saying it may be distributed
to that Work or Derivative Works thereof, that is intentionally under the terms of this General Public License. The "Program", below,
submitted to Licensor for inclusion in the Work by the copyright owner refers to any such program or work, and a "work based on the Program"
or by an individual or Legal Entity authorized to submit on behalf of means either the Program or any derivative work under copyright law:
the copyright owner. For the purposes of this definition, "submitted" that is to say, a work containing the Program or a portion of it,
means any form of electronic, verbal, or written communication sent either verbatim or with modifications and/or translated into another
to the Licensor or its representatives, including but not limited to language. (Hereinafter, translation is included without limitation in
communication on electronic mailing lists, source code control systems, the term "modification".) Each licensee is addressed as "you".
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."
"Contributor" shall mean Licensor and any individual or Legal Entity Activities other than copying, distribution and modification are not
on behalf of whom a Contribution has been received by Licensor and covered by this License; they are outside its scope. The act of
subsequently incorporated within the Work. 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 1. You may copy and distribute verbatim copies of the Program's
this License, each Contributor hereby grants to You a perpetual, source code as you receive it, in any medium, provided that you
worldwide, non-exclusive, no-charge, royalty-free, irrevocable conspicuously and appropriately publish on each copy an appropriate
copyright license to reproduce, prepare Derivative Works of, copyright notice and disclaimer of warranty; keep intact all the
publicly display, publicly perform, sublicense, and distribute the notices that refer to this License and to the absence of any warranty;
Work and such Derivative Works in Source or Object form. 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 You may charge a fee for the physical act of transferring a copy, and
this License, each Contributor hereby grants to You a perpetual, you may at your option offer warranty protection in exchange for a fee.
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.
4. Redistribution. You may reproduce and distribute copies of the 2. You may modify your copy or copies of the Program or any portion
Work or Derivative Works thereof in any medium, with or without of it, thus forming a work based on the Program, and copy and
modifications, and in Source or Object form, provided that You distribute such modifications or work under the terms of Section 1
meet the following conditions: above, provided that you also meet all of these conditions:
(a) You must give any other recipients of the Work or a) You must cause the modified files to carry prominent notices
Derivative Works a copy of this License; and stating that you changed the files and the date of any change.
(b) You must cause any modified files to carry prominent notices b) You must cause any work that you distribute or publish, that in
stating that You changed the files; and 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 c) If the modified program normally reads commands interactively
that You distribute, all copyright, patent, trademark, and when run, you must cause it, when started running for such
attribution notices from the Source form of the Work, interactive use in the most ordinary way, to print or display an
excluding those notices that do not pertain to any part of announcement including an appropriate copyright notice and a
the Derivative Works; and 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 These requirements apply to the modified work as a whole. If
distribution, then any Derivative Works that You distribute must identifiable sections of that work are not derived from the Program,
include a readable copy of the attribution notices contained and can be reasonably considered independent and separate works in
within such NOTICE file, excluding those notices that do not themselves, then this License, and its terms, do not apply to those
pertain to any part of the Derivative Works, in at least one sections when you distribute them as separate works. But when you
of the following places: within a NOTICE text file distributed distribute the same sections as part of a whole which is a work based
as part of the Derivative Works; within the Source form or on the Program, the distribution of the whole must be on the terms of
documentation, if provided along with the Derivative Works; or, this License, whose permissions for other licensees extend to the
within a display generated by the Derivative Works, if and entire whole, and thus to each and every part regardless of who wrote it.
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.
You may add Your own copyright statement to Your modifications and Thus, it is not the intent of this section to claim rights or contest
may provide additional or different license terms and conditions your rights to work written entirely by you; rather, the intent is to
for use, reproduction, or distribution of Your modifications, or exercise the right to control the distribution of derivative or
for any such Derivative Works as a whole, provided Your use, collective works based on the Program.
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, In addition, mere aggregation of another work not based on the Program
any Contribution intentionally submitted for inclusion in the Work with the Program (or with a work based on the Program) on a volume of
by You to the Licensor shall be under the terms and conditions of a storage or distribution medium does not bring the other work under
this License, without any additional terms or conditions. the scope of this License.
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.
6. Trademarks. This License does not grant permission to use the trade 3. You may copy and distribute the Program (or a work based on it,
names, trademarks, service marks, or product names of the Licensor, under Section 2) in object code or executable form under the terms of
except as required for reasonable and customary use in describing the Sections 1 and 2 above provided that you also do one of the following:
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or a) Accompany it with the complete corresponding machine-readable
agreed to in writing, Licensor provides the Work (and each source code, which must be distributed under the terms of Sections
Contributor provides its Contributions) on an "AS IS" BASIS, 1 and 2 above on a medium customarily used for software interchange; or,
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.
8. Limitation of Liability. In no event and under no legal theory, b) Accompany it with a written offer, valid for at least three
whether in tort (including negligence), contract, or otherwise, years, to give any third party, for a charge no more than your
unless required by applicable law (such as deliberate and grossly cost of physically performing source distribution, a complete
negligent acts) or agreed to in writing, shall any Contributor be machine-readable copy of the corresponding source code, to be
liable to You for damages, including any direct, indirect, special, distributed under the terms of Sections 1 and 2 above on a medium
incidental, or consequential damages of any character arising as a customarily used for software interchange; or,
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.
9. Accepting Warranty or Additional Liability. While redistributing c) Accompany it with the information you received as to the offer
the Work or Derivative Works thereof, You may choose to offer, to distribute corresponding source code. (This alternative is
and charge a fee for, acceptance of support, warranty, indemnity, allowed only for noncommercial distribution and only if you
or other liability obligations and/or rights consistent with this received the program in object code or executable form with such
License. However, in accepting such obligations, You may act only an offer, in accord with Subsection b above.)
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.
END OF TERMS AND CONDITIONS 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.
APPENDIX: How to apply the Apache License to your work. 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.
To apply the Apache License to your work, attach the following 4. You may not copy, modify, sublicense, or distribute the Program
boilerplate notice, with the fields enclosed by brackets "[]" except as expressly provided under this License. Any attempt
replaced with your own identifying information. (Don't include otherwise to copy, modify, sublicense or distribute the Program is
the brackets!) The text should be enclosed in the appropriate void, and will automatically terminate your rights under this License.
comment syntax for the file format. We also recommend that a However, parties who have received copies, or rights, from you under
file or class name and description of purpose be included on the this License will not have their licenses terminated so long as such
same "printed page" as the copyright notice for easier parties remain in full compliance.
identification within third-party archives.
Copyright 2026 Umbra Tools (umbratools.com) 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.
Licensed under the Apache License, Version 2.0 (the "License"); 6. Each time you redistribute the Program (or any work based on the
you may not use this file except in compliance with the License. Program), the recipient automatically receives a license from the
You may obtain a copy of the License at 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.
http://www.apache.org/licenses/LICENSE-2.0 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.
Unless required by applicable law or agreed to in writing, software If any portion of this section is held invalid or unenforceable under
distributed under the License is distributed on an "AS IS" BASIS, any particular circumstance, the balance of the section is intended to
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. apply and the section as a whole is intended to apply in other
See the License for the specific language governing permissions and circumstances.
limitations under the License.
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
How to Apply These Terms to Your New Programs
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.
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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.
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 ## Status
`v1.0.0` is the first consolidated release. The most important outstanding `v1.1.0` resolves the three problems that blocked the consolidation:
problem is documented in
[`docs/KNOWN-ISSUES.md`](umbra-bookshelf/docs/KNOWN-ISSUES.md) §1: the books and * **`bd_books` and `bd_series` schema conflict** — the catalogue schema is now
library modules still create `bd_books` and `bd_series` with two incompatible canonical, with a migration that preserves data from installations built on the
schemas. This predates the consolidation — merging the plugins made it old library schema. Legacy tables are renamed aside, never dropped.
unconditional, and resolving it requires a data migration. * **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 ## 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 <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Custom Widgets * Custom Widgets

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // 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 * BDMurphy-Bookshelf functions and definitions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // 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 * The template for displaying comments

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // 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 * External Book Data API Helpers

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // 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 * Short URL Rewrites - Override BookHelper for direct template rendering

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* BD Bookshelf - Admin JavaScript * 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 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_Book_Manager …_Helper_Admin …_Admin
Umbra_Authors …_Helper_Install …_Install Umbra_Authors …_Helper_Install …_Install
Umbra_Series Umbra_Email_Sender 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_Shortcodes
Umbra_SEO assets: admin/, public/, library/, email/ Umbra_SEO assets: admin/, public/, library/, email/
Umbra_GDPR assets/ Umbra_GDPR assets/
Umbra_Goodreads_Scraper Umbra_Goodreads_Scraper (only scraper)
Umbra_Amazon_Scraper
Umbra_Admin Umbra_Admin
Umbra_Migration Umbra_Migration
Umbra_Theme_Compatibility -> class_alias BD_BookShelfHelper Umbra_Theme_Compatibility -> class_alias BD_BookShelfHelper
includes/class-umbra-schema-migration.php reconciles pre-consolidation schemas
``` ```
## Design rules ## Design rules
@@ -62,9 +63,19 @@ callbacks are registered before `init` fires.
static API the unmodified theme calls, and re-exports the legacy static API the unmodified theme calls, and re-exports the legacy
`BD_BookShelfHelper` name via `class_alias()`. `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 **Schema work happens before `dbDelta`, never after.** `dbDelta` adds columns but
to share table names `bd_books` and `bd_series`. Consolidation made them never drops them, so pointing it at a table of the wrong shape produces a table
unconditionally conflict; it did not make them agree. Resolving this is the next that satisfies neither schema. Reconciliation therefore runs first, and the
piece of work — see `KNOWN-ISSUES.md` §1 for the options. 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 # Known issues
Carried into the Umbra Bookshelf consolidation. Items 1–4 are **pre-existing** Defects and carried-over problems. For intended work, see `TASKS.md`.
defects inherited from the three merged plugins; item 5 and the two repaired
below are noted for traceability. 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 ### 1. Rating sync blocks a worker with `sleep()`
different columns.
`Umbra_Database::table()` returns `$wpdb->prefix . 'bd_' . $name`, and the `includes/books/class-umbra-rating-sync.php`
catalogue issues `CREATE TABLE {prefix}bd_books` with **no** `IF NOT EXISTS`
guard: `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, catalogue wp_bd_books id, title, slug, description, cover_image, isbn_*,
goodreads_id, rating_*, created_at, ... asin_*, amazon_asin, goodreads_id, rating_*, series_id
library wp_bd_books id, post_id, pen_name_id, series_id, volume_number, 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` …; **Now:** `includes/class-umbra-schema-migration.php` runs before `dbDelta` on
library: `name`, `slug`, `cover_attachment_id`, `header_attachment_id`, `blurb`, …). 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 * book **IDs are preserved**, which keeps `bd_book_meta`, `bd_reviews`,
no-ops (`IF NOT EXISTS`, library) or raises a database error (catalogue's `bd_ratings` and `bd_review_shortlinks` valid
unguarded `CREATE TABLE`). Whichever schema exists first is what the other * `title`, `slug` and `description` are resolved from the linked post
module then reads and writes against, producing wrong results rather than an * the library's single `isbn`/`asin` land on the ebook columns; `amazon_asin` is
obvious failure. 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 Rows whose post no longer exists are skipped with a log entry rather than
live data — a data-migration project, not a rename. Both datasets may hold rows creating an unusable book. Duplicate slugs are de-duplicated, because the
that matter. 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_*` ### Only Goodreads scraping remains
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.
--- **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`. Both library "scrapers" were empty stubs attached to cron events, so
* Library: `Umbra_Bookshelf_Helper::refresh_goodreads_data()`, `bd_bookshelf_goodreads_refresh` (daily) and `bd_bookshelf_amazon_scrape`
`::scrape_amazon_ratings()`. (six-hourly) fired on schedule to do nothing.
Both attach to the **same** cron hooks (`bd_bookshelf_goodreads_refresh`, **Now:** Goodreads only, and one implementation of it.
`bd_bookshelf_amazon_scrape`), so each scheduled run performs duplicate `Umbra_Amazon_Scraper` is deleted; the duplicate HTML parsing is gone from
requests to third-party APIs — wasted quota, faster rate limiting, and two `Umbra_Rating_Sync`, which now delegates to `Umbra_Goodreads_Scraper`. The two
places to fix a parsing bug. Needs a decision on which implementation survives. 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 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 ### Email admin script did not exist
generated at that path. Replace it with real artwork before release.
`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 ## Fixed in 1.0.0 (the consolidation)
`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
* **`Cannot declare class` fatal.** `class-bdm-theme-compatibility.php` declared * **`Cannot declare class` fatal.** `class-bdm-theme-compatibility.php` declared
`BD_BookShelfHelper`, the same class name as the library plugin's own helper. `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 Latent while the two were separate plugins — it only fired when both were
active), but an unconditional fatal once merged. The shim is now active — but an unconditional fatal once merged. The shim is now
`Umbra_Theme_Compatibility`, with a `class_alias()` preserving the legacy name `Umbra_Theme_Compatibility`, with `class_alias()` preserving the legacy name
for the unmodified theme. so the unmodified theme keeps working.
* **Duplicate `bd_bookshelf_import_csv` AJAX handler.** Both `Umbra_Admin` and * **Duplicate `bd_bookshelf_import_csv` AJAX handler.** `Umbra_Admin` and
`Umbra_Migration` registered it; the second silently replaced the first. The `Umbra_Migration` both registered it; the second silently replaced the first.
import page now lives solely in `Umbra_Admin` (it is the variant wired to The import page now lives solely in `Umbra_Admin` (the variant wired to
`admin/js/umbra-bookshelf-admin.js`); `Umbra_Migration` keeps only the `admin/js/umbra-bookshelf-admin.js`); `Umbra_Migration` keeps only the
preview/run endpoints it uniquely provides. preview/run endpoints it uniquely provides.
* **Duplicate `bd-bookshelf-settings` submenu.** Catalogue and library both * **Duplicate `bd-bookshelf-settings` submenu.** Catalogue and library both
claimed the slug, leaving one Settings screen unreachable. Now claimed the slug, leaving one Settings screen unreachable. Now
`umbra-bookshelf-settings` and `umbra-bookshelf-library-settings`. `umbra-bookshelf-settings` and `umbra-bookshelf-library-settings`.
* **Duplicate admin menu.** All three modules called `add_menu_page`. Now one * **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 * **Colliding asset handles.** Catalogue and library both enqueued
`bd-bookshelf-admin`; the second enqueue was silently discarded. Handles are `bd-bookshelf-admin`; the second enqueue was silently discarded. Handles are
now unique per module. now unique per module.
* **Modules could miss their own `init` callbacks.** Constructing modules from * **Modules could miss their own `init` callbacks.** Constructing modules from
inside an `init` callback means their `init` registrations race the hook inside an `init` callback means their `init` registrations race the hook being
currently being dispatched. Modules are now constructed on `plugins_loaded`, dispatched, so the `book` post type and `[bd-book]` shortcode could fail to
behind a re-entrancy guard. register. Modules now construct on `plugins_loaded`, behind a re-entrancy
guard.
* **Over-broad asset hook check.** The library module's * **Over-broad asset hook check.** The library module's
`strpos($hook, 'bd-bookshelf')` matched every catalogue and email screen once `strpos($hook, 'bd-bookshelf')` matched every catalogue and email screen once
all slugs shared the `umbra-bookshelf-` prefix. Now matched against its own 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) // 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. * Umbra Bookshelf - Email module admin scripts.
* *
* NOTE: Umbra_Simple_Email_Admin::enqueue_assets() has always enqueued this * INTENTIONALLY EMPTY.
* 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 * Umbra_Simple_Email_Admin::enqueue_assets() has always enqueued this file on
* placeholder so the enqueue resolves. Add email-module admin behaviour below. * 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 <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Admin Interface class * 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 <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Authors Management class * Authors Management class

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Rating Sync Engine class * 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) { public function sync_book_rating($book_id) {
global $wpdb; global $wpdb;
@@ -85,120 +95,54 @@ class Umbra_Rating_Sync {
if (!$book) { if (!$book) {
return false; return false;
} }
$amazon_rating = 0; // Without a recorded Goodreads ID there is nothing to look up, and no
$goodreads_rating = 0; // reliable way to discover one from the catalogue alone. Leave the
// stored rating untouched.
// Sync Amazon rating if (empty($book->goodreads_id)) {
if (!empty($book->amazon_asin) || !empty($book->isbn_ebook)) { return false;
$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;
}
} }
// Sync Goodreads rating $scraper = new Umbra_Goodreads_Scraper();
if (!empty($book->goodreads_id)) { $details = $scraper->get_book_details($book->goodreads_id);
$goodreads_rating = $this->fetch_goodreads_rating($book->goodreads_id);
// A transport or parse failure comes back as an 'error' key. Keep the
// Keep last rating if fetch fails // last known rating rather than overwriting it with zero.
if ($goodreads_rating === false) { if (!is_array($details) || !empty($details['error'])) {
$goodreads_rating = $book->rating_goodreads; $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 $rating = !empty($details['rating']) ? (float) $details['rating'] : 0.0;
$table = Umbra_Database::table('books');
$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( $wpdb->update(
$table, Umbra_Database::table('books'),
[ $update,
'rating_amazon' => $amazon_rating,
'rating_goodreads' => $goodreads_rating,
'last_sync' => current_time('mysql'),
],
['id' => $book_id], ['id' => $book_id],
['%f', '%f', '%s'], null,
['%d'] ['%d']
); );
return true; 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 * Manual sync trigger
*/ */

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Theme Compatibility Layer * 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 <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Email Admin Class * Email Admin Class

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Main Plugin Class * Main Plugin Class
@@ -54,9 +54,10 @@ class Umbra_Bookshelf_Helper {
// Schedule cron jobs on init // Schedule cron jobs on init
add_action('init', array($this, 'schedule_cron_events')); add_action('init', array($this, 'schedule_cron_events'));
// Cron callbacks // Rating refresh is owned by Umbra_Rating_Sync (bd_bookshelf_sync_ratings),
add_action('bd_bookshelf_goodreads_refresh', array($this, 'refresh_goodreads_data')); // which drives Umbra_Goodreads_Scraper. The former
add_action('bd_bookshelf_amazon_scrape', array($this, 'scrape_amazon_ratings')); // 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 * 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() { public function schedule_cron_events() {
if (!wp_next_scheduled('bd_bookshelf_goodreads_refresh')) { // Intentionally empty: see the note above.
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
} }
/** /**

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Admin Class * 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 '<p class="description">' . esc_html__('Default: 30469651 (B_D_Murphy)', 'umbra-bookshelf') . '</p></td></tr>';
echo '</table>'; echo '</table>';
echo '<h2>' . esc_html__('Amazon Ratings Warning', 'umbra-bookshelf') . '</h2>'; echo '<p class="description">' . esc_html__('Ratings are refreshed every 12 hours from Goodreads. Amazon ratings are not scraped.', 'umbra-bookshelf') . '</p>';
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>';
submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings'); submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings');
echo '</form></div>'; echo '</form></div>';

View File

@@ -1,6 +1,6 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: GPL-2.0-or-later
/** /**
* Installation and Database Setup * Installation and Database Setup
@@ -34,26 +34,23 @@ class Umbra_Bookshelf_Helper_Install {
/** /**
* Create database tables * 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() { public static function create_tables() {
global $wpdb; global $wpdb;
$charset_collate = $wpdb->get_charset_collate(); $charset_collate = $wpdb->get_charset_collate();
$tables = array( $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 ( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_book_meta (
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
book_id BIGINT(20) NOT NULL, book_id BIGINT(20) NOT NULL,
@@ -65,17 +62,6 @@ class Umbra_Bookshelf_Helper_Install {
UNIQUE KEY book_language (book_id, language_code) UNIQUE KEY book_language (book_id, language_code)
) $charset_collate;", ) $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 ( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_pen_names (
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
pen_name VARCHAR(255) NOT NULL, pen_name VARCHAR(255) NOT NULL,

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,9 @@ Tags: books, series, isbn, authors, newsletter, publishing
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.6 Tested up to: 6.6
Requires PHP: 7.4 Requires PHP: 7.4
Stable tag: 1.0.0 Stable tag: 1.1.0
License: Apache-2.0 License: GPLv2 or later
License URI: https://www.apache.org/licenses/LICENSE-2.0 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. 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 = = Upgrading from the old plugins =
Umbra Bookshelf reads and writes the same database tables, option keys, post 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 meta, cron hooks and shortcodes as the three plugins it replaces. Install Umbra
is picked up as-is; no migration is required. 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 If you ran BD BookShelfHelper, the first page load after upgrading reconciles
BD SimpleEmail. **Do not delete** them from the Plugins page first — deleting your database automatically. The old table layout is kept aside as
runs their uninstall routines, which drop your tables and options. `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 The shortcodes `[bd-book]`, `[bd-books]` and `[bdm_signup]` are unchanged, so
existing posts keep working. The admin menu locations have changed: see the 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? = = Do I need to migrate my books? =
No. Table names, option keys, post meta keys and shortcodes are deliberately Usually no. Table names, option keys, post meta keys and shortcodes are
unchanged. Deactivate the old plugins rather than deleting them. 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. = = 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 are all still present as submenus, and Settings is split into catalogue
settings and library settings. 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? = = Are there known problems? =
Yes — see `docs/KNOWN-ISSUES.md`. The most significant is that the catalogue and Yes — see `docs/KNOWN-ISSUES.md`, which is included with the plugin.
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.
== Changelog == == 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 = = 1.0.0 =
* Initial release of the consolidated plugin. * Initial release of the consolidated plugin.
* Merged BD Bookshelf, BD BookShelfHelper and BD SimpleEmail into one plugin * Merged BD Bookshelf, BD BookShelfHelper and BD SimpleEmail into one plugin

View File

@@ -1,9 +1,9 @@
# Umbra Bookshelf — Plugin Specification # Umbra Bookshelf — Plugin Specification
**Status:** Active **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` **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` | | Domain path | `/languages` |
| Author | Umbra Tools | | Author | Umbra Tools |
| Requires | WordPress 6.0, PHP 7.4 | | 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: 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` | | Post meta | `_bd_bookshelf_book_id` |
| Transient prefix | `_transient_bd_bookshelf_*` | | 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` | | 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` | | 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]` | | Shortcodes | `[bd-book]`, `[bd-books]`, `[bdm_signup]` |
| Content types | post type `book`, taxonomy `series` | | Content types | post type `book`, taxonomy `series` |
@@ -84,8 +84,9 @@ umbra-bookshelf/
├── umbra-bookshelf.php bootstrap: constants, requires, singleton, single top-level menu ├── umbra-bookshelf.php bootstrap: constants, requires, singleton, single top-level menu
├── uninstall.php drops every table and option from all three modules ├── uninstall.php drops every table and option from all three modules
├── includes/ ├── includes/
│ ├── class-umbra-schema-migration.php reconciles pre-consolidation schemas
│ ├── books/ catalogue: books, authors, series, retail, ratings, │ ├── 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, │ ├── library/ book post type + taxonomy, pen names, data entry,
│ │ shortlinks, reviews, ratings, migrate │ │ shortlinks, reviews, ratings, migrate
│ └── email/ subscribers, newsletters, AI-assisted composition, sending │ └── 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`, Classes: `Umbra_Database`, `Umbra_Book_Manager`, `Umbra_Authors`, `Umbra_Series`,
`Umbra_Retail_Sites`, `Umbra_Rating_Sync`, `Umbra_Shortcodes`, `Umbra_SEO`, `Umbra_Retail_Sites`, `Umbra_Rating_Sync`, `Umbra_Shortcodes`, `Umbra_SEO`,
`Umbra_GDPR`, `Umbra_Goodreads_Scraper`, `Umbra_Amazon_Scraper`, `Umbra_Admin`, `Umbra_GDPR`, `Umbra_Goodreads_Scraper`, `Umbra_Admin`, `Umbra_Migration`,
`Umbra_Migration`, `Umbra_Theme_Compatibility`. `Umbra_Theme_Compatibility`.
> `Umbra_SEO` is never instantiated by the bootstrap (carried over unchanged from **This module owns `{prefix}bd_books` and `{prefix}bd_series`.** Its schema is
> the original plugin). See `docs/KNOWN-ISSUES.md` item 5. 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/` ### 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`, Classes: `Umbra_Bookshelf_Helper`, `Umbra_Bookshelf_Helper_Admin`,
`Umbra_Bookshelf_Helper_Install`. `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/` ### 4.3 Email — `includes/email/`
Owns newsletter delivery: subscriber and newsletter tables, `[bdm_signup]`, 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 ## 6. Lifecycle
* **Activate:** create catalogue tables (`Umbra_Database::create_tables()`), * **Activate:** reconcile the schema (`Umbra_Schema_Migration::maybe_migrate()`),
library tables + roles (`Umbra_Bookshelf_Helper_Install::activate()`), email then create catalogue tables (`Umbra_Database::create_tables()`), library tables
tables (`Umbra_Simple_Email_Install::activate()`), flush rewrites, record + roles (`Umbra_Bookshelf_Helper_Install::activate()`), email tables
(`Umbra_Simple_Email_Install::activate()`), flush rewrites, record
`bd_bookshelf_activation_time`. `bd_bookshelf_activation_time`.
* **Deactivate:** clear all three cron hooks, run module deactivators, flush * **Update:** plugin updates do not re-run activation, so `admin_init` re-checks
rewrites. 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 * **Uninstall:** drop every table across all three modules (deduplicated so
`bd_books`/`bd_series` are dropped once), delete `bd_bookshelf_%` options and `bd_books`/`bd_series` are dropped once), delete `bd_bookshelf_%` options and
transients, delete the `bdm_*` options, clear cron. Guarded by 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 ## 7. Verification
```bash ```bash
python3 umbra-bookshelf/tests/verify_structure.py # static checks umbra-bookshelf/tests/run_all.sh # every suite
php umbra-bookshelf/tests/smoke_runtime.php front # front-end boot ```
php umbra-bookshelf/tests/smoke_runtime.php admin # admin boot + menu shape
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 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 `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 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 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 The three problems that blocked the consolidation — the dual `bd_books` /
books and library modules. This predates the consolidation and is **not** fixed `bd_series` schemas, duplicated scraping, and the Apache/GPL licence mismatch —
here; see `docs/KNOWN-ISSUES.md`. 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', foreach (array('Umbra_Database', 'Umbra_Book_Manager', 'Umbra_Series',
'Umbra_Authors', 'Umbra_Retail_Sites', 'Umbra_Rating_Sync', 'Umbra_Authors', 'Umbra_Retail_Sites', 'Umbra_Rating_Sync',
'Umbra_Shortcodes', 'Umbra_SEO', 'Umbra_GDPR', '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_Admin', 'Umbra_Migration', 'Umbra_Theme_Compatibility',
'Umbra_Bookshelf_Helper', 'Umbra_Bookshelf_Helper_Admin', 'Umbra_Bookshelf_Helper', 'Umbra_Bookshelf_Helper_Admin',
'Umbra_Bookshelf_Helper_Install', 'Umbra_Simple_Email', '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)); !is_a('Umbra_Bookshelf_Helper', 'Umbra_Theme_Compatibility', true));
section('5. Constants'); section('5. Constants');
check('UMBRA_BOOKSHELF_VERSION', defined('UMBRA_BOOKSHELF_VERSION') // Compare against the plugin header rather than a hard-coded literal, so a
&& UMBRA_BOOKSHELF_VERSION === '1.0.0', UMBRA_BOOKSHELF_VERSION ?? 'undefined'); // 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_DIR', defined('UMBRA_BOOKSHELF_DIR'));
check('UMBRA_BOOKSHELF_URL', defined('UMBRA_BOOKSHELF_URL')); check('UMBRA_BOOKSHELF_URL', defined('UMBRA_BOOKSHELF_URL'));
check('UMBRA_BOOKSHELF_BASENAME', defined('UMBRA_BOOKSHELF_BASENAME')); 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)) print('1. php -l: %d files checked' % len(php))
# ------------------------------------------------- 2. duplicate class decls # ------------------------------------------------- 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 = {} decls = {}
for f in php: for f in source_php:
src = io.open(f, encoding='utf-8', errors='replace').read() 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_]*)', for m in re.finditer(r'^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)',
src, re.M): src, re.M):
@@ -55,7 +57,7 @@ print('2. class declarations: %d unique, %d duplicated'
# --------------------------------------------- 3. referenced classes exist # --------------------------------------------- 3. referenced classes exist
refs = set() refs = set()
for f in php: for f in source_php:
src = io.open(f, encoding='utf-8', errors='replace').read() 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): for m in re.finditer(r'\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(', src):
refs.add(m.group(1)) refs.add(m.group(1))
@@ -70,7 +72,9 @@ wp_provided = {'BD_BookShelfHelper', # created via class_alias()
known_external = {'GeminiClient', known_external = {'GeminiClient',
# browser JS globals, matched by the `new X(` heuristic # browser JS globals, matched by the `new X(` heuristic
# when scanning inline <script> blocks inside PHP files # 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 missing = sorted(r for r in refs
if r not in decls if r not in decls
and r not in wp_provided and r not in wp_provided

View File

@@ -1,18 +1,18 @@
<?php <?php
// Copyright 2026 Umbra Tools (umbratools.com) // 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 Name: Umbra Bookshelf
* Plugin URI: https://umbratools.com/plugins/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. * 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 at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: Umbra Tools * Author: Umbra Tools
* Author URI: https://umbratools.com * Author URI: https://umbratools.com
* License: Apache-2.0 * License: GPLv2 or later
* License URI: https://www.apache.org/licenses/LICENSE-2.0 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: umbra-bookshelf * Text Domain: umbra-bookshelf
* Domain Path: /languages * Domain Path: /languages
* *
@@ -34,7 +34,7 @@ if (!defined('ABSPATH')) {
/** /**
* Plugin constants * Plugin constants
*/ */
define('UMBRA_BOOKSHELF_VERSION', '1.0.0'); define('UMBRA_BOOKSHELF_VERSION', '1.1.0');
define('UMBRA_BOOKSHELF_FILE', __FILE__); define('UMBRA_BOOKSHELF_FILE', __FILE__);
define('UMBRA_BOOKSHELF_DIR', plugin_dir_path(__FILE__)); define('UMBRA_BOOKSHELF_DIR', plugin_dir_path(__FILE__));
define('UMBRA_BOOKSHELF_URL', plugin_dir_url(__FILE__)); define('UMBRA_BOOKSHELF_URL', plugin_dir_url(__FILE__));
@@ -54,6 +54,9 @@ define('UMBRA_BOOKSHELF_EMAIL_VERSION', '2.0.0');
* Load module files * 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) // 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-database.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-book-manager.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-seo.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-gdpr.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-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-admin.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-migration.php'; require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-migration.php';
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-theme-compatibility.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 // means those init callbacks are added while 'init' is already being
// dispatched, so whether they run becomes an ordering accident. // dispatched, so whether they run becomes an ordering accident.
add_action('plugins_loaded', [$this, 'init_modules']); 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_GDPR();
new Umbra_Rating_Sync(); 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) // Library module (post type, taxonomy, shortcodes, pen names)
Umbra_Bookshelf_Helper::instance(); Umbra_Bookshelf_Helper::instance();
@@ -271,12 +296,15 @@ final class Umbra_Bookshelf {
/** /**
* Plugin activation * Plugin activation
* *
* Runs each module's installer. See docs/KNOWN-ISSUES.md item 1: the * Reconciles a pre-consolidation schema first, then creates tables. See
* catalogue and library modules both create bd_books / bd_series with * Umbra_Schema_Migration for why the ordering matters.
* different schemas, which is a pre-existing conflict carried over from
* the separate plugins and deliberately not resolved in this release.
*/ */
public function activate() { 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 = new Umbra_Database();
$database->create_tables(); $database->create_tables();

View File

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