diff --git a/LICENSE b/LICENSE index a0eaa8e..9efa6fb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,202 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + Preamble - 1. Definitions. + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). + The precise terms and conditions for copying, distribution and +modification follow. - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) - 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 - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. - 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"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. - 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 - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + 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. + + + Copyright (C) + + 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 . + +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. + + , 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. diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 726016d..0000000 --- a/NOTICE +++ /dev/null @@ -1,2 +0,0 @@ -Umbra Tools (https://umbratools.com) -Copyright 2026 Umbra Tools (umbratools.com) diff --git a/README.md b/README.md index efb2e0a..369b88d 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,16 @@ which drop your tables and options. ## Status -`v1.0.0` is the first consolidated release. The most important outstanding -problem is documented in -[`docs/KNOWN-ISSUES.md`](umbra-bookshelf/docs/KNOWN-ISSUES.md) §1: the books and -library modules still create `bd_books` and `bd_series` with two incompatible -schemas. This predates the consolidation — merging the plugins made it -unconditional, and resolving it requires a data migration. +`v1.1.0` resolves the three problems that blocked the consolidation: + +* **`bd_books` and `bd_series` schema conflict** — the catalogue schema is now + canonical, with a migration that preserves data from installations built on the + old library schema. Legacy tables are renamed aside, never dropped. +* **Duplicated scraping** — Goodreads only, one implementation, driven by a + single 12-hour cron. +* **Apache/GPL licence mismatch** — GPL-2.0-or-later throughout, which is what + WordPress.org requires. ## Licence -Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE). +GPLv2 or later (GPL-2.0-or-later) — WordPress.org compatible. See [`LICENSE`](LICENSE). diff --git a/goodreads-widget.php b/goodreads-widget.php index 24b6975..3047a94 100644 --- a/goodreads-widget.php +++ b/goodreads-widget.php @@ -1,6 +1,6 @@ { // Mobile navigation toggle diff --git a/mockups/functions.php b/mockups/functions.php index 9d4cebb..c3ec854 100755 --- a/mockups/functions.php +++ b/mockups/functions.php @@ -1,6 +1,6 @@ class_alias BD_BookShelfHelper + + includes/class-umbra-schema-migration.php reconciles pre-consolidation schemas ``` ## Design rules @@ -62,9 +63,19 @@ callbacks are registered before `init` fires. static API the unmodified theme calls, and re-exports the legacy `BD_BookShelfHelper` name via `class_alias()`. -## Not resolved here +**One owner per table.** `bd_books` and `bd_series` belong to the catalogue. The +library module deliberately no longer creates them. Where an installation was +built on the old library schema, `Umbra_Schema_Migration` renames the old table +aside, creates the canonical one, and copies rows across preserving IDs. -The catalogue and library modules keep two independent data models that happen -to share table names `bd_books` and `bd_series`. Consolidation made them -unconditionally conflict; it did not make them agree. Resolving this is the next -piece of work — see `KNOWN-ISSUES.md` §1 for the options. +**Schema work happens before `dbDelta`, never after.** `dbDelta` adds columns but +never drops them, so pointing it at a table of the wrong shape produces a table +that satisfies neither schema. Reconciliation therefore runs first, and the +migration is what guarantees the invariant. + +## Still to reconcile + +Making the catalogue schema canonical removed the *table* conflict. The two +modules still model a book differently — the catalogue as a row in `bd_books`, +the library as a `book` custom post type with per-post meta — and nothing keeps +them in step. See `TASKS.md` §5. diff --git a/umbra-bookshelf/docs/KNOWN-ISSUES.md b/umbra-bookshelf/docs/KNOWN-ISSUES.md index 296ad34..ee16ad3 100644 --- a/umbra-bookshelf/docs/KNOWN-ISSUES.md +++ b/umbra-bookshelf/docs/KNOWN-ISSUES.md @@ -1,131 +1,172 @@ # Known issues -Carried into the Umbra Bookshelf consolidation. Items 1–4 are **pre-existing** -defects inherited from the three merged plugins; item 5 and the two repaired -below are noted for traceability. +Defects and carried-over problems. For intended work, see `TASKS.md`. + +Status at 1.1.0: the three issues that blocked the consolidation are resolved. +What remains is listed first. --- -## 1. `bd_books` and `bd_series` have two incompatible schemas (DEFERRED — unresolved) +## Open -The books and library modules both create tables with identical names and -different columns. +### 1. Rating sync blocks a worker with `sleep()` -`Umbra_Database::table()` returns `$wpdb->prefix . 'bd_' . $name`, and the -catalogue issues `CREATE TABLE {prefix}bd_books` with **no** `IF NOT EXISTS` -guard: +`includes/books/class-umbra-rating-sync.php` + +`sync_all_ratings()` iterates up to 999 books and calls `sleep(2)` between each, +inside a cron run — roughly 33 minutes of a blocked PHP worker per tick. + +Pre-existing. Deliberately left alone when Amazon scraping was removed, because +that change was scoped to dropping a source. Fix suggestion is in `TASKS.md` §3. + +### 2. The two modules still model books differently + +Making the catalogue schema canonical resolved the *table* conflict, but the +catalogue (row in `bd_books`) and the library (`book` custom post type with +per-post meta) are still independent models with no synchronisation between +them. Editing a book in one place does not update the other. + +See `TASKS.md` §5 for the decision to make. + +### 3. Set-aside legacy tables are never cleaned up + +On a reconciled installation, `bd_books_legacy_library` and +`bd_series_legacy_library` remain indefinitely. Inert, but untidy. See +`TASKS.md` §4. + +--- + +## Resolved in 1.1.0 + +### Catalogue schema is now canonical for `bd_books` and `bd_series` + +**Was:** the catalogue and library modules both created `bd_books` and +`bd_series`, with different columns. Whichever activated first won; the other +either no-opped or errored, then read and wrote against the wrong shape. The +divergence was: ``` -catalogue wp_bd_books id, title, slug, description, isbn_*, amazon_asin, - goodreads_id, rating_*, created_at, ... +catalogue wp_bd_books id, title, slug, description, cover_image, isbn_*, + asin_*, amazon_asin, goodreads_id, rating_*, series_id library wp_bd_books id, post_id, pen_name_id, series_id, volume_number, - asin, isbn, publish_date, sort_order, created_at + asin, isbn, publish_date, sort_order ``` -Same for `wp_bd_series` (catalogue: `name`, `slug`, `description` …; -library: `name`, `slug`, `cover_attachment_id`, `header_attachment_id`, `blurb`, …). +**Now:** `includes/class-umbra-schema-migration.php` runs before `dbDelta` on +activation and on update. If it finds a library-shaped table it renames it +aside (**never drops it**), creates the canonical table, and copies rows across: -**Consequence.** Whichever module is activated first wins; the second either -no-ops (`IF NOT EXISTS`, library) or raises a database error (catalogue's -unguarded `CREATE TABLE`). Whichever schema exists first is what the other -module then reads and writes against, producing wrong results rather than an -obvious failure. +* book **IDs are preserved**, which keeps `bd_book_meta`, `bd_reviews`, + `bd_ratings` and `bd_review_shortlinks` valid +* `title`, `slug` and `description` are resolved from the linked post +* the library's single `isbn`/`asin` land on the ebook columns; `amazon_asin` is + also populated so existing product links keep working +* `series_id` is preserved; the series row is migrated first so it still resolves +* post linkage is written back as `_bd_bookshelf_book_id` (preserved meta key) +* `publish_date` has no catalogue column, so it is kept as + `_bd_bookshelf_publish_date` post meta +* goodreads/amazon values in `bd_ratings` are folded into the catalogue's + `rating_goodreads` / `rating_amazon` columns -**Why deferred.** Resolving it means choosing a canonical schema and migrating -live data — a data-migration project, not a rename. Both datasets may hold rows -that matter. +Rows whose post no longer exists are skipped with a log entry rather than +creating an unusable book. Duplicate slugs are de-duplicated, because the +catalogue's `slug` column is `UNIQUE`. -**Options.** +Verified by `tests/schema_migration_test.php` — 42 checks against a real +database, covering shape detection, row mapping, ID preservation, orphan +handling, rating folding, idempotency and slug collisions. -1. Library schema becomes canonical; catalogue tables move to `bd_bookshelf_*` - with an upgrade routine copying existing rows. Requires renaming DB keys, - which this consolidation deliberately avoids. -2. Catalogue schema becomes canonical; library relies on the `book` post type and - postmeta instead of `bd_books`. -3. Delete the catalogue duplicates and keep only the library model. +### Only Goodreads scraping remains ---- +**Was:** three Goodreads implementations and two Amazon ones: -## 2. Goodreads and Amazon scraping is implemented twice +| Implementation | State before | +|---|---| +| `Umbra_Goodreads_Scraper` | complete, 325 lines, **never called** | +| `Umbra_Rating_Sync::fetch_goodreads_rating()` | working duplicate | +| library `refresh_goodreads_data()` | empty stub, but scheduled daily | +| `Umbra_Amazon_Scraper` | complete, 296 lines, **never called** | +| `Umbra_Rating_Sync::fetch_amazon_rating()` | working duplicate | -* Catalogue: `Umbra_Goodreads_Scraper`, `Umbra_Amazon_Scraper`. -* Library: `Umbra_Bookshelf_Helper::refresh_goodreads_data()`, - `::scrape_amazon_ratings()`. +Both library "scrapers" were empty stubs attached to cron events, so +`bd_bookshelf_goodreads_refresh` (daily) and `bd_bookshelf_amazon_scrape` +(six-hourly) fired on schedule to do nothing. -Both attach to the **same** cron hooks (`bd_bookshelf_goodreads_refresh`, -`bd_bookshelf_amazon_scrape`), so each scheduled run performs duplicate -requests to third-party APIs — wasted quota, faster rate limiting, and two -places to fix a parsing bug. Needs a decision on which implementation survives. +**Now:** Goodreads only, and one implementation of it. +`Umbra_Amazon_Scraper` is deleted; the duplicate HTML parsing is gone from +`Umbra_Rating_Sync`, which now delegates to `Umbra_Goodreads_Scraper`. The two +library stubs and their cron registrations are removed. The surviving schedule +is the single `bd_bookshelf_sync_ratings` event every 12 hours. ---- +The `rating_amazon` **column** is retained — it holds existing data and is +populated from `bd_ratings` during migration. Amazon retail *links* are +unaffected; only rating scraping was removed. The two former hooks are still +cleared on deactivation and uninstall so no stale schedules survive an upgrade. -## 3. `assets/images/default-cover.png` was missing +### Licence is GPLv2 or later -`Umbra_Bookshelf_Helper` returns `assets/images/default-cover.png` as the +**Was:** plugin headers said `GPLv2 or later` while `LICENSE`, `NOTICE` and 101 +SPDX headers said Apache-2.0. The theme was worse: `style.css` declared GPLv2 +while its PHP files carried Apache headers. + +**Now:** GPL-2.0-or-later throughout, which is what WordPress.org requires and +what the theme already claimed. `LICENSE` is the full GPLv2 text; `NOTICE` is +removed (it was Apache-specific); every SPDX header reads +`GPL-2.0-or-later`. + +`Old/` and `archive/pre-umbra-releases/` keep their original Apache headers on +purpose — they are frozen pre-consolidation snapshots, not shipped code. + +### Default cover was missing + +`Umbra_Bookshelf_Helper` returned `assets/images/default-cover.png` as the fallback cover, but the repository contained no images at all, so every -cover-less book rendered a broken image. +cover-less book rendered a broken image. A real cover +(`WorkingTitleCover_512x815.png`, 512×815) is now committed at that path. +Replace with final artwork before release if this was only a working title. -**Repaired during consolidation:** a neutral 600×900 placeholder PNG was -generated at that path. Replace it with real artwork before release. +### Email admin script did not exist + +`Umbra_Simple_Email_Admin::enqueue_assets()` enqueued `email/assets/js/admin.js` +on every email admin page, but the file was never committed — a guaranteed 404. +An empty placeholder is now committed with a comment explaining its history. +Defining its behaviour is `TASKS.md` §1. + +### `Umbra_SEO` was loaded but never instantiated + +It registers its hooks from a constructor, and the bootstrap did not construct +it, so the class was dead code. It is now instantiated and its meta/Open +Graph/Twitter/sitemap hooks attach. Completing SEO for generated book detail +pages is `TASKS.md` §2. --- -## 4. `email/assets/js/admin.js` did not exist - -`Umbra_Simple_Email_Admin::enqueue_assets()` enqueued it on every email admin -page, but no such file was ever committed — a guaranteed 404. - -**Repaired during consolidation:** an empty placeholder was created with a -comment explaining the history. Add the intended behaviour, or drop the enqueue. - ---- - -## 5. `Umbra_SEO` is never instantiated - -`Umbra_SEO` registers its hooks from a constructor, but the bootstrap does not -construct it — carried over unchanged from the original BD Bookshelf bootstrap. -The class is loaded and its hooks never attach, so the module is inert. - -Left as-is because enabling it changes behaviour. Either instantiate it in -`Umbra_Bookshelf::init_modules()` or delete it. - ---- - -## 6. Licence metadata is inconsistent (NOT CHANGED) - -Plugin headers declared `License: GPLv2 or later` while the repository's -`LICENSE`/`NOTICE` files (added in `85de97b`) and every SPDX header state -**Apache-2.0**. The consolidated header now says Apache-2.0 to match the SPDX -headers and `LICENSE`, but this needs a deliberate decision — WordPress -plugin-directory submissions require a GPL-compatible licence. - ---- - -## Fixed during consolidation +## Fixed in 1.0.0 (the consolidation) * **`Cannot declare class` fatal.** `class-bdm-theme-compatibility.php` declared `BD_BookShelfHelper`, the same class name as the library plugin's own helper. - Latent while the two were separate plugins (it only fired when both were - active), but an unconditional fatal once merged. The shim is now - `Umbra_Theme_Compatibility`, with a `class_alias()` preserving the legacy name - for the unmodified theme. -* **Duplicate `bd_bookshelf_import_csv` AJAX handler.** Both `Umbra_Admin` and - `Umbra_Migration` registered it; the second silently replaced the first. The - import page now lives solely in `Umbra_Admin` (it is the variant wired to + Latent while the two were separate plugins — it only fired when both were + active — but an unconditional fatal once merged. The shim is now + `Umbra_Theme_Compatibility`, with `class_alias()` preserving the legacy name + so the unmodified theme keeps working. +* **Duplicate `bd_bookshelf_import_csv` AJAX handler.** `Umbra_Admin` and + `Umbra_Migration` both registered it; the second silently replaced the first. + The import page now lives solely in `Umbra_Admin` (the variant wired to `admin/js/umbra-bookshelf-admin.js`); `Umbra_Migration` keeps only the preview/run endpoints it uniquely provides. * **Duplicate `bd-bookshelf-settings` submenu.** Catalogue and library both claimed the slug, leaving one Settings screen unreachable. Now `umbra-bookshelf-settings` and `umbra-bookshelf-library-settings`. * **Duplicate admin menu.** All three modules called `add_menu_page`. Now one - top-level menu registered by the bootstrap at priority 9. + top-level menu, registered by the bootstrap at `admin_menu` priority 9. * **Colliding asset handles.** Catalogue and library both enqueued `bd-bookshelf-admin`; the second enqueue was silently discarded. Handles are now unique per module. * **Modules could miss their own `init` callbacks.** Constructing modules from - inside an `init` callback means their `init` registrations race the hook - currently being dispatched. Modules are now constructed on `plugins_loaded`, - behind a re-entrancy guard. + inside an `init` callback means their `init` registrations race the hook being + dispatched, so the `book` post type and `[bd-book]` shortcode could fail to + register. Modules now construct on `plugins_loaded`, behind a re-entrancy + guard. * **Over-broad asset hook check.** The library module's `strpos($hook, 'bd-bookshelf')` matched every catalogue and email screen once all slugs shared the `umbra-bookshelf-` prefix. Now matched against its own diff --git a/umbra-bookshelf/docs/TASKS.md b/umbra-bookshelf/docs/TASKS.md new file mode 100644 index 0000000..7e9fbeb --- /dev/null +++ b/umbra-bookshelf/docs/TASKS.md @@ -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: + +* `` 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. diff --git a/umbra-bookshelf/email/assets/js/admin.js b/umbra-bookshelf/email/assets/js/admin.js index 1a534f0..c4a3da8 100644 --- a/umbra-bookshelf/email/assets/js/admin.js +++ b/umbra-bookshelf/email/assets/js/admin.js @@ -1,11 +1,22 @@ // Copyright 2026 Umbra Tools (umbratools.com) -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: GPL-2.0-or-later /** * Umbra Bookshelf - Email module admin scripts. * - * NOTE: Umbra_Simple_Email_Admin::enqueue_assets() has always enqueued this - * file, but the file was never present in the source repository, so every - * email admin page issued a 404 for it. It is created here as an empty - * placeholder so the enqueue resolves. Add email-module admin behaviour below. + * INTENTIONALLY EMPTY. + * + * Umbra_Simple_Email_Admin::enqueue_assets() has always enqueued this file on + * every email admin screen, but the file was never committed to the source + * repository, so each of those pages issued a 404 for it. An empty file is + * committed here so the enqueue resolves and the console stays clean. + * + * TASK: define the email module's admin behaviour. Candidates, in rough order + * of value: + * - subscriber list: search, sort and bulk delete + * - composer: live preview and a send test to the current user + * - settings: validate the Gemini API key and Gmail credentials on save + * rather than only at send time + * + * See docs/TASKS.md. */ diff --git a/umbra-bookshelf/includes/books/class-umbra-admin.php b/umbra-bookshelf/includes/books/class-umbra-admin.php index 7ddc220..16b5fa4 100644 --- a/umbra-bookshelf/includes/books/class-umbra-admin.php +++ b/umbra-bookshelf/includes/books/class-umbra-admin.php @@ -1,6 +1,6 @@ 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('/]*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('/]*>.*?]*>(.*?)<\/span>/si', $html, $matches)) { - $book['title'] = trim(strip_tags($matches[1])); - } elseif (preg_match('/]*>.*?]*class="[^"]*a-size[^"]*"[^>]*>(.*?)<\/span>/si', $html, $matches)) { - $book['title'] = trim(strip_tags($matches[1])); - } - - // Extract author - if (preg_match('/]*class="[^"]*a-size[^"]*"[^>]*>\s*by\s+]*>(.*?)<\/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('/]*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('/]*id="title"[^>]*>(.*?)<\/h1>/si', $html, $matches)) { - $book['title'] = trim(strip_tags($matches[1])); - } - - // Extract author - if (preg_match('/by\s+]*>(.*?)<\/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('/]*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('/]*id="bookDescription_feature_div"[^>]*>.*?]*>(.*?)<\/span>/si', $html, $matches)) { - $book['description'] = trim(strip_tags($matches[1])); - } - - // Look for other format links (Kindle, Paperback, Hardcover) - if (preg_match_all('/]*>.*?]*href="\/dp\/([A-Z0-9]{10})"[^>]*>.*?]*>(.*?)<\/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; - } -} diff --git a/umbra-bookshelf/includes/books/class-umbra-authors.php b/umbra-bookshelf/includes/books/class-umbra-authors.php index 788349c..260d302 100644 --- a/umbra-bookshelf/includes/books/class-umbra-authors.php +++ b/umbra-bookshelf/includes/books/class-umbra-authors.php @@ -1,6 +1,6 @@ amazon_asin) || !empty($book->isbn_ebook)) { - $isbn = $book->amazon_asin ?: $book->isbn_ebook; - $amazon_rating = $this->fetch_amazon_rating($isbn); - - // Keep last rating if fetch fails - if ($amazon_rating === false) { - $amazon_rating = $book->rating_amazon; - } + + // Without a recorded Goodreads ID there is nothing to look up, and no + // reliable way to discover one from the catalogue alone. Leave the + // stored rating untouched. + if (empty($book->goodreads_id)) { + return false; } - - // Sync Goodreads rating - if (!empty($book->goodreads_id)) { - $goodreads_rating = $this->fetch_goodreads_rating($book->goodreads_id); - - // Keep last rating if fetch fails - if ($goodreads_rating === false) { - $goodreads_rating = $book->rating_goodreads; - } + + $scraper = new Umbra_Goodreads_Scraper(); + $details = $scraper->get_book_details($book->goodreads_id); + + // A transport or parse failure comes back as an 'error' key. Keep the + // last known rating rather than overwriting it with zero. + if (!is_array($details) || !empty($details['error'])) { + $message = (is_array($details) && !empty($details['error'])) + ? $details['error'] + : 'unexpected response shape'; + error_log('Umbra Bookshelf: Goodreads fetch error - ' . $message); + return false; } - - // Update book ratings - $table = Umbra_Database::table('books'); + + $rating = !empty($details['rating']) ? (float) $details['rating'] : 0.0; + + $update = [ + 'rating_goodreads' => $rating, + 'last_sync' => current_time('mysql'), + ]; + + // Goodreads also reports a cover and an ISBN-13. Adopt them only where + // the catalogue has nothing, so local edits are never clobbered. + if (!empty($details['cover_image']) && empty($book->cover_image)) { + $update['cover_image'] = esc_url_raw($details['cover_image']); + } + if (!empty($details['isbn13']) && empty($book->isbn_ebook)) { + $update['isbn_ebook'] = $details['isbn13']; + } + $wpdb->update( - $table, - [ - 'rating_amazon' => $amazon_rating, - 'rating_goodreads' => $goodreads_rating, - 'last_sync' => current_time('mysql'), - ], + Umbra_Database::table('books'), + $update, ['id' => $book_id], - ['%f', '%f', '%s'], + null, ['%d'] ); - + return true; } - /** - * Fetch Amazon rating via web scraping - */ - private function fetch_amazon_rating($isbn) { - $url = "https://www.amazon.com/dp/{$isbn}"; - - $response = wp_remote_get($url, [ - 'timeout' => 15, - 'headers' => [ - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language' => 'en-US,en;q=0.5', - ], - ]); - - if (is_wp_error($response)) { - error_log('BD Bookshelf: Amazon fetch error - ' . $response->get_error_message()); - return false; - } - - $body = wp_remote_retrieve_body($response); - - // Parse rating from HTML - // Amazon uses data-hook="review-star-rating" or similar - if (preg_match('/data-hook="review-star-rating"[^>]*>.*?(\d+\.?\d*)\s*out of\s*5/si', $body, $matches)) { - return (float) $matches[1]; - } - - // Alternative pattern - if (preg_match('/a-icon-alt[^>]*>(\d+\.?\d*)\s*out of\s*\d+ stars/si', $body, $matches)) { - return (float) $matches[1]; - } - - return false; - } - - /** - * Fetch Goodreads rating via web scraping - */ - private function fetch_goodreads_rating($goodreads_id) { - $url = "https://www.goodreads.com/book/show/{$goodreads_id}"; - - $response = wp_remote_get($url, [ - 'timeout' => 15, - 'headers' => [ - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language' => 'en-US,en;q=0.5', - ], - ]); - - if (is_wp_error($response)) { - error_log('BD Bookshelf: Goodreads fetch error - ' . $response->get_error_message()); - return false; - } - - $body = wp_remote_retrieve_body($response); - - // Parse rating from HTML - // Goodreads uses ratingValue in meta or specific class - if (preg_match('/ratingValue[^>]*>(\d+\.?\d*)]*>(\d+\.?\d*) 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', + ); + } +} diff --git a/umbra-bookshelf/includes/email/class-umbra-email-admin.php b/umbra-bookshelf/includes/email/class-umbra-email-admin.php index f08c752..7b209ca 100644 --- a/umbra-bookshelf/includes/email/class-umbra-email-admin.php +++ b/umbra-bookshelf/includes/email/class-umbra-email-admin.php @@ -1,6 +1,6 @@ ' . esc_html__('Default: 30469651 (B_D_Murphy)', 'umbra-bookshelf') . '

'; echo ''; - echo '

' . esc_html__('Amazon Ratings Warning', 'umbra-bookshelf') . '

'; - echo '

' . esc_html__('Warning: Scraping Amazon product pages may conflict with their Terms of Service. Use at your own risk.', 'umbra-bookshelf') . '

'; + echo '

' . esc_html__('Ratings are refreshed every 12 hours from Goodreads. Amazon ratings are not scraped.', 'umbra-bookshelf') . '

'; submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings'); echo ''; diff --git a/umbra-bookshelf/includes/library/class-umbra-library-install.php b/umbra-bookshelf/includes/library/class-umbra-library-install.php index a145a5f..1024ce1 100644 --- a/umbra-bookshelf/includes/library/class-umbra-library-install.php +++ b/umbra-bookshelf/includes/library/class-umbra-library-install.php @@ -1,6 +1,6 @@ get_charset_collate(); $tables = array( - "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_books ( - id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY, - post_id BIGINT(20) NOT NULL, - pen_name_id BIGINT(20), - series_id BIGINT(20), - volume_number INT(11), - asin VARCHAR(20), - isbn VARCHAR(20), - publish_date DATE, - sort_order INT(11) DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY post_id (post_id) - ) $charset_collate;", - "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_book_meta ( id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY, book_id BIGINT(20) NOT NULL, @@ -65,17 +62,6 @@ class Umbra_Bookshelf_Helper_Install { UNIQUE KEY book_language (book_id, language_code) ) $charset_collate;", - "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_series ( - id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - cover_attachment_id BIGINT(20), - header_attachment_id BIGINT(20), - blurb TEXT, - display_order INT(11) DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) $charset_collate;", - "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_pen_names ( id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY, pen_name VARCHAR(255) NOT NULL, diff --git a/umbra-bookshelf/library/assets/js/admin.js b/umbra-bookshelf/library/assets/js/admin.js index cc1c199..d7f302a 100644 --- a/umbra-bookshelf/library/assets/js/admin.js +++ b/umbra-bookshelf/library/assets/js/admin.js @@ -1,5 +1,5 @@ // Copyright 2026 Umbra Tools (umbratools.com) -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: GPL-2.0-or-later /** * BD BookShelfHelper Admin JavaScript diff --git a/umbra-bookshelf/public/js/umbra-bookshelf-gdpr.js b/umbra-bookshelf/public/js/umbra-bookshelf-gdpr.js index 6e7868a..f077f81 100644 --- a/umbra-bookshelf/public/js/umbra-bookshelf-gdpr.js +++ b/umbra-bookshelf/public/js/umbra-bookshelf-gdpr.js @@ -1,5 +1,5 @@ // Copyright 2026 Umbra Tools (umbratools.com) -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: GPL-2.0-or-later /** * BD Bookshelf - GDPR Consent diff --git a/umbra-bookshelf/public/js/umbra-bookshelf.js b/umbra-bookshelf/public/js/umbra-bookshelf.js index 86c9ddd..ffd2ecb 100644 --- a/umbra-bookshelf/public/js/umbra-bookshelf.js +++ b/umbra-bookshelf/public/js/umbra-bookshelf.js @@ -1,5 +1,5 @@ // Copyright 2026 Umbra Tools (umbratools.com) -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: GPL-2.0-or-later /** * BD Bookshelf - Public JavaScript diff --git a/umbra-bookshelf/readme.txt b/umbra-bookshelf/readme.txt index 244570c..a114eac 100644 --- a/umbra-bookshelf/readme.txt +++ b/umbra-bookshelf/readme.txt @@ -4,9 +4,9 @@ Tags: books, series, isbn, authors, newsletter, publishing Requires at least: 6.0 Tested up to: 6.6 Requires PHP: 7.4 -Stable tag: 1.0.0 -License: Apache-2.0 -License URI: https://www.apache.org/licenses/LICENSE-2.0 +Stable tag: 1.1.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html Complete book management for authors and publishers: catalogue and ISBN tracking, series, pen names, retail links, rating sync, GDPR consent, and newsletter delivery. @@ -31,12 +31,15 @@ Everything now sits under a single **Umbra Bookshelf** admin menu. = Upgrading from the old plugins = Umbra Bookshelf reads and writes the same database tables, option keys, post -meta, cron hooks and shortcodes as the three plugins it replaces. Existing data -is picked up as-is; no migration is required. +meta, cron hooks and shortcodes as the three plugins it replaces. Install Umbra +Bookshelf, then deactivate BD Bookshelf, BD BookShelfHelper and BD SimpleEmail. +**Do not delete** them from the Plugins page first — deleting runs their +uninstall routines, which drop your tables and options. -Install Umbra Bookshelf, then deactivate BD Bookshelf, BD BookShelfHelper and -BD SimpleEmail. **Do not delete** them from the Plugins page first — deleting -runs their uninstall routines, which drop your tables and options. +If you ran BD BookShelfHelper, the first page load after upgrading reconciles +your database automatically. The old table layout is kept aside as +`wp_bd_books_legacy_library` and `wp_bd_series_legacy_library` so nothing is +lost; you can drop those once you are satisfied the migration went well. The shortcodes `[bd-book]`, `[bd-books]` and `[bdm_signup]` are unchanged, so existing posts keep working. The admin menu locations have changed: see the @@ -46,8 +49,11 @@ specification in `spec/SPEC_Umbra_Bookshelf_Plugin.md` for the full menu map. = Do I need to migrate my books? = -No. Table names, option keys, post meta keys and shortcodes are deliberately -unchanged. Deactivate the old plugins rather than deleting them. +Usually no. Table names, option keys, post meta keys and shortcodes are +deliberately unchanged; deactivate the old plugins rather than deleting them. +The one exception is a site that ran BD BookShelfHelper: its `bd_books` and +`bd_series` tables had a different layout, and those are reconciled +automatically on upgrade, with the originals kept aside. = The admin menu looks different. = @@ -55,15 +61,31 @@ It is one menu now instead of three. The catalogue, library and email screens are all still present as submenus, and Settings is split into catalogue settings and library settings. += Where do ratings come from? = + +Goodreads, refreshed every 12 hours. Amazon ratings are not scraped. Amazon +retail *links* still work as normal. + = Are there known problems? = -Yes — see `docs/KNOWN-ISSUES.md`. The most significant is that the catalogue and -library modules both create `bd_books` and `bd_series` with different schemas, -inherited from the old plugins. If you ran more than one of them together, check -that screen before relying on it. +Yes — see `docs/KNOWN-ISSUES.md`, which is included with the plugin. == Changelog == += 1.1.0 = +* Relicensed to GPLv2 or later for WordPress.org compatibility. The plugin + headers previously said GPLv2 while the licence files said Apache-2.0. +* The catalogue schema is now canonical for `bd_books` and `bd_series`. A site + that ran BD BookShelfHelper is reconciled automatically, preserving book IDs, + ratings and post links; the old tables are kept aside rather than dropped. +* Goodreads is now the only rating source, with a single implementation and one + 12-hour cron event. Duplicated Amazon rating scraping removed. +* `Umbra_SEO` is now active, emitting meta, Open Graph, Twitter card and sitemap + data for book detail pages. +* Fixed a missing default cover image and a missing email admin script, both of + which caused 404s. +* Admin settings screen for ratings now notes that Goodreads is the only source. + = 1.0.0 = * Initial release of the consolidated plugin. * Merged BD Bookshelf, BD BookShelfHelper and BD SimpleEmail into one plugin diff --git a/umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md b/umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md index b967180..5e3ef94 100644 --- a/umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md +++ b/umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md @@ -1,9 +1,9 @@ # Umbra Bookshelf — Plugin Specification **Status:** Active -**Version:** 1.0.0 +**Version:** 1.1.0 **Supersedes:** `spec/archive/SPEC_BD_Bookshelf_Plugin.txt`, `spec/archive/SPEC_BD_BookHelper_Plugin.txt` -**License:** Apache-2.0 +**License:** GPL-2.0-or-later (WordPress.org compatible) --- @@ -18,7 +18,7 @@ | Domain path | `/languages` | | Author | Umbra Tools | | Requires | WordPress 6.0, PHP 7.4 | -| Version | 1.0.0 | +| Version | 1.1.0 | The three pre-merge plugins map onto modules as follows: @@ -63,7 +63,7 @@ post content. Renaming any of them silently orphans existing data. | Post meta | `_bd_bookshelf_book_id` | | Transient prefix | `_transient_bd_bookshelf_*` | | Nonce actions | `bd_bookshelf_admin_nonce`, `bd_bookshelf_import_posts`, `bd_bookshelf_import_csv`, `bd_bookshelf_admin`, `bd_bookshelf_gdpr_nonce` | -| Cron hooks | `bd_bookshelf_sync_ratings`, `bd_bookshelf_goodreads_refresh`, `bd_bookshelf_amazon_scrape` | +| Cron hooks | `bd_bookshelf_sync_ratings` (active). `bd_bookshelf_goodreads_refresh` and `bd_bookshelf_amazon_scrape` are **retired** but still cleared on deactivate/uninstall so upgrades leave no stale schedules | | AJAX actions | `bd_bookshelf_*`, `bdm_update_links`, `bdm_autosave`, `bdm_ajax_signup`, `bdm_signup_submit`, `bdm_unsubscribe_submit` | | Shortcodes | `[bd-book]`, `[bd-books]`, `[bdm_signup]` | | Content types | post type `book`, taxonomy `series` | @@ -84,8 +84,9 @@ umbra-bookshelf/ ├── umbra-bookshelf.php bootstrap: constants, requires, singleton, single top-level menu ├── uninstall.php drops every table and option from all three modules ├── includes/ +│ ├── class-umbra-schema-migration.php reconciles pre-consolidation schemas │ ├── books/ catalogue: books, authors, series, retail, ratings, -│ │ shortcodes, SEO, GDPR, scrapers, admin, import +│ │ shortcodes, SEO, GDPR, Goodreads scraper, admin, import │ ├── library/ book post type + taxonomy, pen names, data entry, │ │ shortlinks, reviews, ratings, migrate │ └── email/ subscribers, newsletters, AI-assisted composition, sending @@ -119,11 +120,20 @@ click tracking, GDPR consent, and CSV/post import. Classes: `Umbra_Database`, `Umbra_Book_Manager`, `Umbra_Authors`, `Umbra_Series`, `Umbra_Retail_Sites`, `Umbra_Rating_Sync`, `Umbra_Shortcodes`, `Umbra_SEO`, -`Umbra_GDPR`, `Umbra_Goodreads_Scraper`, `Umbra_Amazon_Scraper`, `Umbra_Admin`, -`Umbra_Migration`, `Umbra_Theme_Compatibility`. +`Umbra_GDPR`, `Umbra_Goodreads_Scraper`, `Umbra_Admin`, `Umbra_Migration`, +`Umbra_Theme_Compatibility`. -> `Umbra_SEO` is never instantiated by the bootstrap (carried over unchanged from -> the original plugin). See `docs/KNOWN-ISSUES.md` item 5. +**This module owns `{prefix}bd_books` and `{prefix}bd_series`.** Its schema is +canonical; the library module no longer creates either table. + +**Goodreads is the only rating source.** `Umbra_Goodreads_Scraper` is the single +scraper; `Umbra_Rating_Sync` delegates all HTML parsing to it. Amazon rating +scraping was removed. The `rating_amazon` column and Amazon retail links are +unaffected. + +`Umbra_SEO` supplies per-book meta description, Open Graph, Twitter cards and +sitemap weighting for generated book detail pages. Completing it is +`docs/TASKS.md` §2. ### 4.2 Library — `includes/library/` @@ -134,6 +144,10 @@ ratings, and a migration screen for data created by earlier plugins. Classes: `Umbra_Bookshelf_Helper`, `Umbra_Bookshelf_Helper_Admin`, `Umbra_Bookshelf_Helper_Install`. +Owns `bd_book_meta`, `bd_pen_names`, `bd_reviews`, `bd_ratings` and +`bd_review_shortlinks`. It does **not** create `bd_books` or `bd_series` — see +§5a. + ### 4.3 Email — `includes/email/` Owns newsletter delivery: subscriber and newsletter tables, `[bdm_signup]`, @@ -181,27 +195,74 @@ of the two was unreachable. --- +## 5a. Schema ownership and reconciliation + +`bd_books` and `bd_series` are owned by the catalogue module. Before the +consolidation the library module created the same two table names with a +different column set, so whichever plugin activated first won and the other +read and wrote against the wrong shape. + +`Umbra_Schema_Migration` reconciles a library-shaped installation. It runs +**before** `dbDelta`, on activation and on `admin_init` when the recorded schema +version is behind, because plugin updates do not re-run activation hooks. + +Ordering matters: `dbDelta` does not drop columns, so pointed at a library-shaped +table it would graft catalogue columns on and leave a table satisfying neither +schema. The migration therefore ensures the table is either correctly +catalogue-shaped or absent before `dbDelta` is allowed near it. + +Behaviour: + +| Step | Detail | +|---|---| +| Detect | `bd_books` is library-shaped if it has `post_id` and no `title`; `bd_series` if it has `blurb` and no `description` | +| Set aside | `RENAME TABLE` to `bd_*_legacy_library`. **Never dropped** — rows stay recoverable | +| Create | canonical schema via `Umbra_Database::create_tables()` | +| Copy | series first, so `books.series_id` still resolves | +| Preserve | book IDs (keeps `bd_book_meta`, `bd_reviews`, `bd_ratings`, `bd_review_shortlinks` valid) | +| Map | `title`/`slug`/`description` from the linked post; library's `isbn`/`asin` onto the ebook columns; `amazon_asin` also set so product links keep working | +| Re-link | writes `_bd_bookshelf_book_id`; keeps `publish_date` as `_bd_bookshelf_publish_date` (no catalogue column exists) | +| Fold ratings | `bd_ratings` goodreads/amazon values into `rating_goodreads` / `rating_amazon`, only where still zero | +| Skip | rows whose post no longer exists, with a log entry, rather than creating an unusable book | +| De-duplicate | slugs, because the catalogue's `slug` column is `UNIQUE` | + +Tracked by the `umbra_bookshelf_schema_version` option (current target: `2`). +Re-running is a no-op. A failure does not advance the version, so the next +attempt retries. + ## 6. Lifecycle -* **Activate:** create catalogue tables (`Umbra_Database::create_tables()`), - library tables + roles (`Umbra_Bookshelf_Helper_Install::activate()`), email - tables (`Umbra_Simple_Email_Install::activate()`), flush rewrites, record +* **Activate:** reconcile the schema (`Umbra_Schema_Migration::maybe_migrate()`), + then create catalogue tables (`Umbra_Database::create_tables()`), library tables + + roles (`Umbra_Bookshelf_Helper_Install::activate()`), email tables + (`Umbra_Simple_Email_Install::activate()`), flush rewrites, record `bd_bookshelf_activation_time`. -* **Deactivate:** clear all three cron hooks, run module deactivators, flush - rewrites. +* **Update:** plugin updates do not re-run activation, so `admin_init` re-checks + the schema version and reconciles if it is behind. +* **Deactivate:** clear all cron hooks named in §2.2 — including the retired + `bd_bookshelf_goodreads_refresh` and `bd_bookshelf_amazon_scrape`, so no stale + schedules survive an upgrade — run module deactivators, flush rewrites. * **Uninstall:** drop every table across all three modules (deduplicated so `bd_books`/`bd_series` are dropped once), delete `bd_bookshelf_%` options and transients, delete the `bdm_*` options, clear cron. Guarded by - `WP_UNINSTALL_PLUGIN`. + `WP_UNINSTALL_PLUGIN`. Set-aside `bd_*_legacy_library` tables are **not** + dropped; removing those is a deliberate operator action (see `docs/TASKS.md` §4). --- ## 7. Verification ```bash -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 +umbra-bookshelf/tests/run_all.sh # every suite +``` + +or individually: + +```bash +python3 umbra-bookshelf/tests/verify_structure.py # static checks +php umbra-bookshelf/tests/smoke_runtime.php front # front-end boot +php umbra-bookshelf/tests/smoke_runtime.php admin # admin boot + menu shape +php umbra-bookshelf/tests/schema_migration_test.php # migration vs. a real database ``` The static check enforces: valid PHP syntax, no class declared twice, no @@ -213,12 +274,28 @@ The runtime check stubs WordPress, loads the bootstrap, fires `plugins_loaded`, `init` and `admin_menu`, and asserts the post type, taxonomy and shortcodes register, that exactly one top-level menu appears with all 20 expected submenus under it, that no callback is registered twice on a hook, and that asset handles -are unique per module. +are unique per module. The declared version is checked against the plugin +header rather than a literal, so a version bump cannot silently break it. + +The schema check drives a throwaway SQLite database through the `sqlite3` CLI — +no credentials, no shared server — and verifies shape detection, row mapping, +ID preservation, orphan handling, rating folding, idempotency and slug +collisions against real data. MySQL `RENAME TABLE` is translated to SQLite's +`ALTER TABLE ... RENAME TO` by the shim, since the migration itself targets +MySQL. --- -## 8. Open issues +## 8. Open issues and tasks -`bd_books` and `bd_series` are created with two incompatible schemas by the -books and library modules. This predates the consolidation and is **not** fixed -here; see `docs/KNOWN-ISSUES.md`. +The three problems that blocked the consolidation — the dual `bd_books` / +`bd_series` schemas, duplicated scraping, and the Apache/GPL licence mismatch — +are resolved as of 1.1.0. + +Remaining, in `docs/KNOWN-ISSUES.md`: the rating sync blocks a worker with +`sleep()`; the catalogue and library still model a book differently, with no +synchronisation between them; and set-aside legacy tables are never reclaimed. + +Intended work, in `docs/TASKS.md`: the email module's admin behaviour, completing +SEO for generated book detail pages, batching the rating sync, cleaning up legacy +tables, reconciling the two book models, and retiring the `Old/` archive. diff --git a/umbra-bookshelf/tests/run_all.sh b/umbra-bookshelf/tests/run_all.sh new file mode 100755 index 0000000..6f6b389 --- /dev/null +++ b/umbra-bookshelf/tests/run_all.sh @@ -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" diff --git a/umbra-bookshelf/tests/schema_migration_test.php b/umbra-bookshelf/tests/schema_migration_test.php new file mode 100644 index 0000000..412ed24 --- /dev/null +++ b/umbra-bookshelf/tests/schema_migration_test.php @@ -0,0 +1,423 @@ +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"; diff --git a/umbra-bookshelf/tests/smoke_runtime.php b/umbra-bookshelf/tests/smoke_runtime.php index 8c989fa..f46b841 100644 --- a/umbra-bookshelf/tests/smoke_runtime.php +++ b/umbra-bookshelf/tests/smoke_runtime.php @@ -155,7 +155,7 @@ section('3. Module classes loaded'); foreach (array('Umbra_Database', 'Umbra_Book_Manager', 'Umbra_Series', 'Umbra_Authors', 'Umbra_Retail_Sites', 'Umbra_Rating_Sync', 'Umbra_Shortcodes', 'Umbra_SEO', 'Umbra_GDPR', - 'Umbra_Goodreads_Scraper', 'Umbra_Amazon_Scraper', + 'Umbra_Goodreads_Scraper', 'Umbra_Schema_Migration', 'Umbra_Admin', 'Umbra_Migration', 'Umbra_Theme_Compatibility', 'Umbra_Bookshelf_Helper', 'Umbra_Bookshelf_Helper_Admin', 'Umbra_Bookshelf_Helper_Install', 'Umbra_Simple_Email', @@ -175,8 +175,13 @@ check('real library class still distinct', !is_a('Umbra_Bookshelf_Helper', 'Umbra_Theme_Compatibility', true)); section('5. Constants'); -check('UMBRA_BOOKSHELF_VERSION', defined('UMBRA_BOOKSHELF_VERSION') - && UMBRA_BOOKSHELF_VERSION === '1.0.0', UMBRA_BOOKSHELF_VERSION ?? 'undefined'); +// Compare against the plugin header rather than a hard-coded literal, so a +// version bump cannot silently break the suite. +$boot_src = file_get_contents(dirname(__DIR__) . '/umbra-bookshelf.php'); +$header_version = preg_match('/^\s*\*\s*Version:\s*(\S+)/m', $boot_src, $vm) ? $vm[1] : null; +check('UMBRA_BOOKSHELF_VERSION matches the plugin header', + defined('UMBRA_BOOKSHELF_VERSION') && UMBRA_BOOKSHELF_VERSION === $header_version, + (UMBRA_BOOKSHELF_VERSION ?? 'undefined') . ' vs header ' . ($header_version ?? 'none')); check('UMBRA_BOOKSHELF_DIR', defined('UMBRA_BOOKSHELF_DIR')); check('UMBRA_BOOKSHELF_URL', defined('UMBRA_BOOKSHELF_URL')); check('UMBRA_BOOKSHELF_BASENAME', defined('UMBRA_BOOKSHELF_BASENAME')); diff --git a/umbra-bookshelf/tests/verify_structure.py b/umbra-bookshelf/tests/verify_structure.py index 805a2a0..4d7a2dc 100644 --- a/umbra-bookshelf/tests/verify_structure.py +++ b/umbra-bookshelf/tests/verify_structure.py @@ -41,8 +41,10 @@ for f in php: print('1. php -l: %d files checked' % len(php)) # ------------------------------------------------- 2. duplicate class decls +# Shipped source only: tests/ defines intentional stubs of real classes. +source_php = [f for f in php if os.sep + 'tests' + os.sep not in f + os.sep] decls = {} -for f in php: +for f in source_php: src = io.open(f, encoding='utf-8', errors='replace').read() for m in re.finditer(r'^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)', src, re.M): @@ -55,7 +57,7 @@ print('2. class declarations: %d unique, %d duplicated' # --------------------------------------------- 3. referenced classes exist refs = set() -for f in php: +for f in source_php: src = io.open(f, encoding='utf-8', errors='replace').read() for m in re.finditer(r'\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(', src): refs.add(m.group(1)) @@ -70,7 +72,9 @@ wp_provided = {'BD_BookShelfHelper', # created via class_alias() known_external = {'GeminiClient', # browser JS globals, matched by the `new X(` heuristic # when scanning inline