Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd78e587a | ||
|
|
da548075f0 | ||
| cb27ece558 | |||
|
|
5b5303186a | ||
|
|
43b9725cd0 |
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
@@ -1,2 +0,0 @@
|
||||
Umbra Tools (https://umbratools.com)
|
||||
Copyright 2026 Umbra Tools (umbratools.com)
|
||||
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Umbra Bookshelf
|
||||
|
||||
A WordPress plugin to display and manage books for users in multiple languages:
|
||||
catalogue, ISBN tracking, series, pen names, retailer links, rating
|
||||
synchronisation, and newsletter delivery.
|
||||
|
||||
Umbra Bookshelf began as three separate plugins — **BD Bookshelf**,
|
||||
**BD BookShelfHelper** and **BD SimpleEmail** — and is now a single consolidated
|
||||
plugin. This repository continues the history of the original
|
||||
[Bookshelf](https://git.mini-eq/umbragit/Bookshelf) repository, which is now
|
||||
closed and archived.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `umbra-bookshelf/` | **The plugin.** Books, library and email modules. Start at [`umbra-bookshelf/readme.txt`](umbra-bookshelf/readme.txt). |
|
||||
| `theme/` | The `bd-bookshelf` WordPress theme. A separate artifact, deliberately **not** renamed in the consolidation. |
|
||||
| `archive/pre-umbra-releases/` | Release zips of the three pre-merge plugins. Superseded — do not install. |
|
||||
| `Old/` | Earlier plugin and theme snapshots, kept for reference. |
|
||||
| `docs/`, mockups, images, `.docx`/`.zip` specs | Design material carried over from the original repository. |
|
||||
|
||||
## Documentation
|
||||
|
||||
* [`umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md`](umbra-bookshelf/spec/SPEC_Umbra_Bookshelf_Plugin.md)
|
||||
— the authoritative specification: identity, naming rules, architecture,
|
||||
module responsibilities, admin menu map, lifecycle.
|
||||
* [`umbra-bookshelf/docs/ARCHITECTURE.md`](umbra-bookshelf/docs/ARCHITECTURE.md)
|
||||
— why the plugin is shaped this way.
|
||||
* [`umbra-bookshelf/docs/KNOWN-ISSUES.md`](umbra-bookshelf/docs/KNOWN-ISSUES.md)
|
||||
— defects carried over from the merge, including one deliberately deferred.
|
||||
* [`umbra-bookshelf/spec/archive/`](umbra-bookshelf/spec/archive/) — pre-merge
|
||||
specifications, retained for provenance only.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# static checks: syntax, duplicate classes, unresolved requires and assets,
|
||||
# leftover old identifiers, preserved storage contracts
|
||||
python3 umbra-bookshelf/tests/verify_structure.py
|
||||
|
||||
# runtime boot against stubbed WordPress (each context needs its own process)
|
||||
php umbra-bookshelf/tests/smoke_runtime.php front
|
||||
php umbra-bookshelf/tests/smoke_runtime.php admin
|
||||
```
|
||||
|
||||
## Upgrading from the old plugins
|
||||
|
||||
Umbra Bookshelf uses the same database tables, option keys, post meta, cron
|
||||
hooks and shortcodes as the three plugins it replaces, so existing data carries
|
||||
over without migration.
|
||||
|
||||
Install Umbra Bookshelf, then **deactivate** BD Bookshelf, BD BookShelfHelper
|
||||
and BD SimpleEmail. Do not delete them — deleting runs their uninstall routines,
|
||||
which drop your tables and options.
|
||||
|
||||
## Status
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 204 KiB |
@@ -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
|
||||
|
||||
|
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
@@ -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;
|
||||
@@ -85,120 +95,54 @@ class Umbra_Rating_Sync {
|
||||
if (!$book) {
|
||||
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);
|
||||
|
||||
// Keep last rating if fetch fails
|
||||
if ($goodreads_rating === false) {
|
||||
$goodreads_rating = $book->rating_goodreads;
|
||||
}
|
||||
|
||||
$scraper = new Umbra_Goodreads_Scraper();
|
||||
$details = $scraper->get_book_details($book->goodreads_id);
|
||||
|
||||
// A transport or parse failure comes back as an 'error' key. Keep the
|
||||
// last known rating rather than overwriting it with zero.
|
||||
if (!is_array($details) || !empty($details['error'])) {
|
||||
$message = (is_array($details) && !empty($details['error']))
|
||||
? $details['error']
|
||||
: 'unexpected response shape';
|
||||
error_log('Umbra Bookshelf: Goodreads fetch error - ' . $message);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update book ratings
|
||||
$table = Umbra_Database::table('books');
|
||||
|
||||
$rating = !empty($details['rating']) ? (float) $details['rating'] : 0.0;
|
||||
|
||||
$update = [
|
||||
'rating_goodreads' => $rating,
|
||||
'last_sync' => current_time('mysql'),
|
||||
];
|
||||
|
||||
// Goodreads also reports a cover and an ISBN-13. Adopt them only where
|
||||
// the catalogue has nothing, so local edits are never clobbered.
|
||||
if (!empty($details['cover_image']) && empty($book->cover_image)) {
|
||||
$update['cover_image'] = esc_url_raw($details['cover_image']);
|
||||
}
|
||||
if (!empty($details['isbn13']) && empty($book->isbn_ebook)) {
|
||||
$update['isbn_ebook'] = $details['isbn13'];
|
||||
}
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
[
|
||||
'rating_amazon' => $amazon_rating,
|
||||
'rating_goodreads' => $goodreads_rating,
|
||||
'last_sync' => current_time('mysql'),
|
||||
],
|
||||
Umbra_Database::table('books'),
|
||||
$update,
|
||||
['id' => $book_id],
|
||||
['%f', '%f', '%s'],
|
||||
null,
|
||||
['%d']
|
||||
);
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Amazon rating via web scraping
|
||||
*/
|
||||
private function fetch_amazon_rating($isbn) {
|
||||
$url = "https://www.amazon.com/dp/{$isbn}";
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 15,
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.5',
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
error_log('BD Bookshelf: Amazon fetch error - ' . $response->get_error_message());
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
|
||||
// Parse rating from HTML
|
||||
// Amazon uses data-hook="review-star-rating" or similar
|
||||
if (preg_match('/data-hook="review-star-rating"[^>]*>.*?(\d+\.?\d*)\s*out of\s*5/si', $body, $matches)) {
|
||||
return (float) $matches[1];
|
||||
}
|
||||
|
||||
// Alternative pattern
|
||||
if (preg_match('/a-icon-alt[^>]*>(\d+\.?\d*)\s*out of\s*\d+ stars/si', $body, $matches)) {
|
||||
return (float) $matches[1];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Goodreads rating via web scraping
|
||||
*/
|
||||
private function fetch_goodreads_rating($goodreads_id) {
|
||||
$url = "https://www.goodreads.com/book/show/{$goodreads_id}";
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 15,
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.5',
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
error_log('BD Bookshelf: Goodreads fetch error - ' . $response->get_error_message());
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
|
||||
// Parse rating from HTML
|
||||
// Goodreads uses ratingValue in meta or specific class
|
||||
if (preg_match('/ratingValue[^>]*>(\d+\.?\d*)</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
@@ -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,
|
||||
|
||||