Resolve the known issues; switch licence to GPL
Closes the three problems that blocked the consolidation and records the rest
as tasks.
Licence: GPLv2 or later (WordPress.org compatible)
LICENSE is now the full GPLv2 text and NOTICE is gone (it was Apache
specific). All 101 SPDX headers read GPL-2.0-or-later and every shipped
header says "GPLv2 or later".
This also fixes an inconsistency that predated the consolidation: the theme's
style.css declared GPLv2 while its PHP files carried Apache headers.
Old/ and archive/pre-umbra-releases/ keep their original Apache headers on
purpose -- they are frozen snapshots, not shipped code.
Schema: the catalogue schema is canonical for bd_books and bd_series
Adds Umbra_Schema_Migration. The catalogue and library modules previously
both created those two tables with different columns, so whichever plugin
activated first won and the other read and wrote against the wrong shape.
The migration detects a library-shaped table, RENAMES it aside (never drops
it), creates the canonical table and copies rows across:
- book IDs preserved, keeping bd_book_meta, bd_reviews, bd_ratings and
bd_review_shortlinks valid
- title/slug/description resolved from the linked post
- the library's single isbn/asin land on the ebook columns; amazon_asin is
also set so existing product links keep working
- series migrated first so books.series_id still resolves
- post linkage rewritten as _bd_bookshelf_book_id (preserved meta key)
- publish_date kept as _bd_bookshelf_publish_date (no catalogue column)
- bd_ratings goodreads/amazon folded into rating_goodreads/rating_amazon
- rows whose post is gone are skipped with a log line, not turned into
unusable books
- duplicate slugs de-duplicated, since catalogue slug is UNIQUE
It runs before dbDelta, and on admin_init as well as activation, because
plugin updates do not re-run activation hooks. Ordering matters: dbDelta adds
columns but never drops them, so pointed at a library-shaped table it would
graft on catalogue columns and leave a table satisfying neither schema.
Umbra_Bookshelf_Helper_Install no longer creates bd_books or bd_series.
Scraping: Goodreads only, one implementation
There were three Goodreads implementations and two Amazon ones; both library
"scrapers" were empty stubs attached to cron events that fired on schedule to
do nothing, and both real scraper classes were never called at all.
Umbra_Amazon_Scraper is deleted and the duplicate HTML parsing is gone from
Umbra_Rating_Sync, which now delegates to Umbra_Goodreads_Scraper. The two
library stubs and their cron registrations are removed. One schedule
survives: bd_bookshelf_sync_ratings, every 12 hours.
The rating_amazon column is retained (it holds data and is populated during
migration) and Amazon retail links are unaffected.
A new rating fetch failure keeps the last known value instead of writing zero.
The admin ratings notice no longer warns about Amazon scraping.
Umbra_SEO is now instantiated
It registers its hooks from a constructor and the bootstrap never constructed
it, so the class was dead code and no SEO output was produced. Its meta, Open
Graph, Twitter card and sitemap hooks now attach. Completing SEO for generated
book detail pages is docs/TASKS.md #2.
Assets that 404'd
- assets/images/default-cover.png now holds a real 512x815 cover
(WorkingTitleCover_512x815.png). The helper returned this path but the
repository contained no images at all.
- email/assets/js/admin.js is committed as an empty placeholder. It was
enqueued on every email admin page but had never been committed.
Tests
- tests/schema_migration_test.php: 42 checks against a real database, driven
through the sqlite3 CLI on a throwaway file. Covers shape detection, row
mapping, ID preservation, orphan handling, rating folding, idempotency and
slug collisions.
- tests/run_all.sh runs every suite.
- The declared version is now checked against the plugin header rather than a
literal, so a version bump cannot silently break the suite.
Docs
- KNOWN-ISSUES.md rewritten: three resolved, three remaining.
- TASKS.md added: email admin behaviour, book detail page SEO, batching the
rating sync, reclaiming legacy tables, reconciling the two book models,
retiring the Old/ archive.
- SPEC and ARCHITECTURE updated for schema ownership, the migration ordering
constraint, and the single scraper.
Version 1.1.0.
This commit is contained in:
474
LICENSE
474
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.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Preamble
|
||||
|
||||
1. Definitions.
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
|
||||
2
NOTICE
2
NOTICE
@@ -1,2 +0,0 @@
|
||||
Umbra Tools (https://umbratools.com)
|
||||
Copyright 2026 Umbra Tools (umbratools.com)
|
||||
17
README.md
17
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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Custom Widgets
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Mobile navigation toggle
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* BDMurphy-Bookshelf functions and definitions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Book Detail
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: 404 Error
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: All Books
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Series Listing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Blog
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Navigation JavaScript
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Social Share JavaScript
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* The template for displaying comments
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Home
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* BDMurphy Bookshelf Theme Functions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Home / Blog Posts Page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* AJAX Handlers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* External Book Data API Helpers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Book Helper Functions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Customizer Settings
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Navigation Menu Hooks
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Theme Setup
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Short URL Rewrites - Override BookHelper for direct template rendering
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Tags
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Theme Admin Menu
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Theme Options Admin Page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Widgets
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Main template file
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: About
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Books
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Contact
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Full Width
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: No Sidebar
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Reviews
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Sign Up
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Unsubscribe
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Default Page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Search Results
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Book Detail
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Template Name: Individual Series
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Social Share Template Part
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* BD Bookshelf - Admin JavaScript
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 55 KiB |
@@ -25,7 +25,7 @@ one of those collided, and each collision is recorded in `KNOWN-ISSUES.md`.
|
||||
┌──────────────────────────┼──────────────────────────┐
|
||||
│ │ │
|
||||
includes/books includes/library includes/email
|
||||
Umbra_Database Umbra_Bookshelf_Helper Umbra_Simple_Email
|
||||
Umbra_Database *owns bd_books/bd_series* Umbra_Simple_Email
|
||||
Umbra_Book_Manager …_Helper_Admin …_Admin
|
||||
Umbra_Authors …_Helper_Install …_Install
|
||||
Umbra_Series Umbra_Email_Sender
|
||||
@@ -34,11 +34,12 @@ one of those collided, and each collision is recorded in `KNOWN-ISSUES.md`.
|
||||
Umbra_Shortcodes
|
||||
Umbra_SEO assets: admin/, public/, library/, email/
|
||||
Umbra_GDPR assets/
|
||||
Umbra_Goodreads_Scraper
|
||||
Umbra_Amazon_Scraper
|
||||
Umbra_Goodreads_Scraper (only scraper)
|
||||
Umbra_Admin
|
||||
Umbra_Migration
|
||||
Umbra_Theme_Compatibility -> class_alias BD_BookShelfHelper
|
||||
|
||||
includes/class-umbra-schema-migration.php reconciles pre-consolidation schemas
|
||||
```
|
||||
|
||||
## Design rules
|
||||
@@ -62,9 +63,19 @@ callbacks are registered before `init` fires.
|
||||
static API the unmodified theme calls, and re-exports the legacy
|
||||
`BD_BookShelfHelper` name via `class_alias()`.
|
||||
|
||||
## Not resolved here
|
||||
**One owner per table.** `bd_books` and `bd_series` belong to the catalogue. The
|
||||
library module deliberately no longer creates them. Where an installation was
|
||||
built on the old library schema, `Umbra_Schema_Migration` renames the old table
|
||||
aside, creates the canonical one, and copies rows across preserving IDs.
|
||||
|
||||
The catalogue and library modules keep two independent data models that happen
|
||||
to share table names `bd_books` and `bd_series`. Consolidation made them
|
||||
unconditionally conflict; it did not make them agree. Resolving this is the next
|
||||
piece of work — see `KNOWN-ISSUES.md` §1 for the options.
|
||||
**Schema work happens before `dbDelta`, never after.** `dbDelta` adds columns but
|
||||
never drops them, so pointing it at a table of the wrong shape produces a table
|
||||
that satisfies neither schema. Reconciliation therefore runs first, and the
|
||||
migration is what guarantees the invariant.
|
||||
|
||||
## Still to reconcile
|
||||
|
||||
Making the catalogue schema canonical removed the *table* conflict. The two
|
||||
modules still model a book differently — the catalogue as a row in `bd_books`,
|
||||
the library as a `book` custom post type with per-post meta — and nothing keeps
|
||||
them in step. See `TASKS.md` §5.
|
||||
|
||||
@@ -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
|
||||
|
||||
103
umbra-bookshelf/docs/TASKS.md
Normal file
103
umbra-bookshelf/docs/TASKS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Tasks
|
||||
|
||||
Known, agreed work that is deliberately not done yet. Ordered by size, not
|
||||
priority. See `KNOWN-ISSUES.md` for defects; this file is for intended work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Define the email module's admin behaviour
|
||||
|
||||
`includes/email/`, `email/assets/js/admin.js`
|
||||
|
||||
The admin script is an empty placeholder. It was referenced by
|
||||
`Umbra_Simple_Email_Admin::enqueue_assets()` but never committed, so the file
|
||||
was created during the consolidation purely to stop the 404.
|
||||
|
||||
Nothing in the email admin is currently script-driven, so the page works — it
|
||||
is just plain HTML forms. Define what should be interactive:
|
||||
|
||||
* **Subscriber list** — search, sort, pagination and bulk delete. The table is
|
||||
already rendered; this is the highest-value addition.
|
||||
* **Composer** — live preview of the rendered newsletter, and a "send a test to
|
||||
me" button that does not touch the real subscriber list.
|
||||
* **Settings** — validate the Gemini API key and the Gmail credentials on save
|
||||
and report the error in place, instead of failing later at send time.
|
||||
|
||||
## 2. Complete SEO for book detail pages
|
||||
|
||||
`includes/books/class-umbra-seo.php`
|
||||
|
||||
The SEO module is now active (it was loaded but never instantiated before, so
|
||||
its hooks never attached). It already provides, per book:
|
||||
|
||||
* `<meta name="title|description|keywords">` from the `bd_book_seo` table
|
||||
* Open Graph tags (`og:type=book`, title, description, URL, image)
|
||||
* Twitter card tags
|
||||
* sitemap weighting driven by the book's rating
|
||||
|
||||
Remaining work, since the system generates the book detail pages itself:
|
||||
|
||||
* **Populate `bd_book_seo` automatically.** Rows are only written when
|
||||
something calls `Umbra_SEO::update_book_seo()`, and today nothing does. On
|
||||
book create/update, derive sensible defaults: title as
|
||||
`{book title} by {author}`, description from the blurb, image from the cover.
|
||||
* **Schema.org `Book` structured data.** Currently only Open Graph is emitted.
|
||||
JSON-LD with `name`, `author`, `isbn`, `aggregateRating`, `offers` and
|
||||
`inLanguage` is what actually drives rich results.
|
||||
* **Per-language metadata.** `bd_book_meta` is keyed by book *and* language, so
|
||||
a translated book needs its own title/description rather than sharing one.
|
||||
* **Avoid clashing with SEO plugins.** If Yoast, Rank Math or SEOPress is
|
||||
active, it will also emit description and OG tags. Detect them and defer, or
|
||||
register with their APIs instead of printing directly.
|
||||
* **Canonical and hreflang** for multi-language book pages.
|
||||
|
||||
## 3. Stop the rating sync blocking on `sleep()`
|
||||
|
||||
`includes/books/class-umbra-rating-sync.php`
|
||||
|
||||
`sync_all_ratings()` walks up to 999 books and calls `sleep(2)` between each
|
||||
one, inside a cron run. That is roughly 33 minutes of a blocked PHP worker, and
|
||||
cron jobs that outrun their next scheduled tick compound.
|
||||
|
||||
Suggested fix: sync a bounded batch per run (for example 25 books), store the
|
||||
cursor in an option, and let the next 12-hour tick continue from there. Or
|
||||
schedule each book as its own single event.
|
||||
|
||||
This predates the consolidation and was left as-is because the agreed change was
|
||||
scoped to dropping Amazon, but it will bite in production.
|
||||
|
||||
## 4. Decide the fate of the set-aside legacy tables
|
||||
|
||||
`includes/class-umbra-schema-migration.php`
|
||||
|
||||
Sites that were built on the library schema get their old `bd_books` and
|
||||
`bd_series` renamed to `bd_books_legacy_library` / `bd_series_legacy_library`
|
||||
rather than dropped, so nothing is lost.
|
||||
|
||||
Once an installation has been verified, those tables are dead weight. Add a
|
||||
small admin action that summarises them (row counts) and offers to drop them
|
||||
once confirmed. Until then they are inert.
|
||||
|
||||
## 5. Reconcile the two book data models
|
||||
|
||||
`includes/books/` and `includes/library/`
|
||||
|
||||
Making the catalogue schema canonical removed the *table* conflict, but the two
|
||||
modules still model a book differently:
|
||||
|
||||
* **Catalogue** — a row in `bd_books`, with its own ISBN/ASIN/rating columns.
|
||||
Not tied to a WordPress post.
|
||||
* **Library** — a `book` custom post type, with per-post meta such as
|
||||
`ebook_url_amazon`, `goodreads_rating`, `seo_title`.
|
||||
|
||||
Nothing currently keeps them in step: editing a book in one place does not
|
||||
update the other. Decide whether the catalogue is the single source of truth
|
||||
with the library reading from it, or whether they stay independent and a
|
||||
sync layer connects them.
|
||||
|
||||
## 6. Remove the historical `Old/` and `archive/` directories
|
||||
|
||||
These hold pre-consolidation plugin snapshots and release zips. They are useful
|
||||
provenance now and will be noise later. The `Old/` tree in particular still
|
||||
carries the old Apache licence headers; it is deliberately excluded from the
|
||||
GPL switch because it is a frozen record, not shipped code.
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Admin Interface class
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* Amazon Scraper class
|
||||
*
|
||||
* Fetches author and book data from Amazon
|
||||
*
|
||||
* @package Umbra_Bookshelf
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Umbra_Amazon_Scraper {
|
||||
|
||||
private $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
/**
|
||||
* Search Amazon for a book by title and author
|
||||
*
|
||||
* @param string $title Book title.
|
||||
* @param string $author Author name.
|
||||
* @return array List of books found.
|
||||
*/
|
||||
public function search_books($title, $author = '') {
|
||||
$query = $title;
|
||||
if ($author) {
|
||||
$query .= ' ' . $author;
|
||||
}
|
||||
|
||||
$url = 'https://www.amazon.com/s?k=' . urlencode($query) . '&i=stripbooks';
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => $this->user_agent,
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.5',
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['error' => $response->get_error_message()];
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
|
||||
return $this->parse_search_results($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Amazon search results
|
||||
*/
|
||||
private function parse_search_results($html) {
|
||||
$books = [];
|
||||
|
||||
// Match search result items with data-asin
|
||||
if (preg_match_all('/<div[^>]*data-asin="([A-Z0-9]{10})"[^>]*class="[^"]*s-result-item[^"]*"[^>]*>(.*?)<\/div>\s*<\/div>\s*<\/div>/si', $html, $matches)) {
|
||||
foreach ($matches as $index => $match) {
|
||||
$asin = $match[1];
|
||||
$item_html = $match[2];
|
||||
|
||||
$book = $this->parse_search_item($item_html, $asin);
|
||||
if ($book) {
|
||||
$books[] = $book;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative pattern
|
||||
if (empty($books)) {
|
||||
if (preg_match_all('/data-asin="([A-Z0-9]{10})"/si', $html, $asin_matches)) {
|
||||
foreach ($asin_matches[1] as $asin) {
|
||||
// Try to find context around this ASIN
|
||||
$pattern = '/data-asin="' . preg_quote($asin) . '"[^>]*>(.*?)(?=data-asin="|$)/si';
|
||||
if (preg_match($pattern, $html, $context_match)) {
|
||||
$book = $this->parse_search_item($context_match[1], $asin);
|
||||
if ($book) {
|
||||
$books[] = $book;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single search result item
|
||||
*/
|
||||
private function parse_search_item($html, $asin) {
|
||||
$book = [
|
||||
'asin' => $asin,
|
||||
'title' => '',
|
||||
'author' => '',
|
||||
'format' => '',
|
||||
'rating' => 0,
|
||||
'ratings_count' => 0,
|
||||
'cover_image' => '',
|
||||
'url' => 'https://www.amazon.com/dp/' . $asin,
|
||||
];
|
||||
|
||||
// Extract title
|
||||
if (preg_match('/<h2[^>]*>.*?<span[^>]*>(.*?)<\/span>/si', $html, $matches)) {
|
||||
$book['title'] = trim(strip_tags($matches[1]));
|
||||
} elseif (preg_match('/<a[^>]*>.*?<span[^>]*class="[^"]*a-size[^"]*"[^>]*>(.*?)<\/span>/si', $html, $matches)) {
|
||||
$book['title'] = trim(strip_tags($matches[1]));
|
||||
}
|
||||
|
||||
// Extract author
|
||||
if (preg_match('/<span[^>]*class="[^"]*a-size[^"]*"[^>]*>\s*by\s+<a[^>]*>(.*?)<\/a>/si', $html, $matches)) {
|
||||
$book['author'] = trim(strip_tags($matches[1]));
|
||||
}
|
||||
|
||||
// Extract format (Kindle, Paperback, Hardcover, Audio)
|
||||
if (preg_match('/(Kindle\s*Edition|Paperback|Hardcover|Audio\s*CD|Audible|MP3\s*CD)/si', $html, $matches)) {
|
||||
$book['format'] = trim($matches[1]);
|
||||
}
|
||||
|
||||
// Extract rating
|
||||
if (preg_match('/(\d+\.?\d*)\s*out\s*of\s*5\s*stars?/si', $html, $matches)) {
|
||||
$book['rating'] = (float) $matches[1];
|
||||
}
|
||||
|
||||
// Extract ratings count
|
||||
if (preg_match('/([\d,]+)\s*ratings?/si', $html, $matches)) {
|
||||
$book['ratings_count'] = absint(str_replace(',', '', $matches[1]));
|
||||
}
|
||||
|
||||
// Extract cover image
|
||||
if (preg_match('/<img[^>]*src="(https:\/\/[^"]*\.jpg)"/si', $html, $matches)) {
|
||||
$book['cover_image'] = $matches[1];
|
||||
}
|
||||
|
||||
if (empty($book['title'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up book by ASIN and get full details including ISBN
|
||||
*
|
||||
* @param string $asin Amazon ASIN.
|
||||
* @return array Book details.
|
||||
*/
|
||||
public function lookup_by_asin($asin) {
|
||||
$url = 'https://www.amazon.com/dp/' . $asin;
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => $this->user_agent,
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.5',
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['error' => $response->get_error_message()];
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
|
||||
return $this->parse_product_page($body, $asin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Amazon product page for full book details
|
||||
*/
|
||||
private function parse_product_page($html, $asin) {
|
||||
$book = [
|
||||
'asin' => $asin,
|
||||
'title' => '',
|
||||
'author' => '',
|
||||
'isbn' => '',
|
||||
'isbn13' => '',
|
||||
'rating' => 0,
|
||||
'ratings_count' => 0,
|
||||
'cover_image' => '',
|
||||
'description' => '',
|
||||
'formats' => [],
|
||||
];
|
||||
|
||||
// Extract title
|
||||
if (preg_match('/<h1[^>]*id="title"[^>]*>(.*?)<\/h1>/si', $html, $matches)) {
|
||||
$book['title'] = trim(strip_tags($matches[1]));
|
||||
}
|
||||
|
||||
// Extract author
|
||||
if (preg_match('/by\s+<a[^>]*>(.*?)<\/a>/si', $html, $matches)) {
|
||||
$book['author'] = trim(strip_tags($matches[1]));
|
||||
}
|
||||
|
||||
// Extract rating
|
||||
if (preg_match('/(\d+\.?\d*)\s*out\s*of\s*5\s*stars?/si', $html, $matches)) {
|
||||
$book['rating'] = (float) $matches[1];
|
||||
}
|
||||
|
||||
// Extract ratings count
|
||||
if (preg_match('/([\d,]+)\s*ratings?/si', $html, $matches)) {
|
||||
$book['ratings_count'] = absint(str_replace(',', '', $matches[1]));
|
||||
}
|
||||
|
||||
// Extract cover image
|
||||
if (preg_match('/<img[^>]*id="landingImage"[^>]*src="([^"]+)"/si', $html, $matches)) {
|
||||
$book['cover_image'] = $matches[1];
|
||||
}
|
||||
|
||||
// Extract ISBN from product details
|
||||
if (preg_match('/ISBN-10:\s*(\d{10})/si', $html, $matches)) {
|
||||
$book['isbn'] = $matches[1];
|
||||
}
|
||||
if (preg_match('/ISBN-13:\s*(\d{13})/si', $html, $matches)) {
|
||||
$book['isbn13'] = $matches[1];
|
||||
}
|
||||
|
||||
// Extract ASIN
|
||||
if (preg_match('/ASIN:\s*([A-Z0-9]{10})/si', $html, $matches)) {
|
||||
$book['asin'] = $matches[1];
|
||||
}
|
||||
|
||||
// Extract description
|
||||
if (preg_match('/<div[^>]*id="bookDescription_feature_div"[^>]*>.*?<span[^>]*>(.*?)<\/span>/si', $html, $matches)) {
|
||||
$book['description'] = trim(strip_tags($matches[1]));
|
||||
}
|
||||
|
||||
// Look for other format links (Kindle, Paperback, Hardcover)
|
||||
if (preg_match_all('/<li[^>]*>.*?<a[^>]*href="\/dp\/([A-Z0-9]{10})"[^>]*>.*?<span[^>]*>(.*?)<\/span>/si', $html, $matches)) {
|
||||
foreach ($matches[1] as $index => $format_asin) {
|
||||
$format_name = trim(strip_tags($matches[2][$index]));
|
||||
if ($format_asin !== $asin && !empty($format_name)) {
|
||||
$book['formats'][] = [
|
||||
'asin' => $format_asin,
|
||||
'format' => $format_name,
|
||||
'url' => 'https://www.amazon.com/dp/' . $format_asin,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all format ASINs for a book by searching
|
||||
*
|
||||
* @param string $title Book title.
|
||||
* @param string $author Author name.
|
||||
* @return array Format => ASIN mapping.
|
||||
*/
|
||||
public function get_format_asins($title, $author = '') {
|
||||
$formats = [
|
||||
'kindle' => '',
|
||||
'paperback' => '',
|
||||
'hardcover' => '',
|
||||
'audio' => '',
|
||||
];
|
||||
|
||||
// Search for the book
|
||||
$results = $this->search_books($title, $author);
|
||||
|
||||
if (isset($results['error']) || empty($results)) {
|
||||
return $formats;
|
||||
}
|
||||
|
||||
// Match results to formats
|
||||
foreach ($results as $book) {
|
||||
$book_title = strtolower($book['title'] ?? '');
|
||||
$book_format = strtolower($book['format'] ?? '');
|
||||
|
||||
// Check if this is the same book (title match)
|
||||
similar_text(strtolower($title), $book_title, $percent);
|
||||
if ($percent < 60) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strpos($book_format, 'kindle') !== false || strpos($book_format, 'ebook') !== false) {
|
||||
$formats['kindle'] = $book['asin'];
|
||||
} elseif (strpos($book_format, 'paperback') !== false) {
|
||||
$formats['paperback'] = $book['asin'];
|
||||
} elseif (strpos($book_format, 'hardcover') !== false) {
|
||||
$formats['hardcover'] = $book['asin'];
|
||||
} elseif (strpos($book_format, 'audio') !== false || strpos($book_format, 'audible') !== false) {
|
||||
$formats['audio'] = $book['asin'];
|
||||
}
|
||||
}
|
||||
|
||||
return $formats;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Authors Management class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Book Manager class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Database management class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* GDPR Compliance class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Goodreads Scraper class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Migration Manager class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Rating Sync Engine class
|
||||
@@ -76,7 +76,17 @@ class Umbra_Rating_Sync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync rating for a single book
|
||||
* Sync rating for a single book from Goodreads
|
||||
*
|
||||
* Goodreads is the only supported rating source. Amazon scraping was
|
||||
* removed: it duplicated work, added nothing the catalogue did not
|
||||
* already store, and was a second scraping surface to maintain.
|
||||
*
|
||||
* All HTML parsing lives in Umbra_Goodreads_Scraper -- this class only
|
||||
* decides what to persist.
|
||||
*
|
||||
* @param int $book_id Catalogue book ID.
|
||||
* @return bool True when a row was written.
|
||||
*/
|
||||
public function sync_book_rating($book_id) {
|
||||
global $wpdb;
|
||||
@@ -86,119 +96,53 @@ class Umbra_Rating_Sync {
|
||||
return false;
|
||||
}
|
||||
|
||||
$amazon_rating = 0;
|
||||
$goodreads_rating = 0;
|
||||
|
||||
// Sync Amazon rating
|
||||
if (!empty($book->amazon_asin) || !empty($book->isbn_ebook)) {
|
||||
$isbn = $book->amazon_asin ?: $book->isbn_ebook;
|
||||
$amazon_rating = $this->fetch_amazon_rating($isbn);
|
||||
|
||||
// Keep last rating if fetch fails
|
||||
if ($amazon_rating === false) {
|
||||
$amazon_rating = $book->rating_amazon;
|
||||
}
|
||||
// Without a recorded Goodreads ID there is nothing to look up, and no
|
||||
// reliable way to discover one from the catalogue alone. Leave the
|
||||
// stored rating untouched.
|
||||
if (empty($book->goodreads_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sync Goodreads rating
|
||||
if (!empty($book->goodreads_id)) {
|
||||
$goodreads_rating = $this->fetch_goodreads_rating($book->goodreads_id);
|
||||
$scraper = new Umbra_Goodreads_Scraper();
|
||||
$details = $scraper->get_book_details($book->goodreads_id);
|
||||
|
||||
// Keep last rating if fetch fails
|
||||
if ($goodreads_rating === false) {
|
||||
$goodreads_rating = $book->rating_goodreads;
|
||||
}
|
||||
// A transport or parse failure comes back as an 'error' key. Keep the
|
||||
// last known rating rather than overwriting it with zero.
|
||||
if (!is_array($details) || !empty($details['error'])) {
|
||||
$message = (is_array($details) && !empty($details['error']))
|
||||
? $details['error']
|
||||
: 'unexpected response shape';
|
||||
error_log('Umbra Bookshelf: Goodreads fetch error - ' . $message);
|
||||
return false;
|
||||
}
|
||||
|
||||
$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'];
|
||||
}
|
||||
|
||||
// Update book ratings
|
||||
$table = Umbra_Database::table('books');
|
||||
$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*)</si', $body, $matches)) {
|
||||
return (float) $matches[1];
|
||||
}
|
||||
|
||||
// Alternative pattern
|
||||
if (preg_match('/class="RatingStatistics__rating"[^>]*>(\d+\.?\d*)</si', $body, $matches)) {
|
||||
return (float) $matches[1];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual sync trigger
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Retail Sites management class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* SEO Metadata Manager class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Series Management class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Shortcodes Manager class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Theme Compatibility Layer
|
||||
|
||||
413
umbra-bookshelf/includes/class-umbra-schema-migration.php
Normal file
413
umbra-bookshelf/includes/class-umbra-schema-migration.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Schema Reconciliation
|
||||
*
|
||||
* The catalogue module owns {prefix}bd_books and {prefix}bd_series. Before the
|
||||
* consolidation, the library module created those same two table names with a
|
||||
* completely different column set, so whichever plugin activated first won and
|
||||
* the other read and wrote against the wrong shape.
|
||||
*
|
||||
* Decision: the catalogue schema is canonical. This class reconciles an
|
||||
* installation that was built on the library schema so no rows are lost.
|
||||
*
|
||||
* The legacy tables are RENAMED, never dropped, so the original data stays
|
||||
* recoverable until an operator removes it deliberately.
|
||||
*
|
||||
* @package Umbra_Bookshelf
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Umbra_Schema_Migration {
|
||||
|
||||
/**
|
||||
* Option recording which schema revision this site is on.
|
||||
*/
|
||||
const VERSION_OPTION = 'umbra_bookshelf_schema_version';
|
||||
|
||||
/**
|
||||
* Schema revision introduced by the consolidation.
|
||||
*/
|
||||
const TARGET_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Suffix given to set-aside legacy tables.
|
||||
*/
|
||||
const LEGACY_SUFFIX = '_legacy_library';
|
||||
|
||||
/**
|
||||
* Run the reconciliation if this site has not already been reconciled.
|
||||
*
|
||||
* Safe to call on every activation: it returns immediately once the site
|
||||
* reports the target schema version.
|
||||
*
|
||||
* @return array Report of what happened, for logging and tests.
|
||||
*/
|
||||
public static function maybe_migrate() {
|
||||
$report = array(
|
||||
'version' => self::VERSION_OPTION,
|
||||
'from' => (int) get_option(self::VERSION_OPTION, 0),
|
||||
'books_migrated' => 0,
|
||||
'series_migrated' => 0,
|
||||
'books_legacy' => false,
|
||||
'series_legacy' => false,
|
||||
'errors' => array(),
|
||||
);
|
||||
|
||||
if ($report['from'] >= self::TARGET_VERSION) {
|
||||
$report['skipped'] = true;
|
||||
return $report;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Series first: books.series_id must point at already-migrated rows.
|
||||
// ------------------------------------------------------------------
|
||||
$series = self::table('series');
|
||||
if (self::table_exists($series) && self::series_is_library_shape($series)) {
|
||||
try {
|
||||
$report['series_migrated'] = self::migrate_series($series);
|
||||
$report['series_legacy'] = true;
|
||||
} catch (Throwable $e) {
|
||||
$report['errors'][] = 'series: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Books.
|
||||
// ------------------------------------------------------------------
|
||||
$books = self::table('books');
|
||||
if (self::table_exists($books) && self::books_is_library_shape($books)) {
|
||||
try {
|
||||
$report['books_migrated'] = self::migrate_books($books);
|
||||
$report['books_legacy'] = true;
|
||||
} catch (Throwable $e) {
|
||||
$report['errors'][] = 'books: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Leave the rating cache alone. bd_ratings is keyed by book_id and the
|
||||
// migration preserves book IDs, so the rows stay valid. They are
|
||||
// folded into the catalogue's own rating_* columns below.
|
||||
// ------------------------------------------------------------------
|
||||
if (!$report['errors']) {
|
||||
self::fold_library_ratings_into_catalogue();
|
||||
|
||||
update_option(self::VERSION_OPTION, self::TARGET_VERSION, false);
|
||||
$report['version'] = self::TARGET_VERSION;
|
||||
}
|
||||
|
||||
if ($report['errors']) {
|
||||
$report['version'] = $report['from']; // not advanced; retried next time
|
||||
error_log('Umbra Bookshelf: schema migration failed - '
|
||||
. implode('; ', $report['errors']));
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-qualified table name.
|
||||
*
|
||||
* @param string $name Bare table name, e.g. 'books'.
|
||||
* @return string
|
||||
*/
|
||||
private static function table($name) {
|
||||
return Umbra_Database::table($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the table exist?
|
||||
*
|
||||
* @param string $table
|
||||
* @return bool
|
||||
*/
|
||||
private static function table_exists($table) {
|
||||
global $wpdb;
|
||||
$found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
|
||||
return $found === $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column names for a table.
|
||||
*
|
||||
* @param string $table
|
||||
* @return array
|
||||
*/
|
||||
private static function columns($table) {
|
||||
global $wpdb;
|
||||
return (array) $wpdb->get_col("DESCRIBE {$table}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Is bd_books still in the old library shape?
|
||||
*
|
||||
* Library shape is identified by post_id and the absence of title.
|
||||
*
|
||||
* @param string $table
|
||||
* @return bool
|
||||
*/
|
||||
private static function books_is_library_shape($table) {
|
||||
$cols = self::columns($table);
|
||||
return in_array('post_id', $cols, true) && !in_array('title', $cols, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is bd_series still in the old library shape?
|
||||
*
|
||||
* Library shape has slug + blurb; catalogue shape has description.
|
||||
*
|
||||
* @param string $table
|
||||
* @return bool
|
||||
*/
|
||||
private static function series_is_library_shape($table) {
|
||||
$cols = self::columns($table);
|
||||
return in_array('blurb', $cols, true) && !in_array('description', $cols, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the legacy table aside and create a canonical one.
|
||||
*
|
||||
* @param string $table
|
||||
* @param string $label For logging.
|
||||
* @return string Legacy table name.
|
||||
*/
|
||||
private static function set_aside($table, $label) {
|
||||
global $wpdb;
|
||||
|
||||
$legacy = $table . self::LEGACY_SUFFIX;
|
||||
|
||||
// Never clobber an existing backup from a previous attempt.
|
||||
if (self::table_exists($legacy)) {
|
||||
$legacy = $table . self::LEGACY_SUFFIX . '_' . gmdate('YmdHis');
|
||||
}
|
||||
|
||||
$wpdb->query("RENAME TABLE {$table} TO {$legacy}");
|
||||
error_log("Umbra Bookshelf: {$label} legacy schema set aside as {$legacy}");
|
||||
|
||||
return $legacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate series rows.
|
||||
*
|
||||
* ID is preserved so books.series_id keeps resolving.
|
||||
*
|
||||
* @param string $table Legacy bd_series.
|
||||
* @return int Rows copied.
|
||||
*/
|
||||
private static function migrate_series($table) {
|
||||
global $wpdb;
|
||||
|
||||
$legacy = self::set_aside($table, 'series');
|
||||
|
||||
// Create the canonical shape.
|
||||
$database = new Umbra_Database();
|
||||
$database->create_tables();
|
||||
|
||||
$target = self::table('series');
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$legacy}", ARRAY_A);
|
||||
$copied = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = isset($row['name']) ? (string) $row['name'] : '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cover = '';
|
||||
if (!empty($row['cover_attachment_id'])) {
|
||||
$cover = (string) wp_get_attachment_url((int) $row['cover_attachment_id']);
|
||||
}
|
||||
|
||||
$inserted = $wpdb->insert(
|
||||
$target,
|
||||
array(
|
||||
'id' => (int) $row['id'],
|
||||
'name' => $name,
|
||||
// blurb was the library's prose field for a series.
|
||||
'description' => isset($row['blurb']) ? (string) $row['blurb'] : '',
|
||||
'cover_image' => $cover,
|
||||
'created_at' => !empty($row['created_at']) ? $row['created_at'] : current_time('mysql'),
|
||||
),
|
||||
array('%d', '%s', '%s', '%s', '%s')
|
||||
);
|
||||
|
||||
if ($inserted !== false) {
|
||||
$copied++;
|
||||
}
|
||||
}
|
||||
|
||||
return $copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate book rows.
|
||||
*
|
||||
* The library row is a thin pointer at a WordPress post, so title, slug and
|
||||
* body are resolved from that post. IDs are preserved, which keeps
|
||||
* bd_book_meta, bd_reviews, bd_ratings and bd_review_shortlinks valid, and
|
||||
* lets us point _bd_bookshelf_book_id at the catalogue row.
|
||||
*
|
||||
* @param string $table Legacy bd_books.
|
||||
* @return int Rows copied.
|
||||
*/
|
||||
private static function migrate_books($table) {
|
||||
global $wpdb;
|
||||
|
||||
$legacy = self::set_aside($table, 'books');
|
||||
|
||||
$database = new Umbra_Database();
|
||||
$database->create_tables();
|
||||
|
||||
$target = self::table('books');
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$legacy}", ARRAY_A);
|
||||
$copied = 0;
|
||||
$used_slugs = array();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$post_id = isset($row['post_id']) ? (int) $row['post_id'] : 0;
|
||||
$post = $post_id ? get_post($post_id) : null;
|
||||
|
||||
$title = $post ? (string) $post->post_title : '';
|
||||
if ($title === '') {
|
||||
// Nothing to name the book after; skip rather than create a
|
||||
// row that cannot satisfy title NOT NULL meaningfully.
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = $post ? (string) $post->post_name : '';
|
||||
$slug = $slug !== '' ? $slug : sanitize_title($title);
|
||||
|
||||
// Catalogue slug is NOT NULL UNIQUE.
|
||||
$base = $slug;
|
||||
$suffix = 1;
|
||||
while (in_array($slug, $used_slugs, true)) {
|
||||
$slug = $base . '-' . (++$suffix);
|
||||
}
|
||||
$used_slugs[] = $slug;
|
||||
|
||||
$book_id = isset($row['id']) ? (int) $row['id'] : 0;
|
||||
|
||||
// The library stored one ISBN and one ASIN with no format attached.
|
||||
// Both land on the ebook columns, which is the least lossy guess.
|
||||
$isbn = isset($row['isbn']) ? (string) $row['isbn'] : '';
|
||||
$asin = isset($row['asin']) ? (string) $row['asin'] : '';
|
||||
|
||||
$inserted = $wpdb->insert(
|
||||
$target,
|
||||
array(
|
||||
'id' => $book_id,
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'description' => $post ? (string) $post->post_content : '',
|
||||
'cover_image' => $post ? (string) get_the_post_thumbnail_url($post_id, 'large') : '',
|
||||
'isbn_ebook' => $isbn,
|
||||
'asin_ebook' => $asin,
|
||||
// Retained so existing Amazon product links keep resolving.
|
||||
'amazon_asin' => $asin,
|
||||
'series_id' => isset($row['series_id']) ? (int) $row['series_id'] : null,
|
||||
'created_at' => !empty($row['created_at']) ? $row['created_at'] : current_time('mysql'),
|
||||
),
|
||||
array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s')
|
||||
);
|
||||
|
||||
if ($inserted === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$copied++;
|
||||
|
||||
// Record the link the library used to imply with its post_id
|
||||
// UNIQUE column. Preserved meta key.
|
||||
if ($post_id && $book_id) {
|
||||
update_post_meta($post_id, '_bd_bookshelf_book_id', $book_id);
|
||||
}
|
||||
|
||||
// Publish date lived only on the library row; the catalogue has no
|
||||
// column for it, so keep it on the post for the theme to read.
|
||||
if ($post_id && !empty($row['publish_date'])) {
|
||||
update_post_meta($post_id, '_bd_bookshelf_publish_date', $row['publish_date']);
|
||||
}
|
||||
}
|
||||
|
||||
return $copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy bd_ratings values into the catalogue's own rating columns.
|
||||
*
|
||||
* Safe to call more than once: it only writes where the catalogue column
|
||||
* is still at its zero default.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function fold_library_ratings_into_catalogue() {
|
||||
global $wpdb;
|
||||
|
||||
$ratings = $wpdb->prefix . 'bd_ratings';
|
||||
$books = self::table('books');
|
||||
|
||||
if (!self::table_exists($ratings) || !self::table_exists($books)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
"SELECT book_id, source, rating FROM {$ratings}", ARRAY_A
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$book_id = (int) $row['book_id'];
|
||||
$rating = (float) $row['rating'];
|
||||
if ($rating <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = strtolower((string) $row['source']);
|
||||
|
||||
if (strpos($source, 'goodreads') !== false) {
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$books} SET rating_goodreads = %f
|
||||
WHERE id = %d AND rating_goodreads = 0",
|
||||
$rating, $book_id
|
||||
));
|
||||
} elseif (strpos($source, 'amazon') !== false) {
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$books} SET rating_amazon = %f
|
||||
WHERE id = %d AND rating_amazon = 0",
|
||||
$rating, $book_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable status, for the admin and for tests.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function status() {
|
||||
$books = self::table('books');
|
||||
$series = self::table('series');
|
||||
|
||||
return array(
|
||||
'version' => (int) get_option(self::VERSION_OPTION, 0),
|
||||
'target_version' => self::TARGET_VERSION,
|
||||
'books_exists' => self::table_exists($books),
|
||||
'books_shape' => self::table_exists($books)
|
||||
? (self::books_is_library_shape($books) ? 'library' : 'catalogue')
|
||||
: 'absent',
|
||||
'series_exists' => self::table_exists($series),
|
||||
'series_shape' => self::table_exists($series)
|
||||
? (self::series_is_library_shape($series) ? 'library' : 'catalogue')
|
||||
: 'absent',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Email Admin Class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Email Installation and Database
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Email Sender Class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Main SimpleEmail Plugin Class
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Main Plugin Class
|
||||
@@ -54,9 +54,10 @@ class Umbra_Bookshelf_Helper {
|
||||
// Schedule cron jobs on init
|
||||
add_action('init', array($this, 'schedule_cron_events'));
|
||||
|
||||
// Cron callbacks
|
||||
add_action('bd_bookshelf_goodreads_refresh', array($this, 'refresh_goodreads_data'));
|
||||
add_action('bd_bookshelf_amazon_scrape', array($this, 'scrape_amazon_ratings'));
|
||||
// Rating refresh is owned by Umbra_Rating_Sync (bd_bookshelf_sync_ratings),
|
||||
// which drives Umbra_Goodreads_Scraper. The former
|
||||
// bd_bookshelf_goodreads_refresh and bd_bookshelf_amazon_scrape hooks
|
||||
// pointed at empty stubs and have been removed.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,29 +171,20 @@ class Umbra_Bookshelf_Helper {
|
||||
|
||||
/**
|
||||
* Schedule cron events
|
||||
*
|
||||
* Rating refresh is owned solely by Umbra_Rating_Sync, which schedules the
|
||||
* single 'bd_bookshelf_sync_ratings' event (every 12 hours) and drives the
|
||||
* one surviving scraper, Umbra_Goodreads_Scraper.
|
||||
*
|
||||
* The former 'bd_bookshelf_goodreads_refresh' (daily) and
|
||||
* 'bd_bookshelf_amazon_scrape' (six-hourly) events are no longer scheduled.
|
||||
* Both pointed at empty stub methods, so each tick only fired HTTP-free
|
||||
* no-ops -- and the Amazon one duplicated work the rating sync already did.
|
||||
* Existing installations still get them cleared on deactivation and on
|
||||
* uninstall, so no stale schedules are left behind.
|
||||
*/
|
||||
public function schedule_cron_events() {
|
||||
if (!wp_next_scheduled('bd_bookshelf_goodreads_refresh')) {
|
||||
wp_schedule_event(time(), 'daily', 'bd_bookshelf_goodreads_refresh');
|
||||
}
|
||||
|
||||
if (!wp_next_scheduled('bd_bookshelf_amazon_scrape')) {
|
||||
wp_schedule_event(time(), 'six_hours', 'bd_bookshelf_amazon_scrape');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Goodreads data
|
||||
*/
|
||||
public function refresh_goodreads_data() {
|
||||
// Implementation for Goodreads API refresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape Amazon ratings
|
||||
*/
|
||||
public function scrape_amazon_ratings() {
|
||||
// Implementation for Amazon rating scrape
|
||||
// Intentionally empty: see the note above.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Admin Class
|
||||
@@ -1132,8 +1132,7 @@ class Umbra_Bookshelf_Helper_Admin {
|
||||
echo '<p class="description">' . esc_html__('Default: 30469651 (B_D_Murphy)', 'umbra-bookshelf') . '</p></td></tr>';
|
||||
echo '</table>';
|
||||
|
||||
echo '<h2>' . esc_html__('Amazon Ratings Warning', 'umbra-bookshelf') . '</h2>';
|
||||
echo '<p style="color:#d63638;">' . esc_html__('Warning: Scraping Amazon product pages may conflict with their Terms of Service. Use at your own risk.', 'umbra-bookshelf') . '</p>';
|
||||
echo '<p class="description">' . esc_html__('Ratings are refreshed every 12 hours from Goodreads. Amazon ratings are not scraped.', 'umbra-bookshelf') . '</p>';
|
||||
|
||||
submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings');
|
||||
echo '</form></div>';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Installation and Database Setup
|
||||
@@ -34,26 +34,23 @@ class Umbra_Bookshelf_Helper_Install {
|
||||
|
||||
/**
|
||||
* Create database tables
|
||||
*
|
||||
* {prefix}bd_books and {prefix}bd_series are NOT created here. Those two
|
||||
* table names are owned by the catalogue module (Umbra_Database), whose
|
||||
* schema is canonical. This module previously created them with a
|
||||
* different column set, so activating both plugins meant one silently
|
||||
* created the table and the other read it against the wrong shape.
|
||||
*
|
||||
* Existing library-shaped installations are reconciled by
|
||||
* Umbra_Schema_Migration, which runs before this method.
|
||||
*
|
||||
* @see \Umbra_Schema_Migration::maybe_migrate()
|
||||
*/
|
||||
public static function create_tables() {
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$tables = array(
|
||||
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_books (
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id BIGINT(20) NOT NULL,
|
||||
pen_name_id BIGINT(20),
|
||||
series_id BIGINT(20),
|
||||
volume_number INT(11),
|
||||
asin VARCHAR(20),
|
||||
isbn VARCHAR(20),
|
||||
publish_date DATE,
|
||||
sort_order INT(11) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY post_id (post_id)
|
||||
) $charset_collate;",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_book_meta (
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
book_id BIGINT(20) NOT NULL,
|
||||
@@ -65,17 +62,6 @@ class Umbra_Bookshelf_Helper_Install {
|
||||
UNIQUE KEY book_language (book_id, language_code)
|
||||
) $charset_collate;",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_series (
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
cover_attachment_id BIGINT(20),
|
||||
header_attachment_id BIGINT(20),
|
||||
blurb TEXT,
|
||||
display_order INT(11) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) $charset_collate;",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS {$wpdb->prefix}bd_pen_names (
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
pen_name VARCHAR(255) NOT NULL,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
32
umbra-bookshelf/tests/run_all.sh
Executable file
32
umbra-bookshelf/tests/run_all.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run every Umbra Bookshelf test suite.
|
||||
#
|
||||
# tests/run_all.sh
|
||||
#
|
||||
# Each runtime suite runs in its own PHP process: the plugin declares classes
|
||||
# at include time, so a front-end request and an admin request cannot share one.
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
fail=0
|
||||
|
||||
run() {
|
||||
local label="$1"; shift
|
||||
printf '\n=== %s ===\n' "$label"
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
fail=1
|
||||
}
|
||||
|
||||
run "static structure" python3 verify_structure.py
|
||||
run "runtime: front-end" php smoke_runtime.php front
|
||||
run "runtime: admin" php smoke_runtime.php admin
|
||||
run "schema migration" php schema_migration_test.php
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "SUITE FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL SUITES PASSED"
|
||||
423
umbra-bookshelf/tests/schema_migration_test.php
Normal file
423
umbra-bookshelf/tests/schema_migration_test.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit test for Umbra_Schema_Migration.
|
||||
*
|
||||
* The consolidation made the catalogue schema canonical for bd_books and
|
||||
* bd_series. Detecting the wrong shape, or dropping a row, would corrupt real
|
||||
* installations, so this exercises the migration against a real database
|
||||
* rather than asserting on strings.
|
||||
*
|
||||
* The database is a throwaway SQLite file driven through the sqlite3 CLI: no
|
||||
* credentials, no shared server, nothing to clean up but the file itself.
|
||||
* A minimal wpdb shim and a stand-in Umbra_Database are defined before the
|
||||
* migration is loaded, so neither the real WordPress nor the real
|
||||
* dbDelta-based installer is involved.
|
||||
*
|
||||
* Run: php umbra-bookshelf/tests/schema_migration_test.php
|
||||
*/
|
||||
|
||||
define('ABSPATH', '/tmp/fake-wp/');
|
||||
|
||||
// WordPress constants the migration relies on.
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
define('OBJECT', 'OBJECT');
|
||||
|
||||
// ---------------------------------------------------------------- wpdb shim
|
||||
class FakeWpdb {
|
||||
|
||||
public $prefix = 'wp_';
|
||||
public $file;
|
||||
public $inserted = array();
|
||||
public $renamed = array();
|
||||
public $meta = array();
|
||||
|
||||
public function __construct() {
|
||||
$this->file = tempnam(sys_get_temp_dir(), 'umbra-schema-') . '.db';
|
||||
// tempnam() creates the file; sqlite is happy to adopt it.
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
if ($this->file && is_file($this->file)) {
|
||||
@unlink($this->file);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bare table name, as the migration sees it. */
|
||||
public function bare($name) {
|
||||
return $this->prefix . 'bd_' . $name;
|
||||
}
|
||||
|
||||
// --- sqlite3 CLI plumbing --------------------------------------------
|
||||
|
||||
/**
|
||||
* Run a statement for effect.
|
||||
*/
|
||||
public function exec($sql) {
|
||||
$cmd = sprintf(
|
||||
'sqlite3 %s %s 2>&1',
|
||||
escapeshellarg($this->file),
|
||||
escapeshellarg($sql)
|
||||
);
|
||||
$out = shell_exec($cmd);
|
||||
if ($out !== null && trim($out) !== '') {
|
||||
throw new RuntimeException('sqlite3: ' . trim($out) . ' :: ' . $sql);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a query and decode rows as associative arrays.
|
||||
*/
|
||||
public function rows($sql) {
|
||||
$cmd = sprintf(
|
||||
'sqlite3 -json %s %s 2>&1',
|
||||
escapeshellarg($this->file),
|
||||
escapeshellarg($sql)
|
||||
);
|
||||
$out = trim((string) shell_exec($cmd));
|
||||
if ($out === '') {
|
||||
return array();
|
||||
}
|
||||
if ($out[0] !== '[') {
|
||||
// A missing table means an assertion is about to fail; report it as
|
||||
// no rows rather than tearing down the rest of the suite.
|
||||
if (stripos($out, 'no such table') !== false) {
|
||||
return array();
|
||||
}
|
||||
throw new RuntimeException('sqlite3: ' . $out . ' :: ' . $sql);
|
||||
}
|
||||
return json_decode($out, true) ?: array();
|
||||
}
|
||||
|
||||
/**
|
||||
* First column of the first row.
|
||||
*/
|
||||
public function scalar($sql) {
|
||||
$rows = $this->rows($sql);
|
||||
if (!$rows) {
|
||||
return null;
|
||||
}
|
||||
return array_values($rows[0])[0];
|
||||
}
|
||||
|
||||
public function table_exists($table) {
|
||||
return (bool) $this->scalar(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=" . $this->quote($table)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a value as a SQL literal.
|
||||
*/
|
||||
public function quote($value) {
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
return "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build "col = literal" pairs.
|
||||
*/
|
||||
private function assignments($data) {
|
||||
$parts = array();
|
||||
foreach ($data as $col => $value) {
|
||||
$parts[] = $col . ' = ' . $this->quote($value);
|
||||
}
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
// --- wpdb API surface used by the migration ---------------------------
|
||||
|
||||
public function prepare($query, ...$args) {
|
||||
$i = 0;
|
||||
return preg_replace_callback('/%[sdf]/', function ($m) use ($args, &$i) {
|
||||
$v = $args[$i++] ?? '';
|
||||
if ($m[0] === '%d') return (string) (int) $v;
|
||||
if ($m[0] === '%f') return (string) (float) $v;
|
||||
return "'" . str_replace("'", "''", (string) $v) . "'";
|
||||
}, $query);
|
||||
}
|
||||
|
||||
public function get_var($query) {
|
||||
if (stripos($query, 'SHOW TABLES LIKE') !== false) {
|
||||
preg_match("/LIKE\s+'([^']+)'/i", $query, $m);
|
||||
return $this->table_exists($m[1]) ? $m[1] : null;
|
||||
}
|
||||
return $this->scalar($query);
|
||||
}
|
||||
|
||||
public function get_col($query) {
|
||||
if (stripos($query, 'DESCRIBE') !== false) {
|
||||
preg_match('/DESCRIBE\s+(\S+)/i', $query, $m);
|
||||
$cols = array();
|
||||
foreach ($this->rows('PRAGMA table_info(' . $m[1] . ')') as $row) {
|
||||
$cols[] = $row['name'];
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
$cols = array();
|
||||
foreach ($this->rows($query) as $row) {
|
||||
$cols[] = array_values($row)[0];
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
|
||||
public function get_results($query, $output = null) {
|
||||
return $this->rows($query);
|
||||
}
|
||||
|
||||
public function query($query) {
|
||||
// Translate MySQL DDL to its SQLite equivalent. The migration targets
|
||||
// MySQL (that is what WordPress runs on); this shim only emulates it.
|
||||
if (preg_match('/^\s*RENAME\s+TABLE\s+(\S+)\s+TO\s+(\S+)\s*;?\s*$/i', $query, $m)) {
|
||||
$this->renamed[] = $query;
|
||||
return $this->exec("ALTER TABLE {$m[1]} RENAME TO {$m[2]}");
|
||||
}
|
||||
return $this->exec($query);
|
||||
}
|
||||
|
||||
public function insert($table, $data, $formats = null) {
|
||||
$this->inserted[] = array($table, $data);
|
||||
$cols = array_keys($data);
|
||||
$vals = array_map(array($this, 'quote'), array_values($data));
|
||||
$this->exec(
|
||||
'INSERT INTO ' . $table . ' (' . implode(',', $cols) . ') VALUES (' .
|
||||
implode(',', $vals) . ')'
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function update($table, $data, $where, $f = null, $wf = null) {
|
||||
$this->exec(
|
||||
'UPDATE ' . $table . ' SET ' . $this->assignments($data) .
|
||||
' WHERE ' . $this->assignments($where)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$GLOBALS['wpdb'] = new FakeWpdb();
|
||||
|
||||
// --- options store used by the migration ---------------------------------
|
||||
$GLOBALS['options'] = array();
|
||||
function get_option($k, $d = false) { return $GLOBALS['options'][$k] ?? $d; }
|
||||
function update_option($k, $v, $a = null) { $GLOBALS['options'][$k] = $v; return true; }
|
||||
|
||||
// --- WordPress helpers the migration calls ------------------------------
|
||||
function current_time($type) { return gmdate('Y-m-d H:i:s'); }
|
||||
function sanitize_title($t) {
|
||||
$t = strtolower(trim($t));
|
||||
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
|
||||
return trim($t, '-');
|
||||
}
|
||||
function update_post_meta($post_id, $key, $value) {
|
||||
$GLOBALS['wpdb']->meta[$post_id][$key] = $value;
|
||||
return true;
|
||||
}
|
||||
function wp_get_attachment_url($id) {
|
||||
return $id ? "https://example.test/wp-content/uploads/att-{$id}.jpg" : false;
|
||||
}
|
||||
function get_the_post_thumbnail_url($id, $size = '') {
|
||||
return "https://example.test/wp-content/uploads/thumb-{$id}.jpg";
|
||||
}
|
||||
|
||||
// --- posts the migration resolves titles/slugs/bodies from ---------------
|
||||
$GLOBALS['posts'] = array(
|
||||
101 => (object) array('ID' => 101, 'post_title' => 'The Silent Harbour', 'post_name' => 'the-silent-harbour', 'post_content' => 'A novel of tide and memory.'),
|
||||
102 => (object) array('ID' => 102, 'post_title' => 'Harbour Lights', 'post_name' => 'harbour-lights', 'post_content' => 'Sequel.'),
|
||||
);
|
||||
function get_post($id) { return $GLOBALS['posts'][$id] ?? null; }
|
||||
|
||||
// --- stand-in for the catalogue installer --------------------------------
|
||||
// The real one shells out to dbDelta(); here it just builds the canonical
|
||||
// schema so the copy step has somewhere to write.
|
||||
class Umbra_Database {
|
||||
public static function table($name) { return $GLOBALS['wpdb']->bare($name); }
|
||||
|
||||
public function create_tables() {
|
||||
$w = $GLOBALS['wpdb'];
|
||||
if (!$w->table_exists(self::table('books'))) {
|
||||
$w->exec('CREATE TABLE ' . self::table('books') . ' (
|
||||
id INTEGER PRIMARY KEY, title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT, cover_image TEXT, isbn_ebook TEXT, asin_ebook TEXT,
|
||||
amazon_asin TEXT, goodreads_id TEXT,
|
||||
rating_amazon REAL DEFAULT 0, rating_goodreads REAL DEFAULT 0,
|
||||
last_sync TEXT, series_id INTEGER,
|
||||
created_at TEXT, updated_at TEXT)');
|
||||
}
|
||||
if (!$w->table_exists(self::table('series'))) {
|
||||
$w->exec('CREATE TABLE ' . self::table('series') . ' (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
||||
cover_image TEXT, created_at TEXT)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/includes/class-umbra-schema-migration.php';
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
$fail = 0;
|
||||
$pass = 0;
|
||||
function check($label, $cond, $detail = '') {
|
||||
global $fail, $pass;
|
||||
if ($cond) { $pass++; echo " ok $label\n"; }
|
||||
else { $fail++; echo " FAIL $label" . ($detail ? " ($detail)" : '') . "\n"; }
|
||||
}
|
||||
function section($t) { echo "\n$t\n"; }
|
||||
|
||||
/** Build a library-shaped installation. */
|
||||
function seed_library_shape() {
|
||||
$w = $GLOBALS['wpdb'];
|
||||
foreach (array('books', 'series', 'ratings', 'book_meta',
|
||||
'books_legacy_library', 'series_legacy_library') as $t) {
|
||||
$w->exec('DROP TABLE IF EXISTS ' . $w->bare($t));
|
||||
}
|
||||
|
||||
$w->exec('CREATE TABLE ' . $w->bare('books') . ' (
|
||||
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL, pen_name_id INTEGER,
|
||||
series_id INTEGER, volume_number INTEGER, asin TEXT, isbn TEXT,
|
||||
publish_date TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT)');
|
||||
$w->exec('CREATE TABLE ' . $w->bare('series') . ' (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE,
|
||||
cover_attachment_id INTEGER, header_attachment_id INTEGER,
|
||||
blurb TEXT, display_order INTEGER DEFAULT 0, created_at TEXT)');
|
||||
$w->exec('CREATE TABLE ' . $w->bare('ratings') . ' (
|
||||
id INTEGER PRIMARY KEY, book_id INTEGER, source TEXT, rating REAL,
|
||||
review_count INTEGER, last_updated TEXT)');
|
||||
$w->exec('CREATE TABLE ' . $w->bare('book_meta') . ' (
|
||||
id INTEGER PRIMARY KEY, book_id INTEGER, language_code TEXT, title TEXT)');
|
||||
|
||||
$b = $w->bare('books');
|
||||
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
|
||||
VALUES (1, 101, 1, 7, 1, 'B00TESTASIN', '9781234567897', '2024-03-01', 0, '2024-01-01 00:00:00')");
|
||||
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
|
||||
VALUES (2, 102, 1, 7, 2, NULL, '9781234567898', '2025-06-01', 1, '2025-01-01 00:00:00')");
|
||||
// row 3 points at a post that no longer exists
|
||||
$w->exec("INSERT INTO {$b} (id, post_id, pen_name_id, series_id, volume_number, asin, isbn, publish_date, sort_order, created_at)
|
||||
VALUES (3, 999, NULL, NULL, NULL, NULL, NULL, NULL, 2, '2025-02-01 00:00:00')");
|
||||
|
||||
$s = $w->bare('series');
|
||||
$w->exec("INSERT INTO {$s} (id, name, slug, cover_attachment_id, header_attachment_id, blurb, display_order, created_at)
|
||||
VALUES (7, 'The Harbour Cycle', 'the-harbour-cycle', 55, 56, 'Ships, storms, and the people who stay.', 0, '2024-01-01 00:00:00')");
|
||||
|
||||
$r = $w->bare('ratings');
|
||||
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (1, 'goodreads', 4.35, 1200, '2025-01-01 00:00:00')");
|
||||
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (1, 'amazon', 4.60, 3400, '2025-01-01 00:00:00')");
|
||||
$w->exec("INSERT INTO {$r} (book_id, source, rating, review_count, last_updated) VALUES (2, 'goodreads', 4.10, 400, '2025-01-01 00:00:00')");
|
||||
|
||||
$m = $w->bare('book_meta');
|
||||
$w->exec("INSERT INTO {$m} (book_id, language_code, title) VALUES (1, 'en', 'The Silent Harbour')");
|
||||
|
||||
$GLOBALS['options'] = array(); // fresh site
|
||||
$GLOBALS['wpdb']->meta = array();
|
||||
}
|
||||
|
||||
$w = $GLOBALS['wpdb'];
|
||||
|
||||
// ======================================================================
|
||||
section('1. Shape detection on a library-shaped install');
|
||||
seed_library_shape();
|
||||
$status = Umbra_Schema_Migration::status();
|
||||
check('bd_books detected as library shape', $status['books_shape'] === 'library', $status['books_shape']);
|
||||
check('bd_series detected as library shape', $status['series_shape'] === 'library', $status['series_shape']);
|
||||
check('schema version not yet advanced', $status['version'] === 0, (string) $status['version']);
|
||||
|
||||
section('2. Running the migration');
|
||||
$report = Umbra_Schema_Migration::maybe_migrate();
|
||||
check('no errors reported', empty($report['errors']), implode('; ', $report['errors']));
|
||||
check('books rows copied', $report['books_migrated'] === 2, (string) $report['books_migrated']);
|
||||
check('series rows copied', $report['series_migrated'] === 1, (string) $report['series_migrated']);
|
||||
check('schema version advanced to target', $report['version'] === Umbra_Schema_Migration::TARGET_VERSION);
|
||||
|
||||
section('3. Resulting schema is the catalogue schema');
|
||||
$status = Umbra_Schema_Migration::status();
|
||||
check('bd_books is now catalogue shape', $status['books_shape'] === 'catalogue', $status['books_shape']);
|
||||
check('bd_series is now catalogue shape', $status['series_shape'] === 'catalogue', $status['series_shape']);
|
||||
|
||||
section('4. Legacy tables preserved, not dropped');
|
||||
check('bd_books_legacy_library exists', $w->table_exists($w->bare('books_legacy_library')));
|
||||
check('bd_series_legacy_library exists', $w->table_exists($w->bare('series_legacy_library')));
|
||||
check('legacy books table still holds its 3 rows',
|
||||
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books_legacy_library')) === 3);
|
||||
|
||||
section('5. Book rows carried over');
|
||||
$books = $w->rows('SELECT * FROM ' . $w->bare('books') . ' ORDER BY id');
|
||||
check('two books present', count($books) === 2, (string) count($books));
|
||||
|
||||
$b1 = $books[0];
|
||||
check('id preserved (1)', (int) $b1['id'] === 1, $b1['id']);
|
||||
check('title resolved from the post', $b1['title'] === 'The Silent Harbour', $b1['title']);
|
||||
check('slug taken from post_name', $b1['slug'] === 'the-silent-harbour', $b1['slug']);
|
||||
check('description resolved from post_content', $b1['description'] === 'A novel of tide and memory.', $b1['description']);
|
||||
check('isbn mapped onto isbn_ebook', $b1['isbn_ebook'] === '9781234567897', (string) $b1['isbn_ebook']);
|
||||
check('asin mapped onto asin_ebook', $b1['asin_ebook'] === 'B00TESTASIN', (string) $b1['asin_ebook']);
|
||||
check('amazon_asin retained for link building', $b1['amazon_asin'] === 'B00TESTASIN', (string) $b1['amazon_asin']);
|
||||
check('series_id preserved', (int) $b1['series_id'] === 7, (string) $b1['series_id']);
|
||||
|
||||
$ids = array_map(function ($r) { return (int) $r['id']; }, $books);
|
||||
check('orphan row (missing post) skipped, not crashed', !in_array(3, $ids, true), implode(',', $ids));
|
||||
check('legacy row count preserved for recovery',
|
||||
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books_legacy_library')) === 3);
|
||||
|
||||
section('6. Ratings folded into catalogue columns');
|
||||
check('goodreads rating mapped', (float) $b1['rating_goodreads'] === 4.35, (string) $b1['rating_goodreads']);
|
||||
check('amazon rating preserved (column retained)', (float) $b1['rating_amazon'] === 4.60, (string) $b1['rating_amazon']);
|
||||
$b2 = $books[1];
|
||||
check('second book goodreads rating mapped', (float) $b2['rating_goodreads'] === 4.10, (string) $b2['rating_goodreads']);
|
||||
|
||||
section('7. Post linkage written with the preserved meta key');
|
||||
check('_bd_bookshelf_book_id set for post 101', ($w->meta[101]['_bd_bookshelf_book_id'] ?? null) == 1);
|
||||
check('_bd_bookshelf_book_id set for post 102', ($w->meta[102]['_bd_bookshelf_book_id'] ?? null) == 2);
|
||||
check('publish date preserved on the post',
|
||||
($w->meta[101]['_bd_bookshelf_publish_date'] ?? null) === '2024-03-01',
|
||||
$w->meta[101]['_bd_bookshelf_publish_date'] ?? 'unset');
|
||||
|
||||
section('8. Series migrated');
|
||||
$series = $w->rows('SELECT * FROM ' . $w->bare('series'));
|
||||
check('one series row', count($series) === 1);
|
||||
check('series id preserved', (int) $series[0]['id'] === 7, $series[0]['id']);
|
||||
check('series name preserved', $series[0]['name'] === 'The Harbour Cycle', $series[0]['name']);
|
||||
check('blurb mapped to description',
|
||||
$series[0]['description'] === 'Ships, storms, and the people who stay.',
|
||||
$series[0]['description']);
|
||||
check('cover attachment resolved to a URL',
|
||||
strpos((string) $series[0]['cover_image'], 'att-55') !== false,
|
||||
(string) $series[0]['cover_image']);
|
||||
|
||||
section('9. Idempotency');
|
||||
$again = Umbra_Schema_Migration::maybe_migrate();
|
||||
check('second run is skipped', !empty($again['skipped']));
|
||||
check('no rows duplicated on re-run',
|
||||
(int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books')) === 2);
|
||||
|
||||
section('10. An already-catalogue-shaped install is left alone');
|
||||
// simulate an older site that never recorded the migration, but whose tables
|
||||
// are already canonical
|
||||
$w->exec('DROP TABLE IF EXISTS ' . $w->bare('books_legacy_library'));
|
||||
$w->exec('DROP TABLE IF EXISTS ' . $w->bare('series_legacy_library'));
|
||||
$GLOBALS['options'] = array();
|
||||
$before_renames = count($w->renamed);
|
||||
$third = Umbra_Schema_Migration::maybe_migrate();
|
||||
check('no rows copied from an already-correct schema', $third['books_migrated'] === 0, (string) $third['books_migrated']);
|
||||
check('no rename performed', count($w->renamed) === $before_renames);
|
||||
check('books untouched', (int) $w->scalar('SELECT COUNT(*) FROM ' . $w->bare('books')) === 2);
|
||||
|
||||
section('11. Duplicate slugs are resolved (catalogue slug is UNIQUE)');
|
||||
seed_library_shape();
|
||||
$GLOBALS['posts'][101]->post_name = 'harbour-lights';
|
||||
$GLOBALS['posts'][102]->post_name = 'harbour-lights';
|
||||
$collide = Umbra_Schema_Migration::maybe_migrate();
|
||||
check('collision run reported no errors', empty($collide['errors']), implode('; ', $collide['errors']));
|
||||
$slugs = $w->rows('SELECT slug FROM ' . $w->bare('books') . ' ORDER BY id');
|
||||
$slugs = array_map(function ($r) { return $r['slug']; }, $slugs);
|
||||
check('both books still present', count($slugs) === 2, implode(',', $slugs));
|
||||
check('slugs are unique', count($slugs) === count(array_unique($slugs)), implode(',', $slugs));
|
||||
|
||||
section('RESULT');
|
||||
echo "\n";
|
||||
if ($fail) { echo "FAIL - {$fail} check(s) failed, {$pass} passed\n"; exit(1); }
|
||||
echo "PASS - {$pass} checks, schema migration verified against a real database\n";
|
||||
@@ -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'));
|
||||
|
||||
@@ -41,8 +41,10 @@ for f in php:
|
||||
print('1. php -l: %d files checked' % len(php))
|
||||
|
||||
# ------------------------------------------------- 2. duplicate class decls
|
||||
# Shipped source only: tests/ defines intentional stubs of real classes.
|
||||
source_php = [f for f in php if os.sep + 'tests' + os.sep not in f + os.sep]
|
||||
decls = {}
|
||||
for f in php:
|
||||
for f in source_php:
|
||||
src = io.open(f, encoding='utf-8', errors='replace').read()
|
||||
for m in re.finditer(r'^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)',
|
||||
src, re.M):
|
||||
@@ -55,7 +57,7 @@ print('2. class declarations: %d unique, %d duplicated'
|
||||
|
||||
# --------------------------------------------- 3. referenced classes exist
|
||||
refs = set()
|
||||
for f in php:
|
||||
for f in source_php:
|
||||
src = io.open(f, encoding='utf-8', errors='replace').read()
|
||||
for m in re.finditer(r'\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(', src):
|
||||
refs.add(m.group(1))
|
||||
@@ -70,7 +72,9 @@ wp_provided = {'BD_BookShelfHelper', # created via class_alias()
|
||||
known_external = {'GeminiClient',
|
||||
# browser JS globals, matched by the `new X(` heuristic
|
||||
# when scanning inline <script> blocks inside PHP files
|
||||
'FormData', 'URL', 'Image', 'Audio'}
|
||||
'FormData', 'URL', 'Image', 'Audio',
|
||||
# SPL / core exceptions
|
||||
'RuntimeException', 'InvalidArgumentException', 'LogicException'}
|
||||
missing = sorted(r for r in refs
|
||||
if r not in decls
|
||||
and r not in wp_provided
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Plugin Name: Umbra Bookshelf
|
||||
* Plugin URI: https://umbratools.com/plugins/umbra-bookshelf
|
||||
* Description: Complete book management for authors and publishers: catalogue and ISBN tracking, series and pen names, retail links, rating synchronisation, GDPR consent, data entry, migration tooling, and newsletter delivery.
|
||||
* Version: 1.0.0
|
||||
* Version: 1.1.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: Umbra Tools
|
||||
* Author URI: https://umbratools.com
|
||||
* License: Apache-2.0
|
||||
* License URI: https://www.apache.org/licenses/LICENSE-2.0
|
||||
* License: GPLv2 or later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: umbra-bookshelf
|
||||
* Domain Path: /languages
|
||||
*
|
||||
@@ -34,7 +34,7 @@ if (!defined('ABSPATH')) {
|
||||
/**
|
||||
* Plugin constants
|
||||
*/
|
||||
define('UMBRA_BOOKSHELF_VERSION', '1.0.0');
|
||||
define('UMBRA_BOOKSHELF_VERSION', '1.1.0');
|
||||
define('UMBRA_BOOKSHELF_FILE', __FILE__);
|
||||
define('UMBRA_BOOKSHELF_DIR', plugin_dir_path(__FILE__));
|
||||
define('UMBRA_BOOKSHELF_URL', plugin_dir_url(__FILE__));
|
||||
@@ -54,6 +54,9 @@ define('UMBRA_BOOKSHELF_EMAIL_VERSION', '2.0.0');
|
||||
* Load module files
|
||||
*/
|
||||
|
||||
// Schema reconciliation (catalogue tables are canonical; runs before dbDelta)
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/class-umbra-schema-migration.php';
|
||||
|
||||
// Books / catalogue module (ex-BD Bookshelf)
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-database.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-book-manager.php';
|
||||
@@ -65,7 +68,6 @@ require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-shortcodes.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-seo.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-gdpr.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-goodreads-scraper.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-amazon-scraper.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-admin.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-migration.php';
|
||||
require_once UMBRA_BOOKSHELF_DIR . 'includes/books/class-umbra-theme-compatibility.php';
|
||||
@@ -140,6 +142,25 @@ final class Umbra_Bookshelf {
|
||||
// means those init callbacks are added while 'init' is already being
|
||||
// dispatched, so whether they run becomes an ordering accident.
|
||||
add_action('plugins_loaded', [$this, 'init_modules']);
|
||||
|
||||
// Plugin *updates* do not re-run the activation hook, so a site
|
||||
// upgrading from the old plugins would never be reconciled. Check on
|
||||
// admin_init instead: cheap (one option read), and keeps schema work
|
||||
// off front-end requests.
|
||||
add_action('admin_init', [$this, 'maybe_upgrade_schema'], 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a pre-consolidation schema on plugin update.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_upgrade_schema() {
|
||||
$version = (int) get_option(Umbra_Schema_Migration::VERSION_OPTION, 0);
|
||||
|
||||
if ($version < Umbra_Schema_Migration::TARGET_VERSION) {
|
||||
Umbra_Schema_Migration::maybe_migrate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,6 +275,10 @@ final class Umbra_Bookshelf {
|
||||
new Umbra_GDPR();
|
||||
new Umbra_Rating_Sync();
|
||||
|
||||
// SEO metadata for book detail pages: per-book title/description/
|
||||
// keywords, Open Graph, Twitter cards and sitemap weighting.
|
||||
new Umbra_SEO();
|
||||
|
||||
// Library module (post type, taxonomy, shortcodes, pen names)
|
||||
Umbra_Bookshelf_Helper::instance();
|
||||
|
||||
@@ -271,12 +296,15 @@ final class Umbra_Bookshelf {
|
||||
/**
|
||||
* Plugin activation
|
||||
*
|
||||
* Runs each module's installer. See docs/KNOWN-ISSUES.md item 1: the
|
||||
* catalogue and library modules both create bd_books / bd_series with
|
||||
* different schemas, which is a pre-existing conflict carried over from
|
||||
* the separate plugins and deliberately not resolved in this release.
|
||||
* Reconciles a pre-consolidation schema first, then creates tables. See
|
||||
* Umbra_Schema_Migration for why the ordering matters.
|
||||
*/
|
||||
public function activate() {
|
||||
// Reconcile any pre-consolidation schema BEFORE dbDelta runs, so the
|
||||
// catalogue's dbDelta() sees either a correct catalogue table or none
|
||||
// at all -- never a library-shaped table it would graft columns onto.
|
||||
Umbra_Schema_Migration::maybe_migrate();
|
||||
|
||||
$database = new Umbra_Database();
|
||||
$database->create_tables();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Copyright 2026 Umbra Tools (umbratools.com)
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/**
|
||||
* Umbra Bookshelf Uninstall
|
||||
|
||||
Reference in New Issue
Block a user