release: BreznGEO v1.0.0 — initial release
Complete rebrand from Bavarian Rank Engine to BreznGEO. Fresh start at v1.0.0 with clean plugin identity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
90d4979b5d
67 changed files with 12092 additions and 0 deletions
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
32
.github/workflows/release.yml
vendored
Normal file
32
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create ZIP
|
||||||
|
run: zip -r bavarian-rank-engine.zip bavarian-rank-engine/
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: "Bavarian Rank Engine v${{ steps.version.outputs.version }}"
|
||||||
|
files: bavarian-rank-engine.zip
|
||||||
|
generate_release_notes: true
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
vendor/
|
||||||
|
.phpunit.result.cache
|
||||||
|
firebase-debug.log
|
||||||
|
composer.phar
|
||||||
|
*.zip
|
||||||
|
/node_modules/
|
||||||
|
.claude/
|
||||||
|
*.log
|
||||||
339
LICENSE
Normal file
339
LICENSE
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
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".
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 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 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) 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.)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
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, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
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 Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, 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.
|
||||||
391
README.de.md
Normal file
391
README.de.md
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
# Bavarian Rank Engine
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
🇬🇧 [English version → README.md](README.md)
|
||||||
|
|
||||||
|
**Website:** [bavarianrankengine.com](https://bavarianrankengine.com) · [How To](https://bavarianrankengine.com/howto.html) · [FAQ](https://bavarianrankengine.com/faq.html) · [Changelog](https://bavarianrankengine.com/changelog.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bavarian Rank Engine ist ein schlankes SEO- & GEO-Plugin für WordPress. Es generiert KI-Metabeschreibungen, gibt Schema.org-Strukturdaten aus, erstellt GEO-Inhaltsblöcke für KI-Engines und verwaltet den Crawler-Zugriff über robots.txt und llms.txt — alles in einem Plugin, ohne dass etwas hinter einer Paywall versteckt wird.
|
||||||
|
|
||||||
|
Es funktioniert mit oder ohne KI-Key. Es integriert sich ohne Konflikte in Rank Math, Yoast, AIOSEO und SEOPress. Kein SaaS. Keine Telemetrie. Keine Upsells.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum dieses Plugin existiert
|
||||||
|
|
||||||
|
Die meisten WordPress-SEO-Plugins haben sich in die gleiche Richtung entwickelt: aufgeblähte Feature-Sets, Dashboards voller Metriken, die niemand gebraucht hat, und ein Preismodell, das die nützlichen Funktionen hinter einem monatlichen Abo versteckt.
|
||||||
|
|
||||||
|
Die KI-Welle hat es schlimmer gemacht. Plugins fingen an, „KI-gestützte" Features anzubieten — aber als Proxy-Dienst. Man zahlt eine monatliche Gebühr, die Inhalte werden über deren Server geleitet, sie rufen die KI-API im eigenen Namen auf und schlagen eine Marge drauf.
|
||||||
|
|
||||||
|
BRE verfolgt einen anderen Ansatz:
|
||||||
|
|
||||||
|
- **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google oder xAI. BRE ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter.
|
||||||
|
- **Klarer Output, kein Lärm.** Metabeschreibungen, Strukturdaten, KI-Inhaltsblöcke für GEO, Bot-Steuerung. Keine Lesbarkeits-Scores, keine Keyword-Dichte-Meter, keine Upsell-Banner.
|
||||||
|
- **Keine Subscription.** GPL-2.0. Kostenlos auf beliebig vielen Sites nutzbar. Die einzigen Kosten sind die API-Nutzung — typischerweise Bruchteile eines Cents pro Beitrag.
|
||||||
|
- **Keine Telemetrie.** BRE sendet keine Daten nach Hause. Kein Usage-Tracking, kein Remote-Logging, keine Analytics, die den eigenen Server verlassen.
|
||||||
|
- **Funktioniert ohne KI.** Kein API-Key? Der Fallback-Extraktor erzeugt eine brauchbare Metabeschreibung aus dem Artikelinhalt per Satzgrenzenerkennung. Jeder Beitrag bekommt eine Beschreibung.
|
||||||
|
|
||||||
|
Entwickelt in Passau, Bayern — für [Donau2Space](https://donau2space.de), einen persönlichen KI-Blog, für den ich genau das gebraucht habe — und nichts mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhaltsverzeichnis
|
||||||
|
|
||||||
|
- [Warum dieses Plugin existiert](#warum-dieses-plugin-existiert)
|
||||||
|
- [Verzeichnisstruktur](#verzeichnisstruktur)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Datenspeicherung](#datenspeicherung)
|
||||||
|
- [Sicherheit](#sicherheit)
|
||||||
|
- [KI-Provider](#ki-provider)
|
||||||
|
- [Hooks & Erweiterbarkeit](#hooks--erweiterbarkeit)
|
||||||
|
- [AJAX-Schnittstellen](#ajax-schnittstellen)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Technischer Stack](#technischer-stack)
|
||||||
|
- [Lizenz](#lizenz)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
bavarian-rank-engine/
|
||||||
|
├── bavarian-rank-engine.php # Plugin-Header, Konstanten (BRE_VERSION, BRE_DIR, BRE_URL)
|
||||||
|
├── uninstall.php # Aufräumen bei Plugin-Löschung
|
||||||
|
├── assets/
|
||||||
|
│ ├── admin.css # Gemeinsames Admin-Stylesheet
|
||||||
|
│ ├── admin.js # Provider-Selektor, Verbindungstest
|
||||||
|
│ ├── bulk.js # Bulk-Generator AJAX-Loop + Progress-UI
|
||||||
|
│ ├── editor-meta.js # Meta Editor Box: Live-Zähler, KI-Regen-Button
|
||||||
|
│ ├── geo-editor.js # GEO Block Editor: Generieren / Löschen Button
|
||||||
|
│ ├── geo-frontend.css # Minimales Stylesheet für .bre-geo auf dem Frontend
|
||||||
|
│ ├── link-suggest.js # Interne Link-Vorschläge: Trigger, UI, Apply (Gutenberg + Classic)
|
||||||
|
│ └── seo-widget.js # SEO Analyse Widget: Live-Auswertung im Editor
|
||||||
|
├── includes/
|
||||||
|
│ ├── Core.php # Singleton-Bootstrap, lädt alle Abhängigkeiten
|
||||||
|
│ ├── Admin/
|
||||||
|
│ │ ├── AdminMenu.php # Menüstruktur + Dashboard-Render
|
||||||
|
│ │ ├── BulkPage.php # Bulk Generator Admin-Seite
|
||||||
|
│ │ ├── GeoEditorBox.php # GEO Block Meta-Box im Post-Editor
|
||||||
|
│ │ ├── GeoPage.php # GEO Block Einstellungsseite
|
||||||
|
│ │ ├── LinkAnalysis.php # AJAX-Handler für Link-Analyse Dashboard
|
||||||
|
│ │ ├── LinkSuggestPage.php # Einstellungsseite für interne Link-Vorschläge
|
||||||
|
│ │ ├── MetaEditorBox.php # Meta Description Meta-Box im Post-Editor
|
||||||
|
│ │ ├── MetaPage.php # Meta Generator Einstellungsseite
|
||||||
|
│ │ ├── ProviderPage.php # AI Provider Einstellungsseite
|
||||||
|
│ │ ├── SchemaMetaBox.php # Schema.org per-Post Meta-Box
|
||||||
|
│ │ ├── TxtPage.php # TXT-Dateien-Seite: llms.txt + robots.txt (Tabs)
|
||||||
|
│ │ ├── SchemaPage.php # Schema.org Einstellungsseite
|
||||||
|
│ │ ├── SeoWidget.php # SEO Analyse Sidebar Widget
|
||||||
|
│ │ ├── SettingsPage.php # Zentrales getSettings() — mergt alle Option-Keys
|
||||||
|
│ │ └── views/ # PHP-Templates für alle Admin-Seiten
|
||||||
|
│ ├── Features/
|
||||||
|
│ │ ├── CrawlerLog.php # KI-Bot-Besuche loggen (eigene DB-Tabelle)
|
||||||
|
│ │ ├── GeoBlock.php # GEO Quick Overview Block (Frontend-Ausgabe)
|
||||||
|
│ │ ├── LlmsTxt.php # /llms.txt Endpunkt mit ETag/Cache
|
||||||
|
│ │ ├── LinkSuggest.php # Interne Link-Vorschläge: Matching-Engine + AJAX-Handler + Meta-Box
|
||||||
|
│ │ ├── MetaGenerator.php # Kernlogik: KI-Aufruf, Speichern, Bulk, AJAX
|
||||||
|
│ │ ├── RobotsTxt.php # robots.txt Bot-Blocking via WP-Filter
|
||||||
|
│ │ └── SchemaEnhancer.php # JSON-LD Schema.org Ausgabe in wp_head
|
||||||
|
│ ├── Helpers/
|
||||||
|
│ │ ├── BulkQueue.php # Mutex-Lock für Bulk-Prozesse (Transient-basiert)
|
||||||
|
│ │ ├── FallbackMeta.php # Meta-Extraktion aus Post-Content ohne KI
|
||||||
|
│ │ ├── KeyVault.php # API-Key Verschleierung vor dem Schreiben in die DB
|
||||||
|
│ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk
|
||||||
|
│ └── Providers/
|
||||||
|
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
|
||||||
|
│ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen
|
||||||
|
│ ├── AnthropicProvider.php # Claude API (Messages API)
|
||||||
|
│ ├── GeminiProvider.php # Google Gemini (generateContent API)
|
||||||
|
│ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt)
|
||||||
|
│ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
|
||||||
|
└── vendor/ # Composer-Abhängigkeiten (nur Produktionsstand)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### AI Meta Generator
|
||||||
|
|
||||||
|
Generiert SEO-optimierte Meta-Beschreibungen (150–160 Zeichen) automatisch beim Veröffentlichen eines Beitrags. Der Prompt ist vollständig anpassbar; unterstützte Platzhalter: `{title}`, `{content}`, `{excerpt}`, `{language}`.
|
||||||
|
|
||||||
|
**Spracherkennung:** Die Zielsprache wird automatisch aus Polylang, WPML oder dem WordPress-Locale ermittelt und im Prompt übergeben — ohne Konfiguration.
|
||||||
|
|
||||||
|
**SEO-Plugin-Integration:** Generierte Beschreibungen landen nicht nur in `_bre_meta_description`, sondern auch direkt im nativen Feld des aktiven SEO-Plugins:
|
||||||
|
|
||||||
|
| SEO-Plugin | Meta-Feld |
|
||||||
|
|---|---|
|
||||||
|
| Rank Math | `rank_math_description` |
|
||||||
|
| Yoast SEO | `_yoast_wpseo_metadesc` |
|
||||||
|
| AIOSEO | `_aioseo_description` |
|
||||||
|
| SEOPress | `_seopress_titles_desc` |
|
||||||
|
| (keins aktiv) | BRE gibt `<meta name="description">` selbst aus |
|
||||||
|
|
||||||
|
**Token-Modus:** Wahlweise wird der gesamte Artikelinhalt gesendet (`full`) oder auf eine konfigurierbare Token-Anzahl (100–8000) gekürzt (`limit`). Das Kürzen erfolgt über `TokenEstimator` — eine wortbasierte Schätzung ohne externe Bibliothek.
|
||||||
|
|
||||||
|
**Fallback ohne KI:** `FallbackMeta::extract()` liefert immer eine brauchbare Beschreibung — auch ohne API-Key oder bei Fehlern. Vollständig multibyte-safe via `mb_substr` / `mb_strrpos`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GEO Block (Quick Overview)
|
||||||
|
|
||||||
|
Generiert KI-gestützte Inhaltsblöcke direkt im Artikeltext für Generative Engine Optimization:
|
||||||
|
|
||||||
|
- **Summary** — Kurzüberblick des Artikels
|
||||||
|
- **Key Points** — Stichpunktliste der wichtigsten Aussagen
|
||||||
|
- **FAQ** — Frage-Antwort-Paare (nur ab konfiguriertem Wort-Schwellenwert, Standard: 350 Wörter)
|
||||||
|
|
||||||
|
**Einfügeposition** (konfigurierbar): nach dem ersten Absatz (Standard), oben, unten.
|
||||||
|
|
||||||
|
**Ausgabe-Modi:**
|
||||||
|
|
||||||
|
| Modus | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `details_collapsible` | Natives HTML `<details>` — zugeklappt, kein JavaScript nötig |
|
||||||
|
| `open_always` | Block immer sichtbar |
|
||||||
|
| `store_only_no_frontend` | Nur in DB speichern, keine Frontend-Ausgabe (z.B. für FAQPage-Schema) |
|
||||||
|
|
||||||
|
Alle Labels, Akzentfarbe, Farbschema (Auto/Hell/Dunkel) und Custom CSS sind über die Admin-Seite konfigurierbar — ohne Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schema.org Enhancer
|
||||||
|
|
||||||
|
Gibt JSON-LD-Strukturdaten und Meta-Tags in `<head>` aus. Einstellungen unter **Bavarian Rank → Schema.org**. Jeder Typ ist einzeln aktivierbar:
|
||||||
|
|
||||||
|
| Typ | Schema.org-Type | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| `organization` | `Organization` | Name, URL, Logo, `sameAs`-Links |
|
||||||
|
| `author` | `Person` | Autorenname, Profil-URL, optionaler Twitter-`sameAs` |
|
||||||
|
| `speakable` | `WebPage` + `SpeakableSpecification` | CSS-Selektoren auf H1 und ersten Absatz |
|
||||||
|
| `article_about` | `Article` | Headline, Publish/Modified, Description, Publisher |
|
||||||
|
| `breadcrumb` | `BreadcrumbList` | Automatisch unterdrückt wenn Rank Math oder Yoast aktiv |
|
||||||
|
| `ai_meta_tags` | — | `<meta name="robots">` mit `max-snippet:-1` |
|
||||||
|
| `faq_schema` | `FAQPage` | Automatisch aus GEO Block Daten befüllt |
|
||||||
|
| `blog_posting` | `BlogPosting` / `Article` | Mit eingebettetem `author` und Featured Image |
|
||||||
|
| `image_object` | `ImageObject` | Featured Image mit Dimensionen und Caption |
|
||||||
|
| `video_object` | `VideoObject` | YouTube/Vimeo wird automatisch erkannt |
|
||||||
|
| `howto` | `HowTo` | Schrittweise Anleitung — Daten per Metabox |
|
||||||
|
| `review` | `Review` | Bewertung mit `ratingValue` — Daten per Metabox |
|
||||||
|
| `recipe` | `Recipe` | Zutaten, Zeiten, Nährwerte — Daten per Metabox |
|
||||||
|
| `event` | `Event` | Datum, Ort, Veranstalter — Daten per Metabox |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### llms.txt
|
||||||
|
|
||||||
|
Bedient `/llms.txt` und paginierte Folgedateien über einen `parse_request`-Hook mit Priorität 1 — vor WordPress-Routing, kein Rewrite-Rule-Flush nötig.
|
||||||
|
|
||||||
|
**HTTP-Caching:** ETag, Last-Modified, Cache-Control. Transient-Cache wird bei jeder Einstellungsänderung automatisch invalidiert.
|
||||||
|
|
||||||
|
**Rank Math Konfliktwarnung:** Falls Rank Math ebenfalls eine llms.txt ausliefern will, zeigt BRE einen Admin-Hinweis an — BRE hat wegen Priorität 1 automatisch Vorrang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### robots.txt Manager
|
||||||
|
|
||||||
|
Hängt `Disallow`-Blöcke über den WordPress-Filter `robots_txt` an — die WordPress-eigene robots.txt bleibt erhalten. 13 KI-Bots einzeln steuerbar: GPTBot, ClaudeBot, Google-Extended, PerplexityBot, CCBot, Applebot-Extended, Bytespider, DataForSeoBot, ImagesiftBot, omgili, Diffbot, FacebookBot, Amazonbot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bulk Generator
|
||||||
|
|
||||||
|
Batch-Verarbeitung aller veröffentlichten Beiträge ohne Meta-Beschreibung. Läuft als AJAX-Request im Browser — kein WP-Cron, keine CLI nötig. 1–20 Beiträge pro Batch, 6s Delay, bis zu 3 Versuche je Post, Mutex-Lock via Transient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Crawler Log
|
||||||
|
|
||||||
|
Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}bre_crawler_log` (bot_name, ip_hash SHA-256, url, visited_at). Einträge älter als 90 Tage werden automatisch bereinigt. Dashboard zeigt 30-Tage-Zusammenfassung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenspeicherung
|
||||||
|
|
||||||
|
### WordPress Options (wp_options)
|
||||||
|
|
||||||
|
| Option-Key | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `bre_settings` | Aktiver Provider, API-Keys (verschleiert), Modell-Auswahl, Token-Kosten, `ai_enabled`-Flag |
|
||||||
|
| `bre_meta_settings` | Meta Generator: Auto-Modus, Post-Types, Token-Modus, Prompt |
|
||||||
|
| `bre_schema_settings` | Schema.org: aktivierte Typen, Organization sameAs-URLs |
|
||||||
|
| `bre_geo_settings` | GEO Block: Modus, Position, Labels, CSS, Prompt, Farbschema |
|
||||||
|
| `bre_robots_settings` | robots.txt: blockierte Bots |
|
||||||
|
| `bre_llms_settings` | llms.txt: Titel, Beschreibung, Featured-Links, Footer, Seitenanzahl |
|
||||||
|
| `bre_usage_stats` | Akkumulierte Token-Nutzung: `tokens_in`, `tokens_out`, `count` |
|
||||||
|
| `bre_first_activated` | Unix-Timestamp der Erstaktivierung (für Welcome Notice) |
|
||||||
|
|
||||||
|
### Post Meta (wp_postmeta)
|
||||||
|
|
||||||
|
| Meta-Key | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `_bre_meta_description` | Generierte Meta-Beschreibung |
|
||||||
|
| `_bre_meta_source` | Quelle: `ai` / `fallback` / `manual` |
|
||||||
|
| `_bre_bulk_failed` | Letzter Fehler beim Bulk-Versuch |
|
||||||
|
| `_bre_geo_summary` | GEO Block Summary |
|
||||||
|
| `_bre_geo_bullets` | GEO Block Key Points (JSON-Array) |
|
||||||
|
| `_bre_geo_faq` | GEO Block FAQ (JSON-Array) |
|
||||||
|
|
||||||
|
### Transients
|
||||||
|
|
||||||
|
| Transient | TTL | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `bre_llms_cache_{n}` | 1 Stunde | Gecachter llms.txt Inhalt je Seite |
|
||||||
|
| `bre_link_analysis` | 1 Stunde | Dashboard Link-Analyse Ergebnis |
|
||||||
|
| `bre_bulk_running` | 15 Minuten | Mutex-Lock für den Bulk Generator |
|
||||||
|
| `bre_meta_stats` | 5 Minuten | Dashboard Meta-Coverage-Abfrage |
|
||||||
|
| `bre_crawler_summary` | 5 Minuten | Dashboard Crawler-Zusammenfassung (letzte 30 Tage) |
|
||||||
|
|
||||||
|
> **Uninstall:** `uninstall.php` löscht `bre_settings` und `_bre_meta_description` für alle Posts. Die übrigen Option-Keys und die `bre_crawler_log`-Tabelle müssen manuell gelöscht werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
### API-Key Verschleierung (KeyVault)
|
||||||
|
|
||||||
|
```
|
||||||
|
Klartextkey → XOR(key, sha256(AUTH_KEY . SECURE_AUTH_KEY)) → base64 → "bre1:<base64>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein `openssl_*` oder externe Extension nötig — läuft auf jeder PHP 8.0+ Installation. Das Präfix `bre1:` ermöglicht spätere Migration ohne Breaking Change.
|
||||||
|
|
||||||
|
**Sicherheitsgrenzen:** XOR mit statischem Salt ist Verschleierung, keine kryptografische Verschlüsselung. Für maximale Sicherheit können Keys als `wp-config.php`-Konstanten definiert werden:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define( 'BRE_OPENAI_KEY', 'sk-...' );
|
||||||
|
define( 'BRE_ANTHROPIC_KEY', 'sk-ant-...' );
|
||||||
|
define( 'BRE_GEMINI_KEY', 'AI...' );
|
||||||
|
define( 'BRE_GROK_KEY', 'xai-...' );
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSRF-Schutz und Capability Checks
|
||||||
|
|
||||||
|
Jeder AJAX-Handler ohne Ausnahme:
|
||||||
|
|
||||||
|
```php
|
||||||
|
check_ajax_referer( 'bre_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( 'Unauthorized', 403 );
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein `wp_ajax_nopriv_`-Handler — alle Endpunkte erfordern `manage_options`.
|
||||||
|
|
||||||
|
### CSS-Sanitierung (GEO Block)
|
||||||
|
|
||||||
|
Das Custom-CSS-Feld des GEO-Blocks wird durch `Helpers\Css::sanitize_declarations()` bereinigt — entfernt Kommentare, geschweifte Klammern, At-Regeln (`@import`, `@media` usw.), `url()`, `expression()` und `javascript:`, bevor die Ausgabe über `wp_add_inline_style()` injiziert wird.
|
||||||
|
|
||||||
|
### Datenschutz (DSGVO)
|
||||||
|
|
||||||
|
CrawlerLog speichert IPs ausschließlich als SHA-256-Hash. Originalwert wird nie persistiert. Einträge nach 90 Tagen automatisch gelöscht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KI-Provider
|
||||||
|
|
||||||
|
| Provider | Klasse | API-Basis-URL |
|
||||||
|
|---|---|---|
|
||||||
|
| OpenAI | `OpenAIProvider` | `https://api.openai.com/v1/chat/completions` |
|
||||||
|
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
|
||||||
|
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
|
||||||
|
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
|
||||||
|
|
||||||
|
Neuen Provider hinzufügen: `ProviderInterface` implementieren, in `Core.php` via `$registry->register()` eintragen — erscheint automatisch in allen Dropdowns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks & Erweiterbarkeit
|
||||||
|
|
||||||
|
### `bre_prompt` (Filter)
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_filter( 'bre_prompt', function( string $prompt, WP_Post $post ): string {
|
||||||
|
$keyword = get_post_meta( $post->ID, 'focus_keyword', true );
|
||||||
|
return $keyword ? $prompt . "\nFokus-Keyword: {$keyword}" : $prompt;
|
||||||
|
}, 10, 2 );
|
||||||
|
```
|
||||||
|
|
||||||
|
### `bre_meta_saved` (Action)
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_action( 'bre_meta_saved', function( int $post_id, string $description ): void {
|
||||||
|
my_cdn_purge( get_permalink( $post_id ) );
|
||||||
|
}, 10, 2 );
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AJAX-Schnittstellen
|
||||||
|
|
||||||
|
Alle Endpunkte erfordern `manage_options` (kein `nopriv`).
|
||||||
|
|
||||||
|
| Action | Handler | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `bre_regen_meta` | `MetaEditorBox::ajax_regen` | Meta-Beschreibung für einzelnen Post neu generieren |
|
||||||
|
| `bre_test_connection` | `ProviderPage::ajax_test_connection` | API-Key und Verbindung testen |
|
||||||
|
| `bre_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Standard-Prompt zurücksetzen |
|
||||||
|
| `bre_link_analysis` | `LinkAnalysis::ajax_analyse` | Link-Analyse ausführen |
|
||||||
|
| `bre_link_suggestions` | `LinkSuggest::ajax_suggest` | Top-10 interne Link-Vorschläge für aktuellen Beitrag zurückgeben |
|
||||||
|
| `bre_geo_generate` | `GeoEditorBox::ajax_generate` | GEO Block generieren |
|
||||||
|
| `bre_geo_clear` | `GeoEditorBox::ajax_clear` | GEO Block löschen |
|
||||||
|
| `bre_llms_clear_cache` | `TxtPage::ajax_clear_cache` | llms.txt Cache leeren |
|
||||||
|
| `bre_dismiss_llms_notice` | `LlmsTxt::ajax_dismiss_notice` | Rank-Math-Hinweis ausblenden |
|
||||||
|
| `bre_dismiss_welcome` | `AdminMenu::ajax_dismiss_welcome` | Welcome Notice per User ausblenden |
|
||||||
|
| `bre_bulk_generate` | `MetaGenerator::ajaxBulkGenerate` | Nächsten Batch verarbeiten |
|
||||||
|
| `bre_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Fortschritt abrufen |
|
||||||
|
| `bre_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Mutex-Lock manuell freigeben |
|
||||||
|
| `bre_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Lock-Status prüfen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Via GitHub Release (empfohlen):**
|
||||||
|
1. `bavarian-rank-engine.zip` vom [neuesten Release](https://github.com/noschmarrn/bavarianrankengine/releases/latest) herunterladen
|
||||||
|
2. In WordPress unter *Plugins → Installieren → Plugin hochladen* einspielen
|
||||||
|
|
||||||
|
**Manuell (clone):**
|
||||||
|
```bash
|
||||||
|
cd /path/to/wordpress/wp-content/plugins/
|
||||||
|
git clone https://github.com/noschmarrn/bavarianrankengine.git bavarian-rank-engine
|
||||||
|
wp plugin activate bavarian-rank-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nach der Aktivierung:**
|
||||||
|
1. *Bavarian Rank → AI Provider* — Provider wählen, API-Key hinterlegen, Verbindungstest
|
||||||
|
2. *Meta Generator* — Auto-Modus aktivieren, Post-Types auswählen
|
||||||
|
|
||||||
|
Kein JavaScript-Build-Step. Alle Assets unter `assets/` sind direkte JS/CSS-Dateien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technischer Stack
|
||||||
|
|
||||||
|
| Komponente | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Backend | PHP 8.0+, WordPress Plugin API |
|
||||||
|
| Namespace | `BavarianRankEngine\` |
|
||||||
|
| Architektur | Singleton-Core, Registry-Pattern (Provider), Feature-Klassen mit `register()` |
|
||||||
|
| Datenbank | WordPress Options API, `wpdb` (eigene Tabelle für CrawlerLog) |
|
||||||
|
| Caching | WordPress Transients |
|
||||||
|
| Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step |
|
||||||
|
| I18n | `.pot`-File, Text-Domain `bavarian-rank-engine` |
|
||||||
|
| Tests | PHPUnit (102 Tests, 216 Assertions) |
|
||||||
|
| Coding Standard | WordPress PHPCS |
|
||||||
|
| Lizenz | GPL-2.0-or-later |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
GPL-2.0-or-later — [https://www.gnu.org/licenses/gpl-2.0.html](https://www.gnu.org/licenses/gpl-2.0.html)
|
||||||
|
|
||||||
|
Copyright (c) 2025–2026 [Donau2Space](https://donau2space.de)
|
||||||
546
README.md
Normal file
546
README.md
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
# Bavarian Rank Engine
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
🇩🇪 [Deutsche Version → README.de.md](README.de.md)
|
||||||
|
|
||||||
|
**Website:** [bavarianrankengine.com](https://bavarianrankengine.com) · [How To](https://bavarianrankengine.com/howto.html) · [FAQ](https://bavarianrankengine.com/faq.html) · [Changelog](https://bavarianrankengine.com/changelog.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bavarian Rank Engine is a lightweight SEO & GEO plugin for WordPress. It generates AI-powered meta descriptions, outputs Schema.org structured data, creates GEO content blocks for AI engines, and manages crawler access via robots.txt and llms.txt — all in one plugin, nothing hidden behind a paywall.
|
||||||
|
|
||||||
|
It works with or without an AI key. It integrates without conflicts into Rank Math, Yoast, AIOSEO, and SEOPress. No SaaS. No telemetry. No upsells.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Plugin Exists
|
||||||
|
|
||||||
|
Most WordPress SEO plugins have evolved in the same direction: bloated feature sets, dashboards full of metrics nobody needed, and a pricing model that locks the useful functionality behind a monthly subscription.
|
||||||
|
|
||||||
|
The AI wave made it worse. Plugins started offering "AI-powered" features — but as a proxy service. You pay a monthly fee, your content goes through their servers, they call the AI API on your behalf and add a margin on top.
|
||||||
|
|
||||||
|
BRE takes a different approach:
|
||||||
|
|
||||||
|
- **Direct API access.** You store your own key from OpenAI, Anthropic, Google, or xAI. BRE calls the API directly. No middleman, no margin, no data passing through third-party servers.
|
||||||
|
- **Clear output, not noise.** Meta descriptions, structured data, GEO content blocks, bot management. No readability scores, no keyword density meters, no upsell banners.
|
||||||
|
- **No subscription.** GPL-2.0. Free to use on any number of sites. The only costs are API usage — typically fractions of a cent per post.
|
||||||
|
- **No telemetry.** BRE sends no data home. No usage tracking, no remote logging, no analytics leaving your server.
|
||||||
|
- **Works without AI.** No API key? The fallback extractor generates a usable meta description from post content using sentence boundary detection. Every post gets a description.
|
||||||
|
|
||||||
|
Built in Passau, Bavaria — for [Donau2Space](https://donau2space.de), a personal AI blog, where exactly this was needed — and nothing more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Why This Plugin Exists](#why-this-plugin-exists)
|
||||||
|
- [Directory Structure](#directory-structure)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Data Storage](#data-storage)
|
||||||
|
- [Security](#security)
|
||||||
|
- [AI Providers](#ai-providers)
|
||||||
|
- [Hooks & Extensibility](#hooks--extensibility)
|
||||||
|
- [AJAX Endpoints](#ajax-endpoints)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bavarian-rank-engine/
|
||||||
|
├── bavarian-rank-engine.php # Plugin header, constants (BRE_VERSION, BRE_DIR, BRE_URL)
|
||||||
|
├── uninstall.php # Cleanup on plugin deletion
|
||||||
|
├── assets/
|
||||||
|
│ ├── admin.css # Shared admin stylesheet
|
||||||
|
│ ├── admin.js # Provider selector, connection test
|
||||||
|
│ ├── bulk.js # Bulk generator AJAX loop + progress UI
|
||||||
|
│ ├── editor-meta.js # Meta editor box: live counter, AI regen button
|
||||||
|
│ ├── geo-editor.js # GEO block editor: generate / clear button
|
||||||
|
│ ├── geo-frontend.css # Minimal stylesheet for .bre-geo on frontend
|
||||||
|
│ ├── link-suggest.js # Internal link suggestions: trigger, UI, apply (Gutenberg + Classic)
|
||||||
|
│ └── seo-widget.js # SEO analysis widget: live evaluation in editor
|
||||||
|
├── includes/
|
||||||
|
│ ├── Core.php # Singleton bootstrap, loads all dependencies
|
||||||
|
│ ├── Admin/
|
||||||
|
│ │ ├── AdminMenu.php # Menu structure + dashboard render
|
||||||
|
│ │ ├── BulkPage.php # Bulk generator admin page
|
||||||
|
│ │ ├── GeoEditorBox.php # GEO block meta box in post editor
|
||||||
|
│ │ ├── GeoPage.php # GEO block settings page
|
||||||
|
│ │ ├── LinkAnalysis.php # AJAX handler for link analysis dashboard
|
||||||
|
│ │ ├── LinkSuggestPage.php # Internal link suggestions settings page
|
||||||
|
│ │ ├── MetaEditorBox.php # Meta description meta box in post editor
|
||||||
|
│ │ ├── MetaPage.php # Meta generator settings page
|
||||||
|
│ │ ├── ProviderPage.php # AI provider settings page
|
||||||
|
│ │ ├── SchemaMetaBox.php # Schema.org per-post meta box
|
||||||
|
│ │ ├── TxtPage.php # TXT Files page: llms.txt + robots.txt (tabbed)
|
||||||
|
│ │ ├── SchemaPage.php # Schema.org settings page
|
||||||
|
│ │ ├── SeoWidget.php # SEO analysis sidebar widget
|
||||||
|
│ │ ├── SettingsPage.php # Central getSettings() — merges all option keys
|
||||||
|
│ │ └── views/ # PHP templates for all admin pages
|
||||||
|
│ ├── Features/
|
||||||
|
│ │ ├── CrawlerLog.php # Log AI bot visits (own DB table)
|
||||||
|
│ │ ├── GeoBlock.php # GEO Quick Overview block (frontend output)
|
||||||
|
│ │ ├── LlmsTxt.php # /llms.txt endpoint with ETag/cache
|
||||||
|
│ │ ├── LinkSuggest.php # Internal link suggestions: matching engine + AJAX handler + meta box
|
||||||
|
│ │ ├── MetaGenerator.php # Core logic: AI call, save, bulk, AJAX
|
||||||
|
│ │ ├── RobotsTxt.php # robots.txt bot blocking via WP filter
|
||||||
|
│ │ └── SchemaEnhancer.php # JSON-LD Schema.org output in wp_head
|
||||||
|
│ ├── Helpers/
|
||||||
|
│ │ ├── BulkQueue.php # Mutex lock for bulk processes (transient-based)
|
||||||
|
│ │ ├── FallbackMeta.php # Meta extraction from post content without AI
|
||||||
|
│ │ ├── KeyVault.php # API key obfuscation before writing to DB
|
||||||
|
│ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk
|
||||||
|
│ └── Providers/
|
||||||
|
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
|
||||||
|
│ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers
|
||||||
|
│ ├── AnthropicProvider.php # Claude API (Messages API)
|
||||||
|
│ ├── GeminiProvider.php # Google Gemini (generateContent API)
|
||||||
|
│ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint)
|
||||||
|
│ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
|
||||||
|
└── vendor/ # Composer dependencies (production only)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### AI Meta Generator
|
||||||
|
|
||||||
|
Generates SEO-optimized meta descriptions (150–160 characters) automatically when a post is published. The prompt is fully customizable; supported placeholders: `{title}`, `{content}`, `{excerpt}`, `{language}`.
|
||||||
|
|
||||||
|
**Language detection:** The target language is automatically detected from Polylang, WPML, or the WordPress locale and passed in the prompt — no configuration needed.
|
||||||
|
|
||||||
|
**SEO plugin integration:** Generated descriptions are written not only to `_bre_meta_description` but also directly to the native field of the active SEO plugin:
|
||||||
|
|
||||||
|
| SEO Plugin | Meta Field |
|
||||||
|
|---|---|
|
||||||
|
| Rank Math | `rank_math_description` |
|
||||||
|
| Yoast SEO | `_yoast_wpseo_metadesc` |
|
||||||
|
| AIOSEO | `_aioseo_description` |
|
||||||
|
| SEOPress | `_seopress_titles_desc` |
|
||||||
|
| (none active) | BRE outputs `<meta name="description">` itself |
|
||||||
|
|
||||||
|
**Token mode:** Either the full post content is sent (`full`) or it is trimmed to a configurable token count (100–8000) (`limit`). Trimming is handled by `TokenEstimator` — a word-based estimate without external libraries, using a ratio of ~0.75 words per token.
|
||||||
|
|
||||||
|
**Fallback without AI:** `FallbackMeta::extract()` always delivers a usable description — even without an API key or on error. Extraction prefers sentence boundaries (`. `, `! `, `? `), falls back to word boundaries, and only uses a hard cut with `…` as a last resort. Fully multibyte-safe via `mb_substr` / `mb_strrpos`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GEO Block (Quick Overview)
|
||||||
|
|
||||||
|
Generates AI-powered content blocks directly in post text for Generative Engine Optimization:
|
||||||
|
|
||||||
|
- **Summary** — brief overview of the post
|
||||||
|
- **Key Points** — bullet list of the most important points
|
||||||
|
- **FAQ** — question-answer pairs (only above a configurable word threshold, default: 350 words)
|
||||||
|
|
||||||
|
**Insert position** (configurable): after the first paragraph (default), top, bottom.
|
||||||
|
|
||||||
|
**Display modes:**
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `details_collapsible` | Native HTML `<details>` — collapsed, no JavaScript needed |
|
||||||
|
| `open_always` | Block always visible |
|
||||||
|
| `store_only_no_frontend` | Store in DB only, no frontend output (e.g. for FAQPage schema) |
|
||||||
|
|
||||||
|
All labels (title, summary, key points, FAQ), the accent color, color scheme (auto/light/dark), and custom CSS are configurable via the admin page — no coding required.
|
||||||
|
|
||||||
|
**Per-post prompt add-on:** Authors can enter a custom prompt addition via a meta box in the post editor that is appended to the base prompt. Can be enabled/disabled globally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schema.org Enhancer
|
||||||
|
|
||||||
|
Outputs JSON-LD structured data and meta tags in `<head>`. Settings under **Bavarian Rank → Schema.org**. Each type is individually toggleable:
|
||||||
|
|
||||||
|
| Type | Schema.org Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `organization` | `Organization` | Name, URL, logo, `sameAs` links (social profiles) |
|
||||||
|
| `author` | `Person` | Author name, profile URL, optional Twitter `sameAs` |
|
||||||
|
| `speakable` | `WebPage` + `SpeakableSpecification` | CSS selectors on H1 and first paragraph |
|
||||||
|
| `article_about` | `Article` | Headline, publish/modified, description, publisher |
|
||||||
|
| `breadcrumb` | `BreadcrumbList` | Automatically suppressed when Rank Math or Yoast is active |
|
||||||
|
| `ai_meta_tags` | — | `<meta name="robots">` + `<meta name="googlebot">` with `max-snippet:-1` |
|
||||||
|
| `faq_schema` | `FAQPage` | Automatically populated from GEO block data |
|
||||||
|
| `blog_posting` | `BlogPosting` / `Article` | With embedded `author` and featured image |
|
||||||
|
| `image_object` | `ImageObject` | Featured image with dimensions and caption |
|
||||||
|
| `video_object` | `VideoObject` | YouTube/Vimeo automatically detected and embedded |
|
||||||
|
| `howto` | `HowTo` | Step-by-step guide — data via meta box in post editor |
|
||||||
|
| `review` | `Review` | Rating with `ratingValue` — data via meta box |
|
||||||
|
| `recipe` | `Recipe` | Ingredients, times, nutritional values — data via meta box |
|
||||||
|
| `event` | `Event` | Date, location, organizer — data via meta box |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### llms.txt
|
||||||
|
|
||||||
|
Serves `/llms.txt` and paginated follow-up files (`/llms-2.txt`, `/llms-3.txt` …) via a `parse_request` hook at priority 1 — before WordPress routing, no rewrite rule flush needed.
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
```
|
||||||
|
# Site Title
|
||||||
|
> Description block
|
||||||
|
|
||||||
|
## Featured Links
|
||||||
|
- [Title](URL): Description
|
||||||
|
|
||||||
|
## Content
|
||||||
|
- [Title](URL): Date
|
||||||
|
|
||||||
|
---
|
||||||
|
## More
|
||||||
|
/llms-2.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP caching:**
|
||||||
|
- `ETag: "md5(content)"` → HTTP 304 on `If-None-Match`
|
||||||
|
- `Last-Modified` based on the most recent `post_modified_gmt` in the database
|
||||||
|
- `Cache-Control: public, max-age=3600`
|
||||||
|
- Transient cache is automatically invalidated on every settings change
|
||||||
|
|
||||||
|
**Rank Math conflict notice:** If Rank Math also wants to serve an llms.txt, BRE shows an admin notice — BRE takes precedence automatically due to priority 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### robots.txt Manager
|
||||||
|
|
||||||
|
Appends `Disallow` blocks via the WordPress filter `robots_txt` — WordPress's own robots.txt is preserved; BRE only extends it.
|
||||||
|
|
||||||
|
Supported AI bots (all individually toggleable):
|
||||||
|
|
||||||
|
| User-Agent | Operator |
|
||||||
|
|---|---|
|
||||||
|
| `GPTBot` | OpenAI |
|
||||||
|
| `ClaudeBot` | Anthropic |
|
||||||
|
| `Google-Extended` | Google (Bard/Gemini training) |
|
||||||
|
| `PerplexityBot` | Perplexity AI |
|
||||||
|
| `CCBot` | Common Crawl |
|
||||||
|
| `Applebot-Extended` | Apple AI |
|
||||||
|
| `Bytespider` | ByteDance |
|
||||||
|
| `DataForSeoBot` | DataForSEO |
|
||||||
|
| `ImagesiftBot` | Imagesift |
|
||||||
|
| `omgili` | Omgili |
|
||||||
|
| `Diffbot` | Diffbot |
|
||||||
|
| `FacebookBot` | Meta |
|
||||||
|
| `Amazonbot` | Amazon |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bulk Generator
|
||||||
|
|
||||||
|
Batch processing of all published posts without a meta description. The process runs as a repeated AJAX request in the browser — no WP-Cron, no CLI required.
|
||||||
|
|
||||||
|
**Technical details:**
|
||||||
|
- 1–20 posts per batch (configurable)
|
||||||
|
- 6-second delay between batches (rate limiting against API limits)
|
||||||
|
- Up to 3 attempts per post
|
||||||
|
- Live progress log and running cost estimate in the admin UI
|
||||||
|
- **Mutex lock via transient** (`bre_bulk_running`, TTL 15 minutes): prevents parallel runs across multiple browser tabs or admin users. The lock is set at start, automatically released after the last batch — or manually via button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Crawler Log
|
||||||
|
|
||||||
|
Logs visits from known AI bots in the dedicated database table `{prefix}bre_crawler_log`:
|
||||||
|
|
||||||
|
| Column | Type | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| `bot_name` | VARCHAR | Name of the bot (e.g. `GPTBot`) |
|
||||||
|
| `ip_hash` | CHAR(64) | SHA-256 hash of the visitor IP |
|
||||||
|
| `url` | VARCHAR(512) | Requested URL |
|
||||||
|
| `visited_at` | DATETIME | Timestamp |
|
||||||
|
|
||||||
|
**Why SHA-256 instead of plain-text IP?** The original IP is never stored. The hash satisfies the GDPR requirement of data minimization: bot patterns are identifiable (same hash = same IP), but tracing back to a person without the plain-text value is practically impossible.
|
||||||
|
|
||||||
|
Entries older than 90 days are automatically cleaned up via weekly cron (`bre_cleanup_crawler_log`). The dashboard shows a 30-day summary per bot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Meta Editor Box
|
||||||
|
|
||||||
|
Meta box in the post editor (Classic and Block Editor):
|
||||||
|
- Source badge: `AI generated` / `Fallback` / `Manual` / `Not generated`
|
||||||
|
- Text field with `maxlength="160"` and live character counter (JavaScript, no save needed)
|
||||||
|
- "Regenerate with AI" button (only when API key is configured) — generates inline without page reload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SEO Analysis Widget
|
||||||
|
|
||||||
|
Sidebar meta box in the post editor with live evaluation while writing:
|
||||||
|
- Title length (target: ≤ 60 characters)
|
||||||
|
- Word count and estimated reading time
|
||||||
|
- Heading hierarchy (H1–H6 tree)
|
||||||
|
- Counter for internal and external links
|
||||||
|
- Inline warnings: no H2, no internal link, title too long
|
||||||
|
|
||||||
|
All stats are updated live via `MutationObserver`, no manual save needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Link Analysis (Dashboard)
|
||||||
|
|
||||||
|
AJAX widget on the plugin dashboard:
|
||||||
|
- Posts with no internal links at all
|
||||||
|
- Posts with an above-average number of external links
|
||||||
|
- Top-5 pillar pages by number of incoming internal links
|
||||||
|
|
||||||
|
Results are cached for 1 hour in the transient cache (`bre_link_analysis`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
### WordPress Options (wp_options)
|
||||||
|
|
||||||
|
| Option Key | Content |
|
||||||
|
|---|---|
|
||||||
|
| `bre_settings` | Active provider, API keys (obfuscated), model selection, token costs, `ai_enabled` flag |
|
||||||
|
| `bre_meta_settings` | Meta generator: auto mode, post types, token mode, prompt |
|
||||||
|
| `bre_schema_settings` | Schema.org: enabled types, organization sameAs URLs |
|
||||||
|
| `bre_geo_settings` | GEO block: mode, position, labels, CSS, prompt, color scheme |
|
||||||
|
| `bre_robots_settings` | robots.txt: blocked bots |
|
||||||
|
| `bre_llms_settings` | llms.txt: title, description, featured links, footer, page count |
|
||||||
|
| `bre_usage_stats` | Accumulated token usage: `tokens_in`, `tokens_out`, `count` |
|
||||||
|
| `bre_first_activated` | Unix timestamp of first activation (used by welcome notice) |
|
||||||
|
|
||||||
|
### Post Meta (wp_postmeta)
|
||||||
|
|
||||||
|
| Meta Key | Content |
|
||||||
|
|---|---|
|
||||||
|
| `_bre_meta_description` | Generated meta description |
|
||||||
|
| `_bre_meta_source` | Source: `ai` / `fallback` / `manual` |
|
||||||
|
| `_bre_bulk_failed` | Last error during bulk attempt |
|
||||||
|
| `_bre_geo_summary` | GEO block summary |
|
||||||
|
| `_bre_geo_bullets` | GEO block key points (JSON array) |
|
||||||
|
| `_bre_geo_faq` | GEO block FAQ (JSON array) |
|
||||||
|
|
||||||
|
### Custom Database Table
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `{prefix}bre_crawler_log` | AI bot visits (bot_name, ip_hash, url, visited_at) |
|
||||||
|
|
||||||
|
### Transients
|
||||||
|
|
||||||
|
| Transient | TTL | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `bre_llms_cache_{n}` | 1 hour | Cached llms.txt content per page |
|
||||||
|
| `bre_link_analysis` | 1 hour | Dashboard link analysis result |
|
||||||
|
| `bre_bulk_running` | 15 minutes | Mutex lock for bulk generator |
|
||||||
|
| `bre_meta_stats` | 5 minutes | Dashboard meta coverage query result |
|
||||||
|
| `bre_crawler_summary` | 5 minutes | Dashboard crawler summary (last 30 days) |
|
||||||
|
|
||||||
|
### Uninstall cleanup
|
||||||
|
|
||||||
|
`uninstall.php` removes on plugin deletion:
|
||||||
|
- Option `bre_settings`
|
||||||
|
- Post meta `_bre_meta_description` for all posts
|
||||||
|
|
||||||
|
> Note: The remaining option keys and the `bre_crawler_log` table are not automatically removed. For full cleanup, delete these manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### API Key Obfuscation (KeyVault)
|
||||||
|
|
||||||
|
```
|
||||||
|
Plaintext key → XOR(key, sha256(AUTH_KEY . SECURE_AUTH_KEY)) → base64 → "bre1:<base64>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`BavarianRankEngine\Helpers\KeyVault` obfuscates API keys before writing to `wp_options`:
|
||||||
|
|
||||||
|
1. A 64-byte salt is derived from the WordPress constants `AUTH_KEY` and `SECURE_AUTH_KEY` via `hash('sha256', ...)`.
|
||||||
|
2. The plaintext is XOR'd byte-by-byte (salt is repeated as needed).
|
||||||
|
3. The result is base64-encoded and prefixed with `bre1:`.
|
||||||
|
|
||||||
|
**Why XOR and not AES?** No `openssl_*` or external extension required — the code runs on any PHP 8.0+ installation without configuration. The `bre1:` prefix allows future migration to stronger encryption without a breaking change.
|
||||||
|
|
||||||
|
**Security boundary:** XOR with a static salt is obfuscation, not cryptographic encryption. An attacker with access to **both** the database **and** `wp-config.php` can reconstruct the key. For maximum security, keys can be defined as `wp-config.php` constants — these take precedence over the database version:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define( 'BRE_OPENAI_KEY', 'sk-...' );
|
||||||
|
define( 'BRE_ANTHROPIC_KEY', 'sk-ant-...' );
|
||||||
|
define( 'BRE_GEMINI_KEY', 'AI...' );
|
||||||
|
define( 'BRE_GROK_KEY', 'xai-...' );
|
||||||
|
```
|
||||||
|
|
||||||
|
In the admin UI, keys are always displayed masked: `••••••Ab3c9` (only the last 5 characters visible).
|
||||||
|
|
||||||
|
### CSRF Protection and Capability Checks
|
||||||
|
|
||||||
|
Every AJAX handler follows the same pattern — without exception:
|
||||||
|
|
||||||
|
```php
|
||||||
|
check_ajax_referer( 'bre_admin', 'nonce' ); // CSRF
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) { // Authorization
|
||||||
|
wp_send_json_error( 'Unauthorized', 403 );
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The nonce `bre_admin` is passed to the frontend via `wp_localize_script` and validated server-side on every request. There are no `wp_ajax_nopriv_` handlers — all AJAX endpoints are exclusively accessible to logged-in users with `manage_options` capability.
|
||||||
|
|
||||||
|
### Input Validation and Output Escaping
|
||||||
|
|
||||||
|
- All `$_POST` values are processed via `wp_unslash()` + specific sanitizers (`sanitize_text_field`, `absint`, `wp_kses_post` depending on context).
|
||||||
|
- All output in admin views is escaped (`esc_html`, `esc_attr`, `esc_url`, `esc_textarea`).
|
||||||
|
- SQL queries exclusively via `$wpdb->prepare()`.
|
||||||
|
- GEO Block custom CSS is sanitized through `Helpers\Css::sanitize_declarations()` — strips comments, braces, at-rules (`@import`, `@media`, etc.), `url()`, `expression()`, and `javascript:` before injection via `wp_add_inline_style()`.
|
||||||
|
|
||||||
|
### Privacy (GDPR)
|
||||||
|
|
||||||
|
The crawler log stores IP addresses exclusively as SHA-256 hashes. The original value is never persisted. Entries are automatically deleted after 90 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Providers
|
||||||
|
|
||||||
|
BRE supports four providers, all implementing the same `ProviderInterface`:
|
||||||
|
|
||||||
|
| Provider | Class | API Base URL |
|
||||||
|
|---|---|---|
|
||||||
|
| OpenAI | `OpenAIProvider` | `https://api.openai.com/v1/chat/completions` |
|
||||||
|
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
|
||||||
|
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
|
||||||
|
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
|
||||||
|
|
||||||
|
### Adding a New Provider
|
||||||
|
|
||||||
|
```php
|
||||||
|
// includes/Providers/YourProvider.php
|
||||||
|
namespace BavarianRankEngine\Providers;
|
||||||
|
|
||||||
|
class YourProvider implements ProviderInterface {
|
||||||
|
public function getId(): string { return 'yourprovider'; }
|
||||||
|
public function getName(): string { return 'Your Provider'; }
|
||||||
|
public function getModels(): array { return [ 'model-id' => 'Model Name' ]; }
|
||||||
|
|
||||||
|
public function testConnection( string $api_key ): array {
|
||||||
|
// Minimal API call — returns ['success' => bool, 'message' => string]
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
|
||||||
|
// Call API, return plain text — throw \RuntimeException on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in `includes/Core.php` → `register_hooks()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$registry->register( new Providers\YourProvider() );
|
||||||
|
```
|
||||||
|
|
||||||
|
The provider automatically appears in all admin dropdowns, the provider settings page, and the cost overview of the bulk generator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks & Extensibility
|
||||||
|
|
||||||
|
### `bre_prompt` (Filter)
|
||||||
|
|
||||||
|
Allows modifying the final prompt immediately before the API call.
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_filter( 'bre_prompt', function( string $prompt, WP_Post $post ): string {
|
||||||
|
$keyword = get_post_meta( $post->ID, 'focus_keyword', true );
|
||||||
|
return $keyword ? $prompt . "\nFocus keyword: {$keyword}" : $prompt;
|
||||||
|
}, 10, 2 );
|
||||||
|
```
|
||||||
|
|
||||||
|
### `bre_meta_saved` (Action)
|
||||||
|
|
||||||
|
Fired after a meta description is successfully saved — both on automatic generation at publish and on manual regen in the editor.
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_action( 'bre_meta_saved', function( int $post_id, string $description ): void {
|
||||||
|
// e.g. sync with external system or cache invalidation
|
||||||
|
my_cdn_purge( get_permalink( $post_id ) );
|
||||||
|
}, 10, 2 );
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Feature
|
||||||
|
|
||||||
|
1. Create `includes/Features/YourFeature.php` with a `register()` method that registers WordPress hooks.
|
||||||
|
2. In `includes/Core.php` → `load_dependencies()`: `require_once BRE_DIR . 'includes/Features/YourFeature.php';`
|
||||||
|
3. In `includes/Core.php` → `register_hooks()`: `( new Features\YourFeature() )->register();`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AJAX Endpoints
|
||||||
|
|
||||||
|
All endpoints are exclusively accessible to logged-in users with `manage_options` (no `nopriv`).
|
||||||
|
|
||||||
|
| Action | Handler | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `bre_regen_meta` | `MetaEditorBox::ajax_regen` | Regenerate meta description for a single post |
|
||||||
|
| `bre_test_connection` | `ProviderPage::ajax_test_connection` | Test API key and connection |
|
||||||
|
| `bre_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Reset to default prompt |
|
||||||
|
| `bre_link_analysis` | `LinkAnalysis::ajax_analyse` | Run link analysis for the dashboard |
|
||||||
|
| `bre_link_suggestions` | `LinkSuggest::ajax_suggest` | Return top-10 internal link suggestions for current post |
|
||||||
|
| `bre_geo_generate` | `GeoEditorBox::ajax_generate` | Generate GEO block for a single post |
|
||||||
|
| `bre_geo_clear` | `GeoEditorBox::ajax_clear` | Clear GEO block data for a single post |
|
||||||
|
| `bre_llms_clear_cache` | `TxtPage::ajax_clear_cache` | Clear llms.txt transient cache |
|
||||||
|
| `bre_dismiss_llms_notice` | `LlmsTxt::ajax_dismiss_notice` | Dismiss Rank Math conflict admin notice |
|
||||||
|
| `bre_dismiss_welcome` | `AdminMenu::ajax_dismiss_welcome` | Dismiss the welcome notice per user |
|
||||||
|
| `bre_bulk_generate` | `MetaGenerator::ajaxBulkGenerate` | Process next batch in bulk generator |
|
||||||
|
| `bre_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Retrieve progress and stats of running bulk |
|
||||||
|
| `bre_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Manually release bulk mutex lock |
|
||||||
|
| `bre_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Check bulk lock status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Via GitHub Release (recommended):**
|
||||||
|
1. Download `bavarian-rank-engine.zip` from the [latest release](https://github.com/noschmarrn/bavarianrankengine/releases/latest)
|
||||||
|
2. In WordPress go to *Plugins → Add New → Upload Plugin*
|
||||||
|
|
||||||
|
**Manual (clone):**
|
||||||
|
```bash
|
||||||
|
cd /path/to/wordpress/wp-content/plugins/
|
||||||
|
git clone https://github.com/noschmarrn/bavarianrankengine.git bavarian-rank-engine
|
||||||
|
wp plugin activate bavarian-rank-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
**After activation:**
|
||||||
|
1. Go to *Bavarian Rank → AI Provider*, select your provider and enter your API key
|
||||||
|
2. Run the connection test
|
||||||
|
3. Go to *Meta Generator*, enable auto mode and select post types
|
||||||
|
|
||||||
|
The plugin has no JavaScript build step. All assets under `assets/` are direct JS/CSS files without transpiling or bundling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | PHP 8.0+, WordPress Plugin API |
|
||||||
|
| Namespace | `BavarianRankEngine\` |
|
||||||
|
| Architecture | Singleton core, registry pattern (providers), feature classes with `register()` |
|
||||||
|
| Database | WordPress Options API, `wpdb` (custom table for CrawlerLog) |
|
||||||
|
| Caching | WordPress transients (llms.txt, link analysis, bulk lock) |
|
||||||
|
| Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step |
|
||||||
|
| i18n | `.pot` file, text domain `bavarian-rank-engine` |
|
||||||
|
| Tests | PHPUnit (102 tests, 216 assertions) |
|
||||||
|
| Coding standard | WordPress PHPCS |
|
||||||
|
| License | GPL-2.0-or-later |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-2.0-or-later — [https://www.gnu.org/licenses/gpl-2.0.html](https://www.gnu.org/licenses/gpl-2.0.html)
|
||||||
|
|
||||||
|
Copyright (c) 2025–2026 [Donau2Space](https://donau2space.de)
|
||||||
339
brezngeo/LICENSE
Normal file
339
brezngeo/LICENSE
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
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".
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 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 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) 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.)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
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, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
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 Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, 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.
|
||||||
137
brezngeo/assets/admin.css
Normal file
137
brezngeo/assets/admin.css
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
.brezngeo-settings h1 {
|
||||||
|
padding-left: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.brezngeo-settings h2 {
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.brezngeo-dashboard-grid .postbox {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.brezngeo-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.brezngeo-provider-row { display: none; }
|
||||||
|
.brezngeo-provider-row.active { display: table-row; }
|
||||||
|
.brezngeo-test-result { margin-left: 10px; font-weight: bold; }
|
||||||
|
.brezngeo-test-result.success { color: #46b450; }
|
||||||
|
.brezngeo-test-result.error { color: #dc3232; }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Welcome notice --- */
|
||||||
|
.brezngeo-welcome-notice {
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #f0f6ff 100%);
|
||||||
|
border-left: 4px solid #2271b1;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
padding: 16px 40px 16px 20px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.brezngeo-welcome-notice a { font-weight: 600; }
|
||||||
|
.brezngeo-dismiss {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.brezngeo-dismiss:hover { color: #333; }
|
||||||
|
|
||||||
|
/* --- Progress bars --- */
|
||||||
|
.brezngeo-progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #f0f0f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.brezngeo-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width .3s ease;
|
||||||
|
}
|
||||||
|
.brezngeo-progress-fill.brezngeo-ok { background: #46b450; }
|
||||||
|
.brezngeo-progress-fill.brezngeo-warn { background: #ffb900; }
|
||||||
|
.brezngeo-progress-fill.brezngeo-bad { background: #dc3232; }
|
||||||
|
|
||||||
|
/* --- Quick links --- */
|
||||||
|
.brezngeo-quick-links-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.brezngeo-quick-links-list li {
|
||||||
|
border-bottom: 1px solid #f0f0f1;
|
||||||
|
}
|
||||||
|
.brezngeo-quick-links-list li:last-child { border-bottom: none; }
|
||||||
|
.brezngeo-quick-links-list a {
|
||||||
|
display: block;
|
||||||
|
padding: 9px 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #2271b1;
|
||||||
|
transition: padding-left .12s;
|
||||||
|
}
|
||||||
|
.brezngeo-quick-links-list a:hover {
|
||||||
|
color: #135e96;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Crawler dot --- */
|
||||||
|
.brezngeo-bot-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #46b450;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Meta coverage --- */
|
||||||
|
.brezngeo-coverage-row { margin-bottom: 14px; }
|
||||||
|
.brezngeo-coverage-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.brezngeo-coverage-stat { color: #666; }
|
||||||
|
|
||||||
|
/* --- Status widget --- */
|
||||||
|
.brezngeo-stat-label {
|
||||||
|
padding: 5px 8px 5px 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 55%;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.brezngeo-stat-value {
|
||||||
|
padding: 5px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- AI enable toggle --- */
|
||||||
|
.brezngeo-ai-toggle-wrap {
|
||||||
|
background: #fff8e5;
|
||||||
|
border-left: 4px solid #ffb900;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.brezngeo-ai-cost-notice {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
126
brezngeo/assets/admin.js
Normal file
126
brezngeo/assets/admin.js
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/* global brezngeoAdmin, brezngeoL10n */
|
||||||
|
jQuery( function ( $ ) {
|
||||||
|
function updateProviderRows() {
|
||||||
|
var active = $( '#brezngeo-provider' ).val();
|
||||||
|
$( '.brezngeo-provider-row' ).removeClass( 'active' );
|
||||||
|
$( '.brezngeo-provider-row[data-provider="' + active + '"]' ).addClass( 'active' );
|
||||||
|
}
|
||||||
|
updateProviderRows();
|
||||||
|
$( '#brezngeo-provider' ).on( 'change', updateProviderRows );
|
||||||
|
|
||||||
|
$( document ).on( 'click', '.brezngeo-test-btn', function () {
|
||||||
|
var btn = $( this );
|
||||||
|
var providerId = btn.data( 'provider' );
|
||||||
|
var resultEl = $( '#test-result-' + providerId );
|
||||||
|
|
||||||
|
resultEl.removeClass( 'success error' ).text( brezngeoAdmin.testing );
|
||||||
|
btn.prop( 'disabled', true );
|
||||||
|
|
||||||
|
$.post( brezngeoAdmin.ajaxUrl, {
|
||||||
|
action: 'brezngeo_test_connection',
|
||||||
|
nonce: brezngeoAdmin.nonce,
|
||||||
|
provider: providerId,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
resultEl.addClass( 'success' ).text( '\u2713 ' + res.data );
|
||||||
|
} else {
|
||||||
|
resultEl.addClass( 'error' ).text( '\u2717 ' + res.data );
|
||||||
|
}
|
||||||
|
} ).fail( function () {
|
||||||
|
resultEl.addClass( 'error' ).text( '\u2717 ' + brezngeoAdmin.networkError );
|
||||||
|
} ).always( function () {
|
||||||
|
btn.prop( 'disabled', false );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
$( '#brezngeo-reset-prompt' ).on( 'click', function () {
|
||||||
|
if ( ! confirm( brezngeoAdmin.resetConfirm ) ) return;
|
||||||
|
$.post( brezngeoAdmin.ajaxUrl, {
|
||||||
|
action: 'brezngeo_get_default_prompt',
|
||||||
|
nonce: brezngeoAdmin.nonce,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
$( 'textarea[name*="prompt"]' ).val( res.data );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
$( '#brezngeo-dismiss-welcome' ).on( 'click', function () {
|
||||||
|
$( '#brezngeo-welcome-notice' ).slideUp( 200 );
|
||||||
|
$.post( brezngeoAdmin.ajaxUrl, {
|
||||||
|
action: 'brezngeo_dismiss_welcome',
|
||||||
|
nonce: brezngeoAdmin.nonce,
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
function updateAiFields() {
|
||||||
|
if ( $( '#brezngeo-ai-enabled' ).is( ':checked' ) ) {
|
||||||
|
$( '#brezngeo-ai-fields' ).show();
|
||||||
|
} else {
|
||||||
|
$( '#brezngeo-ai-fields' ).hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $( '#brezngeo-ai-enabled' ).length ) {
|
||||||
|
updateAiFields();
|
||||||
|
$( '#brezngeo-ai-enabled' ).on( 'change', updateAiFields );
|
||||||
|
}
|
||||||
|
|
||||||
|
// llms.txt cache clear button
|
||||||
|
$( '#brezngeo-llms-clear-cache' ).on( 'click', function () {
|
||||||
|
$.post( brezngeoAdmin.ajaxUrl, {
|
||||||
|
action: 'brezngeo_llms_clear_cache',
|
||||||
|
nonce: brezngeoAdmin.nonce,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
$( '#brezngeo-cache-result' ).text( res.success ? brezngeoAdmin.cacheCleared : brezngeoAdmin.error );
|
||||||
|
setTimeout( function () { $( '#brezngeo-cache-result' ).text( '' ); }, 3000 );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Link analysis dashboard widget
|
||||||
|
if ( typeof brezngeoL10n !== 'undefined' && $( '#brezngeo-link-analysis-content' ).length ) {
|
||||||
|
$.post( brezngeoAdmin.ajaxUrl, {
|
||||||
|
action: 'brezngeo_link_analysis',
|
||||||
|
nonce: brezngeoAdmin.nonce,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( ! res.success ) {
|
||||||
|
$( '#brezngeo-link-analysis-content' ).text( brezngeoL10n.analysisError );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var d = res.data, h = '';
|
||||||
|
h += '<p><strong>' + brezngeoL10n.noLinksHeading + ' (' + d.no_internal_links.length + ')</strong></p>';
|
||||||
|
if ( d.no_internal_links.length ) {
|
||||||
|
h += '<ul style="margin:0 0 10px 20px;">';
|
||||||
|
$.each( d.no_internal_links.slice( 0, 10 ), function ( i, p ) {
|
||||||
|
h += '<li>' + $( '<span>' ).text( p.title ).html() + '</li>';
|
||||||
|
} );
|
||||||
|
if ( d.no_internal_links.length > 10 ) h += '<li>\u2026</li>';
|
||||||
|
h += '</ul>';
|
||||||
|
} else {
|
||||||
|
h += '<p>' + brezngeoL10n.allLinked + '</p>';
|
||||||
|
}
|
||||||
|
h += '<p><strong>' + brezngeoL10n.manyExternalPre + d.threshold + ')</strong></p>';
|
||||||
|
if ( d.too_many_external.length ) {
|
||||||
|
h += '<ul style="margin:0 0 10px 20px;">';
|
||||||
|
$.each( d.too_many_external.slice( 0, 5 ), function ( i, p ) {
|
||||||
|
h += '<li>' + $( '<span>' ).text( p.title ).html() + ' (' + p.count + ')</li>';
|
||||||
|
} );
|
||||||
|
h += '</ul>';
|
||||||
|
} else {
|
||||||
|
h += '<p>' + brezngeoL10n.noExternalIssues + '</p>';
|
||||||
|
}
|
||||||
|
h += '<p><strong>' + brezngeoL10n.pillarHeading + '</strong></p>';
|
||||||
|
if ( d.pillar_pages.length ) {
|
||||||
|
h += '<ul style="margin:0 0 10px 20px;">';
|
||||||
|
$.each( d.pillar_pages, function ( i, p ) {
|
||||||
|
h += '<li><a href="' + $( '<span>' ).text( p.url ).html() + '" target="_blank">' + $( '<span>' ).text( p.url ).html() + '</a> (' + p.count + 'x)</li>';
|
||||||
|
} );
|
||||||
|
h += '</ul>';
|
||||||
|
} else {
|
||||||
|
h += '<p>' + brezngeoL10n.noData + '</p>';
|
||||||
|
}
|
||||||
|
$( '#brezngeo-link-analysis-content' ).html( h );
|
||||||
|
} ).fail( function () {
|
||||||
|
$( '#brezngeo-link-analysis-content' ).text( brezngeoL10n.connectionError );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
} );
|
||||||
221
brezngeo/assets/bulk.js
Normal file
221
brezngeo/assets/bulk.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/* global brezngeoBulk */
|
||||||
|
jQuery( function ( $ ) {
|
||||||
|
var running = false;
|
||||||
|
var stopFlag = false;
|
||||||
|
var processed = 0;
|
||||||
|
var total = 0;
|
||||||
|
var failedItems = [];
|
||||||
|
|
||||||
|
if ( brezngeoBulk.isLocked ) {
|
||||||
|
showLockWarning( brezngeoBulk.lockAge );
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
|
||||||
|
function showLockWarning( age ) {
|
||||||
|
var msg = brezngeoBulk.i18n.lockWarning + ( age ? ' (' + brezngeoBulk.i18n.since + ' ' + age + 's)' : '' ) + '.';
|
||||||
|
$( '#brezngeo-lock-warning' ).text( msg ).show();
|
||||||
|
$( '#brezngeo-bulk-start' ).prop( 'disabled', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLockWarning() {
|
||||||
|
$( '#brezngeo-lock-warning' ).hide();
|
||||||
|
$( '#brezngeo-bulk-start' ).prop( 'disabled', false );
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStats() {
|
||||||
|
$.post( brezngeoBulk.ajaxUrl, { action: 'brezngeo_bulk_stats', nonce: brezngeoBulk.nonce } )
|
||||||
|
.done( function ( res ) {
|
||||||
|
if ( ! res.success ) return;
|
||||||
|
var html = '<strong>' + brezngeoBulk.i18n.postsWithoutMeta + '</strong><ul>';
|
||||||
|
var t = 0;
|
||||||
|
$.each( res.data, function ( pt, count ) {
|
||||||
|
html += '<li>' + $( '<span>' ).text( pt ).html() + ': <strong>' + parseInt( count, 10 ) + '</strong></li>';
|
||||||
|
t += parseInt( count, 10 );
|
||||||
|
} );
|
||||||
|
html += '</ul><strong>' + brezngeoBulk.i18n.total + ' ' + t + '</strong>';
|
||||||
|
total = t;
|
||||||
|
$( '#brezngeo-bulk-stats' ).html( html );
|
||||||
|
updateCostEstimate();
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
$( '#brezngeo-bulk-limit, #brezngeo-bulk-model, #brezngeo-bulk-provider' ).on( 'change', updateCostEstimate );
|
||||||
|
|
||||||
|
function updateCostEstimate() {
|
||||||
|
var limit = parseInt( $( '#brezngeo-bulk-limit' ).val(), 10 ) || 20;
|
||||||
|
var inputTokens = limit * 800;
|
||||||
|
var outputTokens = limit * 50;
|
||||||
|
var costHtml = '~' + inputTokens + ' ' + brezngeoBulk.i18n.inputTokens + ' + ' + outputTokens + ' ' + brezngeoBulk.i18n.outputTokens;
|
||||||
|
|
||||||
|
var costData = brezngeoBulk.costs || {};
|
||||||
|
var provider = $( '#brezngeo-bulk-provider' ).val();
|
||||||
|
var model = $( '#brezngeo-bulk-model' ).val();
|
||||||
|
|
||||||
|
if ( costData[ provider ] && costData[ provider ][ model ] ) {
|
||||||
|
var c = costData[ provider ][ model ];
|
||||||
|
var inCost = ( inputTokens / 1000000 ) * parseFloat( c.input || 0 );
|
||||||
|
var outCost= ( outputTokens / 1000000 ) * parseFloat( c.output || 0 );
|
||||||
|
var total = inCost + outCost;
|
||||||
|
if ( total > 0 ) {
|
||||||
|
costHtml += ' \u2248 $' + total.toFixed( 4 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$( '#brezngeo-cost-estimate' ).text( costHtml );
|
||||||
|
}
|
||||||
|
|
||||||
|
$( '#brezngeo-bulk-start' ).on( 'click', function () {
|
||||||
|
if ( running ) return;
|
||||||
|
$.post( brezngeoBulk.ajaxUrl, { action: 'brezngeo_bulk_status', nonce: brezngeoBulk.nonce } )
|
||||||
|
.done( function ( res ) {
|
||||||
|
if ( res.success && res.data.locked ) {
|
||||||
|
showLockWarning( res.data.lock_age );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startRun();
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
function startRun() {
|
||||||
|
running = true;
|
||||||
|
stopFlag = false;
|
||||||
|
processed = 0;
|
||||||
|
failedItems = [];
|
||||||
|
|
||||||
|
$( '#brezngeo-bulk-start' ).prop( 'disabled', true );
|
||||||
|
$( '#brezngeo-bulk-stop' ).show();
|
||||||
|
$( '#brezngeo-progress-wrap' ).show();
|
||||||
|
$( '#brezngeo-bulk-log' ).show().html( '' );
|
||||||
|
$( '#brezngeo-failed-summary' ).hide().html( '' );
|
||||||
|
hideLockWarning();
|
||||||
|
|
||||||
|
var limit = parseInt( $( '#brezngeo-bulk-limit' ).val(), 10 ) || 20;
|
||||||
|
var provider = $( '#brezngeo-bulk-provider' ).val();
|
||||||
|
var model = $( '#brezngeo-bulk-model' ).val();
|
||||||
|
|
||||||
|
log( brezngeoBulk.i18n.logStart.replace( '{limit}', limit ).replace( '{provider}', provider ) );
|
||||||
|
runBatch( 'post', limit, provider, model, true );
|
||||||
|
}
|
||||||
|
|
||||||
|
$( '#brezngeo-bulk-stop' ).on( 'click', function () {
|
||||||
|
stopFlag = true;
|
||||||
|
log( '\u26A0 ' + brezngeoBulk.i18n.stopRequested, 'warn' );
|
||||||
|
releaseLock();
|
||||||
|
} );
|
||||||
|
|
||||||
|
function releaseLock() {
|
||||||
|
$.post( brezngeoBulk.ajaxUrl, { action: 'brezngeo_bulk_release', nonce: brezngeoBulk.nonce } );
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBatch( postType, remaining, provider, model, isFirst ) {
|
||||||
|
if ( stopFlag || remaining <= 0 ) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchSize = Math.min( 20, remaining );
|
||||||
|
var isLast = ( remaining - batchSize ) <= 0;
|
||||||
|
|
||||||
|
log( brezngeoBulk.i18n.logProcess.replace( '{count}', batchSize ).replace( '{remaining}', remaining ) );
|
||||||
|
|
||||||
|
$.post( brezngeoBulk.ajaxUrl, {
|
||||||
|
action: 'brezngeo_bulk_generate',
|
||||||
|
nonce: brezngeoBulk.nonce,
|
||||||
|
post_type: postType,
|
||||||
|
batch_size: batchSize,
|
||||||
|
provider: provider,
|
||||||
|
model: model,
|
||||||
|
is_first: isFirst ? 1 : 0,
|
||||||
|
is_last: isLast ? 1 : 0,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( ! res.success ) {
|
||||||
|
if ( res.data && res.data.locked ) {
|
||||||
|
showLockWarning( res.data.lock_age );
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log( '\u2717 Fehler: ' + $( '<span>' ).text( ( res.data && res.data.message ) || brezngeoBulk.i18n.unknownError ).html(), 'error' );
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.each( res.data.results, function ( i, item ) {
|
||||||
|
if ( item.success ) {
|
||||||
|
var note = item.attempts > 1 ? ' (' + brezngeoBulk.i18n.attempt + ' ' + item.attempts + ')' : '';
|
||||||
|
log(
|
||||||
|
'\u2713 [' + item.id + '] ' +
|
||||||
|
$( '<span>' ).text( item.title ).html() + note +
|
||||||
|
'<br><small style="color:#9cdcfe;">' +
|
||||||
|
$( '<span>' ).text( item.description ).html() +
|
||||||
|
'</small>'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
failedItems.push( item );
|
||||||
|
log(
|
||||||
|
'\u2717 [' + item.id + '] ' +
|
||||||
|
$( '<span>' ).text( item.title ).html() +
|
||||||
|
' \u2014 ' + $( '<span>' ).text( item.error ).html(),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
processed++;
|
||||||
|
} );
|
||||||
|
|
||||||
|
updateProgress( processed, total );
|
||||||
|
|
||||||
|
var newRemaining = remaining - batchSize;
|
||||||
|
if ( res.data.remaining > 0 && ! stopFlag && newRemaining > 0 ) {
|
||||||
|
setTimeout( function () {
|
||||||
|
runBatch( postType, newRemaining, provider, model, false );
|
||||||
|
}, brezngeoBulk.rateDelay );
|
||||||
|
} else {
|
||||||
|
if ( isLast || res.data.remaining === 0 ) releaseLock();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
} ).fail( function () {
|
||||||
|
log( '\u2717 ' + brezngeoBulk.i18n.networkError, 'error' );
|
||||||
|
releaseLock();
|
||||||
|
finish();
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress( done, t ) {
|
||||||
|
var pct = t > 0 ? Math.round( ( done / t ) * 100 ) : 100;
|
||||||
|
$( '#brezngeo-progress-bar' ).css( 'width', pct + '%' );
|
||||||
|
$( '#brezngeo-progress-text' ).text( done + ' / ' + t + ' ' + brezngeoBulk.i18n.processed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a line to the log console.
|
||||||
|
* @param {string} msg Pre-escaped HTML string. User data MUST be escaped via
|
||||||
|
* $('<span>').text(val).html() before passing here.
|
||||||
|
* @param {string} type 'error' | 'warn' | undefined
|
||||||
|
*/
|
||||||
|
function log( msg, type ) {
|
||||||
|
var color = type === 'error' ? '#f48771' : type === 'warn' ? '#dcdcaa' : '#9cdcfe';
|
||||||
|
$( '#brezngeo-bulk-log' ).append(
|
||||||
|
'<div style="color:' + color + ';margin-bottom:4px;">' + msg + '</div>'
|
||||||
|
);
|
||||||
|
var el = document.getElementById( 'brezngeo-bulk-log' );
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
running = false;
|
||||||
|
$( '#brezngeo-bulk-start' ).prop( 'disabled', false );
|
||||||
|
$( '#brezngeo-bulk-stop' ).hide();
|
||||||
|
log( brezngeoBulk.i18n.done );
|
||||||
|
|
||||||
|
if ( failedItems.length > 0 ) {
|
||||||
|
var html = '<strong>\u26A0 ' + failedItems.length + ' ' + brezngeoBulk.i18n.postsFailed + '</strong><ul>';
|
||||||
|
$.each( failedItems, function ( i, item ) {
|
||||||
|
html += '<li>[' + item.id + '] ' +
|
||||||
|
$( '<span>' ).text( item.title ).html() +
|
||||||
|
': <em>' + $( '<span>' ).text( item.error ).html() + '</em></li>';
|
||||||
|
} );
|
||||||
|
html += '</ul>';
|
||||||
|
$( '#brezngeo-failed-summary' ).html( html ).show();
|
||||||
|
}
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
} );
|
||||||
32
brezngeo/assets/editor-meta.js
Normal file
32
brezngeo/assets/editor-meta.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/* global jQuery, ajaxurl */
|
||||||
|
jQuery( function ( $ ) {
|
||||||
|
var $textarea = $( '#brezngeo-meta-description' );
|
||||||
|
var $count = $( '#brezngeo-meta-count' );
|
||||||
|
var $btn = $( '#brezngeo-regen-meta' );
|
||||||
|
|
||||||
|
if ( ! $textarea.length ) return;
|
||||||
|
|
||||||
|
$textarea.on( 'input', function () {
|
||||||
|
$count.text( $( this ).val().length + ' / 160' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( ! $btn.length ) return;
|
||||||
|
|
||||||
|
$btn.on( 'click', function () {
|
||||||
|
$btn.prop( 'disabled', true ).text( '…' );
|
||||||
|
$.post( ajaxurl, {
|
||||||
|
action: 'brezngeo_regen_meta',
|
||||||
|
nonce: $btn.data( 'nonce' ),
|
||||||
|
post_id: $btn.data( 'post-id' ),
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
$textarea.val( res.data.description );
|
||||||
|
$count.text( res.data.description.length + ' / 160' );
|
||||||
|
} else {
|
||||||
|
alert( 'Fehler: ' + ( res.data || 'Unbekannt' ) );
|
||||||
|
}
|
||||||
|
} ).always( function () {
|
||||||
|
$btn.prop( 'disabled', false ).text( 'Mit KI neu generieren' );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
84
brezngeo/assets/geo-editor.js
Normal file
84
brezngeo/assets/geo-editor.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/* global jQuery, ajaxurl */
|
||||||
|
jQuery( function ( $ ) {
|
||||||
|
var $box = $( '#brezngeo-geo-box' );
|
||||||
|
if ( ! $box.length ) return;
|
||||||
|
|
||||||
|
var postId = $box.data( 'post-id' );
|
||||||
|
var nonce = $box.data( 'nonce' );
|
||||||
|
var $generate = $( '#brezngeo-geo-generate' );
|
||||||
|
var $clear = $( '#brezngeo-geo-clear' );
|
||||||
|
var $status = $( '#brezngeo-geo-status' );
|
||||||
|
var $summary = $( '#brezngeo-geo-summary' );
|
||||||
|
var $bullets = $( '#brezngeo-geo-bullets' );
|
||||||
|
var $faq = $( '#brezngeo-geo-faq' );
|
||||||
|
var $lock = $( '#brezngeo-geo-lock' );
|
||||||
|
|
||||||
|
function setStatus( msg, isError ) {
|
||||||
|
$status.text( msg ).css( 'color', isError ? '#dc3232' : '#46b450' );
|
||||||
|
if ( msg ) {
|
||||||
|
setTimeout( function () { $status.text( '' ); }, 4000 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFields( data ) {
|
||||||
|
$summary.val( data.summary || '' );
|
||||||
|
$bullets.val( ( data.bullets || [] ).join( '\n' ) );
|
||||||
|
var faqLines = ( data.faq || [] ).map( function ( item ) {
|
||||||
|
return item.q + ' | ' + item.a;
|
||||||
|
} );
|
||||||
|
$faq.val( faqLines.join( '\n' ) );
|
||||||
|
// AI-generated content resets the lock
|
||||||
|
$lock.prop( 'checked', false );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track manual edits → auto-set lock to protect from overwrite
|
||||||
|
$summary.add( $bullets ).add( $faq ).on( 'input', function () {
|
||||||
|
$lock.prop( 'checked', true );
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( $generate.length ) {
|
||||||
|
$generate.on( 'click', function () {
|
||||||
|
$generate.prop( 'disabled', true ).text( '…' );
|
||||||
|
setStatus( '' );
|
||||||
|
$.post( ajaxurl, {
|
||||||
|
action: 'brezngeo_geo_generate',
|
||||||
|
nonce: nonce,
|
||||||
|
post_id: postId,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
populateFields( res.data );
|
||||||
|
setStatus( 'Generated ✓', false );
|
||||||
|
$generate.text( 'Regenerate' );
|
||||||
|
} else {
|
||||||
|
setStatus( res.data || 'Error', true );
|
||||||
|
}
|
||||||
|
} ).fail( function () {
|
||||||
|
setStatus( 'Connection error', true );
|
||||||
|
} ).always( function () {
|
||||||
|
$generate.prop( 'disabled', false );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $clear.length ) {
|
||||||
|
$clear.on( 'click', function () {
|
||||||
|
if ( ! window.confirm( 'Really clear GEO fields?' ) ) return;
|
||||||
|
$clear.prop( 'disabled', true );
|
||||||
|
$.post( ajaxurl, {
|
||||||
|
action: 'brezngeo_geo_clear',
|
||||||
|
nonce: nonce,
|
||||||
|
post_id: postId,
|
||||||
|
} ).done( function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
$summary.val( '' );
|
||||||
|
$bullets.val( '' );
|
||||||
|
$faq.val( '' );
|
||||||
|
$lock.prop( 'checked', false );
|
||||||
|
setStatus( 'Cleared', false );
|
||||||
|
}
|
||||||
|
} ).always( function () {
|
||||||
|
$clear.prop( 'disabled', false );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
} );
|
||||||
160
brezngeo/assets/geo-frontend.css
Normal file
160
brezngeo/assets/geo-frontend.css
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/* BreznGEO — GEO Block (scoped to .brezngeo-geo) */
|
||||||
|
|
||||||
|
/* ── Base layout + Light theme (default) ──────────────── */
|
||||||
|
.brezngeo-geo,
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="light"] {
|
||||||
|
--brezngeo-border: #e0e0e0;
|
||||||
|
--brezngeo-bg: #fafafa;
|
||||||
|
--brezngeo-sec-border:#e0e0e0;
|
||||||
|
--brezngeo-label: #666;
|
||||||
|
--brezngeo-faq-ans: #444;
|
||||||
|
--brezngeo-accent: #0073aa;
|
||||||
|
|
||||||
|
margin: 1.5em 0;
|
||||||
|
border: 1px solid var(--brezngeo-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--brezngeo-bg);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark theme ───────────────────────────────────────── */
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="dark"] {
|
||||||
|
--brezngeo-border: #3d3d3d;
|
||||||
|
--brezngeo-bg: #1e1e1e;
|
||||||
|
--brezngeo-sec-border:#3d3d3d;
|
||||||
|
--brezngeo-label: #999;
|
||||||
|
--brezngeo-faq-ans: #bbb;
|
||||||
|
--brezngeo-accent: #4ea8d8;
|
||||||
|
|
||||||
|
border: 1px solid var(--brezngeo-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--brezngeo-bg);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Minimal theme ────────────────────────────────────── */
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="minimal"] {
|
||||||
|
--brezngeo-sec-border:#efefef;
|
||||||
|
--brezngeo-label: #999;
|
||||||
|
--brezngeo-faq-ans: #666;
|
||||||
|
|
||||||
|
margin: 1.5em 0;
|
||||||
|
border: none;
|
||||||
|
border-left: 2px solid #d0d0d0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bavarian theme ───────────────────────────────────── */
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="bavarian"] {
|
||||||
|
--brezngeo-border: #0052a0;
|
||||||
|
--brezngeo-bg: #f0f5fc;
|
||||||
|
--brezngeo-sec-border:#c5d8f5;
|
||||||
|
--brezngeo-label: #003d82;
|
||||||
|
--brezngeo-faq-ans: #2a4a7f;
|
||||||
|
--brezngeo-accent: #0066b3;
|
||||||
|
|
||||||
|
border: 1px solid var(--brezngeo-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--brezngeo-bg);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Summary bar ──────────────────────────────────────── */
|
||||||
|
.brezngeo-geo summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-left: 3px solid var(--brezngeo-accent);
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.brezngeo-geo summary::before {
|
||||||
|
content: '▶';
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
font-size: 0.7em;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
color: var(--brezngeo-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo[open] summary::before { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
.brezngeo-geo__title { flex: 1; }
|
||||||
|
|
||||||
|
/* ── Minimal summary override ─────────────────────────── */
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="minimal"] summary {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
font-weight: 500;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="minimal"] summary::before {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bavarian summary: Rauten (diamond) pattern ───────── */
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="bavarian"] summary {
|
||||||
|
background-color: #0066b3;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, rgba(255,255,255,0.18) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(255,255,255,0.18) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.18) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.18) 75%);
|
||||||
|
background-size: 10px 10px;
|
||||||
|
background-position: 0 0, 0 5px, 5px -5px, -5px 0;
|
||||||
|
color: #fff;
|
||||||
|
border-left-color: #003d82;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="bavarian"] summary .brezngeo-geo__title {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="bavarian"] summary::before {
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Content sections ─────────────────────────────────── */
|
||||||
|
.brezngeo-geo__section {
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border-top: 1px solid var(--brezngeo-sec-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo[data-brezngeo-theme="minimal"] .brezngeo-geo__section {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo__section h3 {
|
||||||
|
font-size: 0.8em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--brezngeo-label);
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo__bullets ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo__bullets li { margin-bottom: 0.25em; }
|
||||||
|
|
||||||
|
.brezngeo-geo__faq dl { margin: 0; }
|
||||||
|
|
||||||
|
.brezngeo-geo__faq dt {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brezngeo-geo__faq dd {
|
||||||
|
margin-left: 0;
|
||||||
|
color: var(--brezngeo-faq-ans);
|
||||||
|
}
|
||||||
300
brezngeo/assets/link-suggest.js
Normal file
300
brezngeo/assets/link-suggest.js
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
/* global jQuery, wp, bavrankLinkSuggest, tinyMCE */
|
||||||
|
( function ( $ ) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if ( typeof bavrankLinkSuggest === 'undefined' ) { return; }
|
||||||
|
|
||||||
|
var cfg = bavrankLinkSuggest;
|
||||||
|
var i18n = cfg.i18n;
|
||||||
|
var suggestions = [];
|
||||||
|
var isRunning = false;
|
||||||
|
|
||||||
|
/* ── Helpers ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function getContent() {
|
||||||
|
if ( window.wp && wp.data && wp.data.select( 'core/editor' ) ) {
|
||||||
|
try {
|
||||||
|
return wp.data.select( 'core/editor' ).getEditedPostContent();
|
||||||
|
} catch ( e ) { /* fall through */ }
|
||||||
|
}
|
||||||
|
if ( typeof tinyMCE !== 'undefined' && tinyMCE.activeEditor && ! tinyMCE.activeEditor.isHidden() ) {
|
||||||
|
return tinyMCE.activeEditor.getContent();
|
||||||
|
}
|
||||||
|
return $( '#content' ).val() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Core: request suggestions ───────────────────────── */
|
||||||
|
|
||||||
|
function triggerAnalysis() {
|
||||||
|
if ( isRunning ) { return; }
|
||||||
|
var content = getContent();
|
||||||
|
if ( ! content ) { return; }
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
$( '#brezngeo-ls-status' ).text( i18n.loading );
|
||||||
|
$( '#brezngeo-ls-results' ).hide();
|
||||||
|
$( '#brezngeo-ls-applied' ).hide();
|
||||||
|
|
||||||
|
$.post( cfg.ajaxUrl, {
|
||||||
|
action: 'brezngeo_link_suggestions',
|
||||||
|
nonce: cfg.nonce,
|
||||||
|
post_id: cfg.postId,
|
||||||
|
post_content: content,
|
||||||
|
} )
|
||||||
|
.done( function ( res ) {
|
||||||
|
if ( res && res.success ) {
|
||||||
|
suggestions = res.data || [];
|
||||||
|
renderSuggestions();
|
||||||
|
} else {
|
||||||
|
$( '#brezngeo-ls-status' ).text( i18n.networkError );
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
.fail( function () {
|
||||||
|
$( '#brezngeo-ls-status' ).text( i18n.networkError );
|
||||||
|
} )
|
||||||
|
.always( function () {
|
||||||
|
isRunning = false;
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render suggestion list ──────────────────────────── */
|
||||||
|
|
||||||
|
function renderSuggestions() {
|
||||||
|
var $list = $( '#brezngeo-ls-list' ).empty();
|
||||||
|
var $results = $( '#brezngeo-ls-results' );
|
||||||
|
var $actions = $( '#brezngeo-ls-actions' );
|
||||||
|
var $status = $( '#brezngeo-ls-status' );
|
||||||
|
|
||||||
|
if ( ! suggestions.length ) {
|
||||||
|
$status.text( i18n.noResults );
|
||||||
|
$results.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status.text( '' );
|
||||||
|
|
||||||
|
suggestions.forEach( function ( s, idx ) {
|
||||||
|
var $row = $( '<div class="brezngeo-ls-row" style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid #f0f0f0;">' );
|
||||||
|
var $cb = $( '<input type="checkbox" class="brezngeo-ls-cb">' ).data( 'idx', idx );
|
||||||
|
var $info = $( '<div style="flex:1;font-size:12px;">' );
|
||||||
|
var badge = s.boosted ? ' <span style="color:#f0a500;font-size:10px;" title="' + esc( i18n.boosted ) + '">★</span>' : '';
|
||||||
|
$info.html(
|
||||||
|
'<strong>' + esc( '\u201c' + s.phrase + '\u201d' ) + '</strong>' + badge +
|
||||||
|
'<br><span style="color:#555;">\u2192 ' + esc( s.post_title ) + '</span>'
|
||||||
|
);
|
||||||
|
var $open = $( '<a href="' + esc( s.url ) + '" target="_blank" rel="noopener" style="font-size:11px;white-space:nowrap;" title="' + esc( i18n.openPost ) + '">[↗]</a>' );
|
||||||
|
$row.append( $cb, $info, $open );
|
||||||
|
$list.append( $row );
|
||||||
|
} );
|
||||||
|
|
||||||
|
$results.show();
|
||||||
|
$actions.css( 'display', 'flex' );
|
||||||
|
updateApplyButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc( str ) {
|
||||||
|
return $( '<div>' ).text( str ).html();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateApplyButton() {
|
||||||
|
var count = $( '.brezngeo-ls-cb:checked' ).length;
|
||||||
|
var label = i18n.applyBtn.replace( '%d', count );
|
||||||
|
$( '#brezngeo-ls-apply' ).text( label ).prop( 'disabled', count === 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Apply selected ──────────────────────────────────── */
|
||||||
|
|
||||||
|
function applySelected() {
|
||||||
|
var selected = [];
|
||||||
|
$( '.brezngeo-ls-cb:checked' ).each( function () {
|
||||||
|
var idx = $( this ).data( 'idx' );
|
||||||
|
if ( suggestions[ idx ] ) {
|
||||||
|
selected.push( suggestions[ idx ] );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
if ( ! selected.length ) { return; }
|
||||||
|
|
||||||
|
// Build preview
|
||||||
|
var lines = selected.map( function ( s ) {
|
||||||
|
return '\u201c' + s.phrase + '\u201d \u2192 ' + s.post_title;
|
||||||
|
} ).join( '\n' );
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
if ( ! window.confirm( i18n.preview + ':\n\n' + lines + '\n\n' + i18n.confirm + '?' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = getContent();
|
||||||
|
var applied = 0;
|
||||||
|
selected.forEach( function ( s ) {
|
||||||
|
var result = insertLink( content, s.phrase, s.url );
|
||||||
|
if ( result !== content ) {
|
||||||
|
content = result;
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( applied ) {
|
||||||
|
setContent( content );
|
||||||
|
$( '#brezngeo-ls-results' ).hide();
|
||||||
|
$( '#brezngeo-ls-applied' ).text( i18n.applied.replace( '%d', applied ) ).show();
|
||||||
|
suggestions = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace first occurrence of phrase (outside <a>) with a link.
|
||||||
|
*/
|
||||||
|
function insertLink( html, phrase, url ) {
|
||||||
|
var escaped = phrase.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
|
||||||
|
var re = new RegExp( '(?<!["\'>])(' + escaped + ')(?![^<]*</a>)', 'i' );
|
||||||
|
var replaced = false;
|
||||||
|
return html.replace( re, function ( match ) {
|
||||||
|
if ( replaced ) { return match; }
|
||||||
|
replaced = true;
|
||||||
|
return '<a href="' + url + '">' + match + '</a>';
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContent( html ) {
|
||||||
|
// Gutenberg
|
||||||
|
if ( window.wp && wp.data && wp.data.dispatch ) {
|
||||||
|
try {
|
||||||
|
var blocks = wp.blocks.parse( html );
|
||||||
|
wp.data.dispatch( 'core/block-editor' ).resetBlocks( blocks );
|
||||||
|
return;
|
||||||
|
} catch ( e ) { /* fall through to classic */ }
|
||||||
|
}
|
||||||
|
// Classic / TinyMCE
|
||||||
|
if ( typeof tinyMCE !== 'undefined' && tinyMCE.activeEditor && ! tinyMCE.activeEditor.isHidden() ) {
|
||||||
|
tinyMCE.activeEditor.setContent( html );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$( '#content' ).val( html );
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Event bindings ──────────────────────────────────── */
|
||||||
|
|
||||||
|
$( document ).on( 'click', '#brezngeo-ls-analyse', triggerAnalysis );
|
||||||
|
$( document ).on( 'click', '#brezngeo-ls-apply', applySelected );
|
||||||
|
$( document ).on( 'click', '#brezngeo-ls-select-all', function () { $( '.brezngeo-ls-cb' ).prop( 'checked', true ); updateApplyButton(); } );
|
||||||
|
$( document ).on( 'click', '#brezngeo-ls-select-none', function () { $( '.brezngeo-ls-cb' ).prop( 'checked', false ); updateApplyButton(); } );
|
||||||
|
$( document ).on( 'change', '.brezngeo-ls-cb', updateApplyButton );
|
||||||
|
|
||||||
|
/* ── Trigger mode ────────────────────────────────────── */
|
||||||
|
|
||||||
|
if ( cfg.triggerMode === 'interval' && cfg.intervalMs > 0 ) {
|
||||||
|
setInterval( triggerAnalysis, cfg.intervalMs );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( cfg.triggerMode === 'save' ) {
|
||||||
|
// Gutenberg
|
||||||
|
if ( window.wp && wp.data ) {
|
||||||
|
var wasSaving = false;
|
||||||
|
wp.data.subscribe( function () {
|
||||||
|
var isSaving = wp.data.select( 'core/editor' ) &&
|
||||||
|
wp.data.select( 'core/editor' ).isSavingPost();
|
||||||
|
if ( ! wasSaving && isSaving ) {
|
||||||
|
triggerAnalysis();
|
||||||
|
}
|
||||||
|
wasSaving = isSaving;
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
// Classic
|
||||||
|
$( document ).on( 'click', '#publish, #save-post', function () {
|
||||||
|
setTimeout( triggerAnalysis, 500 );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings page: post search for exclude/boost ────── */
|
||||||
|
|
||||||
|
function initPostSearch( $input, $results, onSelect ) {
|
||||||
|
var timer;
|
||||||
|
$input.on( 'input', function () {
|
||||||
|
clearTimeout( timer );
|
||||||
|
var q = $input.val().trim();
|
||||||
|
if ( q.length < 2 ) { $results.hide(); return; }
|
||||||
|
timer = setTimeout( function () {
|
||||||
|
$.ajax( {
|
||||||
|
url: cfg.restUrl,
|
||||||
|
data: { search: q, type: 'post', subtype: 'any', per_page: 10, _fields: 'id,title,url' },
|
||||||
|
headers: { 'X-WP-Nonce': cfg.restNonce },
|
||||||
|
} ).done( function ( items ) {
|
||||||
|
$results.empty().show();
|
||||||
|
if ( ! items.length ) {
|
||||||
|
$results.append( '<div style="padding:6px;">No results</div>' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach( function ( item ) {
|
||||||
|
$( '<div style="padding:6px;cursor:pointer;" class="brezngeo-ls-result-item">' )
|
||||||
|
.text( item.title.rendered || item.title )
|
||||||
|
.data( 'item', item )
|
||||||
|
.on( 'click', function () {
|
||||||
|
onSelect( item );
|
||||||
|
$results.hide();
|
||||||
|
$input.val( '' );
|
||||||
|
} )
|
||||||
|
.appendTo( $results );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}, 300 );
|
||||||
|
} );
|
||||||
|
$( document ).on( 'click', function ( e ) {
|
||||||
|
if ( ! $input.is( e.target ) ) { $results.hide(); }
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude search
|
||||||
|
if ( $( '#brezngeo-ls-exclude-search' ).length ) {
|
||||||
|
initPostSearch(
|
||||||
|
$( '#brezngeo-ls-exclude-search' ),
|
||||||
|
$( '#brezngeo-ls-exclude-results' ),
|
||||||
|
function ( item ) {
|
||||||
|
var id = item.id;
|
||||||
|
var title = item.title.rendered || item.title;
|
||||||
|
var fieldName = 'brezngeo_link_suggest_settings[excluded_posts][]';
|
||||||
|
$( '#brezngeo-ls-excluded-list' ).append(
|
||||||
|
$( '<span class="brezngeo-ls-tag" style="display:inline-flex;align-items:center;gap:4px;background:#e0e0e0;padding:2px 8px;border-radius:3px;margin:2px;">' )
|
||||||
|
.data( 'id', id )
|
||||||
|
.append(
|
||||||
|
$( '<span>' ).text( title ),
|
||||||
|
$( '<input type="hidden">' ).attr( 'name', fieldName ).val( id ),
|
||||||
|
$( '<button type="button" class="brezngeo-ls-remove" style="background:none;border:none;cursor:pointer;color:#555;">✕</button>' )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost search
|
||||||
|
if ( $( '#brezngeo-ls-boost-search' ).length ) {
|
||||||
|
initPostSearch(
|
||||||
|
$( '#brezngeo-ls-boost-search' ),
|
||||||
|
$( '#brezngeo-ls-boost-results' ),
|
||||||
|
function ( item ) {
|
||||||
|
var id = item.id;
|
||||||
|
var title = item.title.rendered || item.title;
|
||||||
|
var idx = $( '.brezngeo-ls-boost-row' ).length;
|
||||||
|
var base = 'brezngeo_link_suggest_settings[boosted_posts][' + idx + ']';
|
||||||
|
$( '#brezngeo-ls-boosted-list' ).append(
|
||||||
|
$( '<div class="brezngeo-ls-boost-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">' ).append(
|
||||||
|
$( '<span>' ).text( '\u2605 ' + title ),
|
||||||
|
$( '<input type="hidden">' ).attr( 'name', base + '[id]' ).val( id ),
|
||||||
|
$( '<label>' ).append(
|
||||||
|
'Boost: ',
|
||||||
|
$( '<input type="number" step="0.1" min="1" max="10" style="width:60px;">' )
|
||||||
|
.attr( 'name', base + '[boost]' ).val( '1.5' )
|
||||||
|
),
|
||||||
|
$( '<button type="button" class="button brezngeo-ls-remove">Remove</button>' )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove tag / boost row
|
||||||
|
$( document ).on( 'click', '.brezngeo-ls-remove', function () {
|
||||||
|
$( this ).closest( '.brezngeo-ls-tag, .brezngeo-ls-boost-row' ).remove();
|
||||||
|
} );
|
||||||
|
|
||||||
|
} )( jQuery );
|
||||||
15
brezngeo/assets/schema-meta-box.js
Normal file
15
brezngeo/assets/schema-meta-box.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
( function () {
|
||||||
|
var sel = document.getElementById( 'brezngeo-schema-type' );
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
var v = sel.value;
|
||||||
|
document.querySelectorAll( '.brezngeo-schema-fields' ).forEach( function ( el ) {
|
||||||
|
el.style.display = el.dataset.breType === v ? '' : 'none';
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( sel ) {
|
||||||
|
sel.addEventListener( 'change', toggle );
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
} )();
|
||||||
112
brezngeo/assets/seo-widget.js
Normal file
112
brezngeo/assets/seo-widget.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/* global jQuery, wp, brezngeoWidget */
|
||||||
|
jQuery( function ( $ ) {
|
||||||
|
var $widget = $( '#brezngeo-seo-widget' );
|
||||||
|
if ( ! $widget.length ) return;
|
||||||
|
|
||||||
|
var siteUrl = $widget.data( 'site-url' ) || window.location.origin;
|
||||||
|
var themeHasH1 = brezngeoWidget && brezngeoWidget.themeHasH1;
|
||||||
|
var locale = ( brezngeoWidget && brezngeoWidget.locale ) ? brezngeoWidget.locale : navigator.language;
|
||||||
|
var debounce = null;
|
||||||
|
|
||||||
|
function getContent() {
|
||||||
|
// Block editor
|
||||||
|
if ( window.wp && wp.data && wp.data.select( 'core/editor' ) ) {
|
||||||
|
try {
|
||||||
|
var blocks = wp.data.select( 'core/editor' ).getBlocks();
|
||||||
|
return blocks.map( function ( b ) {
|
||||||
|
return ( b.attributes && b.attributes.content ) ? b.attributes.content : '';
|
||||||
|
} ).join( ' ' );
|
||||||
|
} catch ( e ) { return ''; }
|
||||||
|
}
|
||||||
|
// Classic editor (TinyMCE or textarea)
|
||||||
|
if ( typeof tinyMCE !== 'undefined' && tinyMCE.activeEditor && ! tinyMCE.activeEditor.isHidden() ) {
|
||||||
|
return tinyMCE.activeEditor.getContent();
|
||||||
|
}
|
||||||
|
return $( '#content' ).val() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle() {
|
||||||
|
if ( window.wp && wp.data && wp.data.select( 'core/editor' ) ) {
|
||||||
|
try {
|
||||||
|
return wp.data.select( 'core/editor' ).getEditedPostAttribute( 'title' ) || '';
|
||||||
|
} catch ( e ) { return ''; }
|
||||||
|
}
|
||||||
|
return $( '#title' ).val() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyse() {
|
||||||
|
var content = getContent();
|
||||||
|
var title = getTitle();
|
||||||
|
var plain = content.replace( /<[^>]+>/g, ' ' ).replace( /\s+/g, ' ' ).trim();
|
||||||
|
var words = plain ? plain.split( /\s+/ ).length : 0;
|
||||||
|
var readMin = Math.max( 1, Math.ceil( words / 200 ) );
|
||||||
|
var minLabel = ( brezngeoWidget && brezngeoWidget.minLabel ) ? brezngeoWidget.minLabel : 'min';
|
||||||
|
|
||||||
|
$( '#brezngeo-title-stat' ).text( title.length + ' / 60' );
|
||||||
|
$( '#brezngeo-words-stat' ).text( words.toLocaleString( locale ) );
|
||||||
|
$( '#brezngeo-read-stat' ).text( '~' + readMin + ' ' + minLabel );
|
||||||
|
|
||||||
|
// Headings — count from HTML tags
|
||||||
|
var h = { h1: 0, h2: 0, h3: 0, h4: 0 };
|
||||||
|
( content.match( /<h([1-4])[\s>]/gi ) || [] ).forEach( function ( tag ) {
|
||||||
|
var level = 'h' + tag.replace( /<h/i, '' )[0];
|
||||||
|
if ( h[ level ] !== undefined ) h[ level ]++;
|
||||||
|
} );
|
||||||
|
|
||||||
|
var hParts = [];
|
||||||
|
[ 'h1', 'h2', 'h3', 'h4' ].forEach( function ( tag ) {
|
||||||
|
if ( h[ tag ] > 0 ) hParts.push( h[ tag ] + '\u00D7 ' + tag.toUpperCase() );
|
||||||
|
} );
|
||||||
|
var noneLabel = ( brezngeoWidget && brezngeoWidget.none ) ? brezngeoWidget.none : 'None';
|
||||||
|
$( '#brezngeo-headings-stat' ).text( hParts.length ? hParts.join( ' ' ) : noneLabel );
|
||||||
|
|
||||||
|
// Links
|
||||||
|
var allLinks = content.match( /href="([^"]+)"/gi ) || [];
|
||||||
|
var siteHost = siteUrl.replace( /https?:\/\//, '' ).replace( /\/$/, '' );
|
||||||
|
var internal = 0;
|
||||||
|
var external = 0;
|
||||||
|
|
||||||
|
allLinks.forEach( function ( tag ) {
|
||||||
|
var href = ( tag.match( /href="([^"]+)"/ ) || [] )[1] || '';
|
||||||
|
if ( href.indexOf( '/' ) === 0 || href.indexOf( siteUrl ) === 0 || href.indexOf( siteHost ) !== -1 ) {
|
||||||
|
internal++;
|
||||||
|
} else if ( /^https?:\/\//.test( href ) ) {
|
||||||
|
external++;
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
var intLabel = ( brezngeoWidget && brezngeoWidget.internal ) ? brezngeoWidget.internal : 'internal';
|
||||||
|
var extLabel = ( brezngeoWidget && brezngeoWidget.external ) ? brezngeoWidget.external : 'external';
|
||||||
|
$( '#brezngeo-links-stat' ).text( internal + ' ' + intLabel + ' ' + external + ' ' + extLabel );
|
||||||
|
|
||||||
|
// Warnings
|
||||||
|
var warnings = [];
|
||||||
|
var noH1Label = ( brezngeoWidget && brezngeoWidget.noH1 ) ? brezngeoWidget.noH1 : 'No H1 heading';
|
||||||
|
var multiH1Label = ( brezngeoWidget && brezngeoWidget.multipleH1 ) ? brezngeoWidget.multipleH1 : 'Multiple H1 headings';
|
||||||
|
var noLinksLabel = ( brezngeoWidget && brezngeoWidget.noInternalLinks ) ? brezngeoWidget.noInternalLinks : 'No internal links';
|
||||||
|
|
||||||
|
if ( h.h1 === 0 && ! themeHasH1 ) warnings.push( '\u26A0 ' + noH1Label );
|
||||||
|
if ( h.h1 > 1 ) warnings.push( '\u26A0 ' + multiH1Label + ' (' + h.h1 + ')' );
|
||||||
|
if ( internal === 0 && words > 50 ) warnings.push( '\u26A0 ' + noLinksLabel );
|
||||||
|
$( '#brezngeo-seo-warnings' ).html( warnings.join( '<br>' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledAnalyse() {
|
||||||
|
clearTimeout( debounce );
|
||||||
|
debounce = setTimeout( analyse, 500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block editor
|
||||||
|
if ( window.wp && wp.data ) {
|
||||||
|
wp.data.subscribe( scheduledAnalyse );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classic editor
|
||||||
|
$( document ).on( 'input change', '#content', scheduledAnalyse );
|
||||||
|
$( document ).on( 'tinymce-editor-init', function ( event, editor ) {
|
||||||
|
editor.on( 'KeyUp Change SetContent', scheduledAnalyse );
|
||||||
|
} );
|
||||||
|
$( '#title' ).on( 'input', scheduledAnalyse );
|
||||||
|
|
||||||
|
analyse();
|
||||||
|
} );
|
||||||
44
brezngeo/brezngeo.php
Normal file
44
brezngeo/brezngeo.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: BreznGEO
|
||||||
|
* Plugin URI: https://brezngeo.com/
|
||||||
|
* Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress.
|
||||||
|
* Version: 1.3.5
|
||||||
|
* Requires at least: 6.0
|
||||||
|
* Requires PHP: 8.0
|
||||||
|
* Author: NoSchmarrn.dev
|
||||||
|
* Author URI: https://noschmarrn.dev/
|
||||||
|
* License: GPLv2 or later
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
* Text Domain: brezngeo
|
||||||
|
* Domain Path: /languages
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
define( 'BREZNGEO_VERSION', '1.3.5' );
|
||||||
|
define( 'BREZNGEO_FILE', __FILE__ );
|
||||||
|
define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Core.php';
|
||||||
|
|
||||||
|
add_action( 'plugins_loaded', static function (): void {
|
||||||
|
\BreznGEO\Core::instance()->init();
|
||||||
|
} );
|
||||||
|
|
||||||
|
register_activation_hook(
|
||||||
|
BREZNGEO_FILE,
|
||||||
|
function () {
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/RobotsTxt.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/CrawlerLog.php';
|
||||||
|
\BreznGEO\Features\CrawlerLog::install();
|
||||||
|
add_rewrite_rule( '^llms\.txt$', 'index.php?brezngeo_llms=1', 'top' );
|
||||||
|
flush_rewrite_rules();
|
||||||
|
if ( ! get_option( 'brezngeo_first_activated' ) ) {
|
||||||
|
update_option( 'brezngeo_first_activated', time() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
333
brezngeo/includes/Admin/AdminMenu.php
Normal file
333
brezngeo/includes/Admin/AdminMenu.php
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminMenu {
|
||||||
|
public const OPTION_KEY_AI_FEATURES = 'brezngeo_ai_features';
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_menu', array( $this, 'add_menus' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_dismiss_welcome', array( $this, 'ajax_dismiss_welcome' ) );
|
||||||
|
add_action( 'admin_post_brezngeo_save_ai_features', array( $this, 'save_ai_features' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get_ai_features(): array {
|
||||||
|
$defaults = array(
|
||||||
|
'meta' => false,
|
||||||
|
'geo' => false,
|
||||||
|
'links' => false,
|
||||||
|
);
|
||||||
|
$saved = get_option( self::OPTION_KEY_AI_FEATURES, array() );
|
||||||
|
$saved = is_array( $saved ) ? $saved : array();
|
||||||
|
return array_merge( $defaults, $saved );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save_ai_features(): void {
|
||||||
|
check_admin_referer( 'brezngeo_save_ai_features' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_die( esc_html__( 'Insufficient permissions.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_admin_referer() above
|
||||||
|
$input = isset( $_POST['brezngeo_ai_features'] ) && is_array( $_POST['brezngeo_ai_features'] )
|
||||||
|
? array_map( 'sanitize_text_field', wp_unslash( $_POST['brezngeo_ai_features'] ) )
|
||||||
|
: array();
|
||||||
|
|
||||||
|
update_option(
|
||||||
|
self::OPTION_KEY_AI_FEATURES,
|
||||||
|
array(
|
||||||
|
'meta' => ! empty( $input['meta'] ),
|
||||||
|
'geo' => ! empty( $input['geo'] ),
|
||||||
|
'links' => ! empty( $input['links'] ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_safe_redirect(
|
||||||
|
add_query_arg(
|
||||||
|
array(
|
||||||
|
'page' => 'brezngeo',
|
||||||
|
'brezngeo-saved' => '1',
|
||||||
|
),
|
||||||
|
admin_url( 'admin.php' )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'toplevel_page_brezngeo' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
wp_enqueue_script( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-admin',
|
||||||
|
'brezngeoAdmin',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'testing' => __( 'Testing…', 'brezngeo' ),
|
||||||
|
'networkError' => __( 'Network error', 'brezngeo' ),
|
||||||
|
'resetConfirm' => __( 'Really reset the prompt?', 'brezngeo' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-admin',
|
||||||
|
'brezngeoL10n',
|
||||||
|
array(
|
||||||
|
'analysisError' => __( 'Analysis error.', 'brezngeo' ),
|
||||||
|
'noLinksHeading' => __( 'Posts without internal links', 'brezngeo' ),
|
||||||
|
'allLinked' => __( 'All posts have internal links.', 'brezngeo' ),
|
||||||
|
'manyExternalPre' => __( 'Posts with many external links (≥', 'brezngeo' ),
|
||||||
|
'noExternalIssues' => __( 'No posts with excessive external links.', 'brezngeo' ),
|
||||||
|
'pillarHeading' => __( 'Pillar Pages (Top 5)', 'brezngeo' ),
|
||||||
|
'noData' => __( 'No data.', 'brezngeo' ),
|
||||||
|
'connectionError' => __( 'Connection error.', 'brezngeo' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_menus(): void {
|
||||||
|
add_menu_page(
|
||||||
|
__( 'BreznGEO', 'brezngeo' ),
|
||||||
|
__( 'BreznGEO', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo',
|
||||||
|
array( $this, 'render_dashboard' ),
|
||||||
|
'dashicons-chart-area',
|
||||||
|
80
|
||||||
|
);
|
||||||
|
|
||||||
|
// First submenu replaces the parent menu link
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'Dashboard', 'brezngeo' ),
|
||||||
|
__( 'Dashboard', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo',
|
||||||
|
array( $this, 'render_dashboard' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'AI Provider', 'brezngeo' ),
|
||||||
|
__( 'AI Provider', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-provider',
|
||||||
|
array( new ProviderPage(), 'render' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'Meta Generator', 'brezngeo' ),
|
||||||
|
__( 'Meta Generator', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-meta',
|
||||||
|
array( new MetaPage(), 'render' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'Schema.org', 'brezngeo' ),
|
||||||
|
__( 'Schema.org', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-schema',
|
||||||
|
array( new SchemaPage(), 'render' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'TXT Files', 'brezngeo' ),
|
||||||
|
__( 'TXT Files', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-txt',
|
||||||
|
array( new TxtPage(), 'render' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'Bulk Generator', 'brezngeo' ),
|
||||||
|
__( 'Bulk Generator', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-bulk',
|
||||||
|
array( new BulkPage(), 'render' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'Link Suggestions', 'brezngeo' ),
|
||||||
|
__( 'Link Suggestions', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-link-suggest',
|
||||||
|
array( new LinkSuggestPage(), 'render' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_submenu_page(
|
||||||
|
'brezngeo',
|
||||||
|
__( 'GEO Quick Overview', 'brezngeo' ),
|
||||||
|
__( 'GEO Block', 'brezngeo' ),
|
||||||
|
'manage_options',
|
||||||
|
'brezngeo-geo',
|
||||||
|
array( new GeoPage(), 'render' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_dashboard(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$provider_key = $settings['provider'] ?? 'openai';
|
||||||
|
$api_key = $settings['api_keys'][ $provider_key ] ?? '';
|
||||||
|
$ai_enabled = $settings['ai_enabled'] ?? false;
|
||||||
|
$has_ai = $ai_enabled && ! empty( $api_key );
|
||||||
|
|
||||||
|
if ( ! $ai_enabled ) {
|
||||||
|
$provider = __( 'AI disabled', 'brezngeo' );
|
||||||
|
} elseif ( empty( $api_key ) ) {
|
||||||
|
$provider = __( '— Not configured —', 'brezngeo' );
|
||||||
|
} else {
|
||||||
|
$prov_obj = \BreznGEO\ProviderRegistry::instance()->get( $provider_key );
|
||||||
|
$provider = $prov_obj ? $prov_obj->getName() : $provider_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
|
||||||
|
$meta_stats = $this->get_meta_stats( $post_types );
|
||||||
|
$bre_compat = $this->get_compat_info();
|
||||||
|
|
||||||
|
$bre_show_welcome = $this->should_show_welcome();
|
||||||
|
|
||||||
|
$usage_stats = get_option(
|
||||||
|
'brezngeo_usage_stats',
|
||||||
|
array(
|
||||||
|
'tokens_in' => 0,
|
||||||
|
'tokens_out' => 0,
|
||||||
|
'count' => 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$model = $settings['models'][ $provider_key ] ?? '';
|
||||||
|
$costs_config = $settings['costs'][ $provider_key ][ $model ] ?? array();
|
||||||
|
$cost_usd = null;
|
||||||
|
if ( ! empty( $costs_config['input'] ) || ! empty( $costs_config['output'] ) ) {
|
||||||
|
$cost_usd = round(
|
||||||
|
( (int) ( $usage_stats['tokens_in'] ?? 0 ) / 1_000_000 ) * (float) ( $costs_config['input'] ?? 0 )
|
||||||
|
+ ( (int) ( $usage_stats['tokens_out'] ?? 0 ) / 1_000_000 ) * (float) ( $costs_config['output'] ?? 0 ),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$crawlers = get_transient( 'brezngeo_crawler_summary' );
|
||||||
|
if ( false === $crawlers ) {
|
||||||
|
$crawlers = \BreznGEO\Features\CrawlerLog::get_recent_summary( 30 );
|
||||||
|
set_transient( 'brezngeo_crawler_summary', $crawlers, 5 * MINUTE_IN_SECONDS );
|
||||||
|
}
|
||||||
|
|
||||||
|
$ai_features = self::get_ai_features();
|
||||||
|
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/dashboard.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function should_show_welcome(): bool {
|
||||||
|
if ( get_user_meta( get_current_user_id(), 'brezngeo_welcome_dismissed', true ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$activated = (int) get_option( 'brezngeo_first_activated', 0 );
|
||||||
|
if ( ! $activated ) {
|
||||||
|
// First admin visit on a legacy install — set timestamp now and show
|
||||||
|
update_option( 'brezngeo_first_activated', time() );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ( time() - $activated ) < DAY_IN_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_dismiss_welcome(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error();
|
||||||
|
}
|
||||||
|
update_user_meta( get_current_user_id(), 'brezngeo_welcome_dismissed', 1 );
|
||||||
|
wp_send_json_success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_meta_stats( array $post_types ): array {
|
||||||
|
$cache_key = 'brezngeo_meta_stats';
|
||||||
|
$cached = get_transient( $cache_key );
|
||||||
|
if ( false !== $cached ) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$stats = array();
|
||||||
|
foreach ( $post_types as $pt ) {
|
||||||
|
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
|
||||||
|
$pt
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$with_meta = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
|
||||||
|
INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
|
||||||
|
WHERE p.post_type = %s AND p.post_status = 'publish'
|
||||||
|
AND pm.meta_key = %s AND pm.meta_value != ''",
|
||||||
|
$pt,
|
||||||
|
'_brezngeo_meta_description'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$stats[ $pt ] = array(
|
||||||
|
'total' => $total,
|
||||||
|
'with_meta' => $with_meta,
|
||||||
|
'pct' => $total > 0 ? round( ( $with_meta / $total ) * 100 ) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient( $cache_key, $stats, 5 * MINUTE_IN_SECONDS );
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_compat_info(): array {
|
||||||
|
$compat = array();
|
||||||
|
if ( defined( 'RANK_MATH_VERSION' ) ) {
|
||||||
|
$compat[] = array(
|
||||||
|
'name' => 'Rank Math',
|
||||||
|
'notes' => array(
|
||||||
|
__( 'llms.txt: BreznGEO serves the file with priority — Rank Math is bypassed.', 'brezngeo' ),
|
||||||
|
__( 'Schema.org: BreznGEO suppresses its own JSON-LD to avoid duplicates.', 'brezngeo' ),
|
||||||
|
__( 'Meta descriptions: BreznGEO writes to the Rank Math meta field.', 'brezngeo' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( defined( 'WPSEO_VERSION' ) ) {
|
||||||
|
$compat[] = array(
|
||||||
|
'name' => 'Yoast SEO',
|
||||||
|
'notes' => array(
|
||||||
|
__( 'Schema.org: BreznGEO suppresses its own JSON-LD to avoid duplicates.', 'brezngeo' ),
|
||||||
|
__( 'Meta descriptions: BreznGEO writes to the Yoast meta field.', 'brezngeo' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( defined( 'AIOSEO_VERSION' ) ) {
|
||||||
|
$compat[] = array(
|
||||||
|
'name' => 'All in One SEO',
|
||||||
|
'notes' => array(
|
||||||
|
__( 'Meta descriptions: BreznGEO writes to the AIOSEO meta field.', 'brezngeo' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( class_exists( 'SeoPress_Titles_Admin' ) ) {
|
||||||
|
$compat[] = array(
|
||||||
|
'name' => 'SEOPress',
|
||||||
|
'notes' => array(
|
||||||
|
__( 'Meta descriptions: BreznGEO writes to the SEOPress meta field.', 'brezngeo' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $compat;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
brezngeo/includes/Admin/BulkPage.php
Normal file
64
brezngeo/includes/Admin/BulkPage.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\ProviderRegistry;
|
||||||
|
|
||||||
|
class BulkPage {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-bulk' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
wp_enqueue_script( 'brezngeo-bulk', BREZNGEO_URL . 'assets/bulk.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-bulk',
|
||||||
|
'brezngeoBulk',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'isLocked' => \BreznGEO\Helpers\BulkQueue::isLocked(),
|
||||||
|
'lockAge' => \BreznGEO\Helpers\BulkQueue::lockAge(),
|
||||||
|
'rateDelay' => 6000,
|
||||||
|
'costs' => $settings['costs'] ?? array(),
|
||||||
|
'i18n' => array(
|
||||||
|
'lockWarning' => __( 'A bulk process is already running', 'brezngeo' ),
|
||||||
|
'since' => __( 'since', 'brezngeo' ),
|
||||||
|
'postsWithoutMeta' => __( 'Posts without meta description:', 'brezngeo' ),
|
||||||
|
'total' => __( 'Total:', 'brezngeo' ),
|
||||||
|
'inputTokens' => __( 'Input tokens', 'brezngeo' ),
|
||||||
|
'outputTokens' => __( 'Output tokens', 'brezngeo' ),
|
||||||
|
'logStart' => __( '▶ Start — max {limit} posts, Provider: {provider}', 'brezngeo' ),
|
||||||
|
'stopRequested' => __( 'Stop requested…', 'brezngeo' ),
|
||||||
|
'logProcess' => __( '↻ Processing {count} posts… ({remaining} remaining)', 'brezngeo' ),
|
||||||
|
'unknownError' => __( 'Unknown error', 'brezngeo' ),
|
||||||
|
'attempt' => __( 'attempt', 'brezngeo' ),
|
||||||
|
'networkError' => __( 'Network error', 'brezngeo' ),
|
||||||
|
'processed' => __( 'processed', 'brezngeo' ),
|
||||||
|
'done' => __( '— Done —', 'brezngeo' ),
|
||||||
|
'postsFailed' => __( 'posts failed:', 'brezngeo' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$registry = ProviderRegistry::instance();
|
||||||
|
$providers = $registry->all();
|
||||||
|
$has_ai = ! empty( $settings['ai_enabled'] )
|
||||||
|
&& ! empty( $settings['api_keys'][ $settings['provider'] ] );
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/bulk.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
231
brezngeo/includes/Admin/GeoEditorBox.php
Normal file
231
brezngeo/includes/Admin/GeoEditorBox.php
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Features\GeoBlock;
|
||||||
|
|
||||||
|
class GeoEditorBox {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'add_meta_boxes', array( $this, 'add_boxes' ) );
|
||||||
|
add_action( 'save_post', array( $this, 'save' ), 10, 2 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_geo_generate', array( $this, 'ajax_generate' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_geo_clear', array( $this, 'ajax_clear' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_boxes(): void {
|
||||||
|
$settings = GeoBlock::getSettings();
|
||||||
|
foreach ( $settings['post_types'] as $pt ) {
|
||||||
|
add_meta_box(
|
||||||
|
'brezngeo_geo_box',
|
||||||
|
__( 'GEO Quick Overview (BreznGEO)', 'brezngeo' ),
|
||||||
|
array( $this, 'render' ),
|
||||||
|
$pt,
|
||||||
|
'normal',
|
||||||
|
'default'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render( \WP_Post $post ): void {
|
||||||
|
$settings = GeoBlock::getSettings();
|
||||||
|
$meta = GeoBlock::getMeta( $post->ID );
|
||||||
|
$enabled = get_post_meta( $post->ID, GeoBlock::META_ENABLED, true );
|
||||||
|
$lock = (bool) get_post_meta( $post->ID, GeoBlock::META_LOCK, true );
|
||||||
|
$generated_at = get_post_meta( $post->ID, GeoBlock::META_GENERATED, true );
|
||||||
|
$prompt_addon = get_post_meta( $post->ID, GeoBlock::META_ADDON, true ) ?: '';
|
||||||
|
$global = SettingsPage::getSettings();
|
||||||
|
$has_api_key = ! empty( $global['api_keys'][ $global['provider'] ] ?? '' );
|
||||||
|
|
||||||
|
wp_nonce_field( 'brezngeo_geo_save_' . $post->ID, 'brezngeo_geo_nonce' );
|
||||||
|
?>
|
||||||
|
<div id="brezngeo-geo-box" data-post-id="<?php echo esc_attr( $post->ID ); ?>"
|
||||||
|
data-nonce="<?php echo esc_attr( wp_create_nonce( 'brezngeo_admin' ) ); ?>">
|
||||||
|
|
||||||
|
<p style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_geo_enabled" value="1"
|
||||||
|
<?php checked( $enabled, '1' ); ?>>
|
||||||
|
<?php esc_html_e( 'Enable GEO block for this post', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_geo_lock" value="1" id="brezngeo-geo-lock"
|
||||||
|
<?php checked( $lock, true ); ?>>
|
||||||
|
<?php esc_html_e( 'Lock auto-regeneration', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
<?php if ( $generated_at ) : ?>
|
||||||
|
<span style="font-size:11px;color:#666;">
|
||||||
|
<?php
|
||||||
|
// translators: %s = human-readable date
|
||||||
|
printf( esc_html__( 'Generated: %s', 'brezngeo' ), esc_html( date_i18n( get_option( 'date_format' ) . ' H:i', (int) $generated_at ) ) );
|
||||||
|
?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if ( $has_api_key ) : ?>
|
||||||
|
<p>
|
||||||
|
<button type="button" class="button" id="brezngeo-geo-generate">
|
||||||
|
<?php
|
||||||
|
empty( $meta['summary'] )
|
||||||
|
? esc_html_e( 'Generate now', 'brezngeo' )
|
||||||
|
: esc_html_e( 'Regenerate', 'brezngeo' );
|
||||||
|
?>
|
||||||
|
</button>
|
||||||
|
<?php if ( ! empty( $meta['summary'] ) ) : ?>
|
||||||
|
<button type="button" class="button" id="brezngeo-geo-clear" style="margin-left:6px;">
|
||||||
|
<?php esc_html_e( 'Clear', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span id="brezngeo-geo-status" style="margin-left:10px;font-size:12px;"></span>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p style="margin-bottom:4px;">
|
||||||
|
<label for="brezngeo-geo-summary"><strong><?php esc_html_e( 'Summary', 'brezngeo' ); ?></strong></label>
|
||||||
|
</p>
|
||||||
|
<textarea id="brezngeo-geo-summary" name="brezngeo_geo_summary" rows="3"
|
||||||
|
style="width:100%;box-sizing:border-box;"><?php echo esc_textarea( $meta['summary'] ); ?></textarea>
|
||||||
|
|
||||||
|
<p style="margin-bottom:4px;margin-top:10px;">
|
||||||
|
<label for="brezngeo-geo-bullets"><strong><?php esc_html_e( 'Key Points', 'brezngeo' ); ?></strong></label>
|
||||||
|
<span style="font-size:11px;color:#666;margin-left:8px;"><?php esc_html_e( '(one per line)', 'brezngeo' ); ?></span>
|
||||||
|
</p>
|
||||||
|
<textarea id="brezngeo-geo-bullets" name="brezngeo_geo_bullets" rows="5"
|
||||||
|
style="width:100%;box-sizing:border-box;"><?php echo esc_textarea( implode( "\n", $meta['bullets'] ) ); ?></textarea>
|
||||||
|
|
||||||
|
<p style="margin-bottom:4px;margin-top:10px;">
|
||||||
|
<label for="brezngeo-geo-faq"><strong><?php esc_html_e( 'FAQ', 'brezngeo' ); ?></strong></label>
|
||||||
|
<span style="font-size:11px;color:#666;margin-left:8px;"><?php esc_html_e( '(Format: Question? | Answer — one per line)', 'brezngeo' ); ?></span>
|
||||||
|
</p>
|
||||||
|
<textarea id="brezngeo-geo-faq" name="brezngeo_geo_faq" rows="4"
|
||||||
|
style="width:100%;box-sizing:border-box;">
|
||||||
|
<?php
|
||||||
|
$faq_lines = array_map(
|
||||||
|
function ( $item ) {
|
||||||
|
return ( $item['q'] ?? '' ) . ' | ' . ( $item['a'] ?? '' );
|
||||||
|
},
|
||||||
|
$meta['faq']
|
||||||
|
);
|
||||||
|
echo esc_textarea( implode( "\n", $faq_lines ) );
|
||||||
|
?>
|
||||||
|
</textarea>
|
||||||
|
|
||||||
|
<?php if ( $settings['allow_prompt_addon'] ) : ?>
|
||||||
|
<p style="margin-bottom:4px;margin-top:10px;">
|
||||||
|
<label for="brezngeo-geo-addon"><strong><?php esc_html_e( 'Prompt add-on (optional)', 'brezngeo' ); ?></strong></label>
|
||||||
|
</p>
|
||||||
|
<textarea id="brezngeo-geo-addon" name="brezngeo_geo_prompt_addon" rows="2"
|
||||||
|
style="width:100%;box-sizing:border-box;"><?php echo esc_textarea( $prompt_addon ); ?></textarea>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save( int $post_id, \WP_Post $post ): void {
|
||||||
|
if ( ! isset( $_POST['brezngeo_geo_nonce'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['brezngeo_geo_nonce'] ) ), 'brezngeo_geo_save_' . $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-post enabled flag ('' = follow global, '1' = on, '0' = off)
|
||||||
|
$enabled = isset( $_POST['brezngeo_geo_enabled'] ) ? '1' : '0';
|
||||||
|
update_post_meta( $post_id, GeoBlock::META_ENABLED, $enabled );
|
||||||
|
|
||||||
|
$lock = isset( $_POST['brezngeo_geo_lock'] ) ? '1' : '';
|
||||||
|
update_post_meta( $post_id, GeoBlock::META_LOCK, $lock );
|
||||||
|
|
||||||
|
// Manual field edits
|
||||||
|
$summary = sanitize_text_field( wp_unslash( $_POST['brezngeo_geo_summary'] ?? '' ) );
|
||||||
|
update_post_meta( $post_id, GeoBlock::META_SUMMARY, $summary );
|
||||||
|
|
||||||
|
$raw_bullets = sanitize_textarea_field( wp_unslash( $_POST['brezngeo_geo_bullets'] ?? '' ) );
|
||||||
|
$bullets = array_values( array_filter( array_map( 'trim', explode( "\n", $raw_bullets ) ) ) );
|
||||||
|
update_post_meta( $post_id, GeoBlock::META_BULLETS, wp_json_encode( $bullets, JSON_UNESCAPED_UNICODE ) );
|
||||||
|
|
||||||
|
$raw_faq = sanitize_textarea_field( wp_unslash( $_POST['brezngeo_geo_faq'] ?? '' ) );
|
||||||
|
$faq = array();
|
||||||
|
foreach ( array_filter( array_map( 'trim', explode( "\n", $raw_faq ) ) ) as $line ) {
|
||||||
|
$parts = explode( '|', $line, 2 );
|
||||||
|
if ( count( $parts ) === 2 ) {
|
||||||
|
$faq[] = array(
|
||||||
|
'q' => trim( $parts[0] ),
|
||||||
|
'a' => trim( $parts[1] ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_post_meta( $post_id, GeoBlock::META_FAQ, wp_json_encode( $faq, JSON_UNESCAPED_UNICODE ) );
|
||||||
|
|
||||||
|
if ( isset( $_POST['brezngeo_geo_prompt_addon'] ) ) {
|
||||||
|
update_post_meta( $post_id, GeoBlock::META_ADDON, sanitize_textarea_field( wp_unslash( $_POST['brezngeo_geo_prompt_addon'] ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue( string $hook ): void {
|
||||||
|
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_script(
|
||||||
|
'brezngeo-geo-editor',
|
||||||
|
BREZNGEO_URL . 'assets/geo-editor.js',
|
||||||
|
array( 'jquery' ),
|
||||||
|
BREZNGEO_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_generate(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||||
|
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
|
||||||
|
if ( ! $post_id || ! get_post( $post_id ) ) {
|
||||||
|
wp_send_json_error( __( 'Post not found.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$geo = new GeoBlock();
|
||||||
|
if ( $geo->generate( $post_id, true ) ) {
|
||||||
|
$meta = GeoBlock::getMeta( $post_id );
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'summary' => $meta['summary'],
|
||||||
|
'bullets' => $meta['bullets'],
|
||||||
|
'faq' => $meta['faq'],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
wp_send_json_error( __( 'Generation failed. Check API key and provider settings.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_clear(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||||
|
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
|
||||||
|
if ( ! $post_id ) {
|
||||||
|
wp_send_json_error( 'Invalid post ID' );
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_post_meta( $post_id, GeoBlock::META_SUMMARY );
|
||||||
|
delete_post_meta( $post_id, GeoBlock::META_BULLETS );
|
||||||
|
delete_post_meta( $post_id, GeoBlock::META_FAQ );
|
||||||
|
delete_post_meta( $post_id, GeoBlock::META_GENERATED );
|
||||||
|
wp_send_json_success();
|
||||||
|
}
|
||||||
|
}
|
||||||
91
brezngeo/includes/Admin/GeoPage.php
Normal file
91
brezngeo/includes/Admin/GeoPage.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Features\GeoBlock;
|
||||||
|
|
||||||
|
class GeoPage {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void {
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_geo',
|
||||||
|
GeoBlock::OPTION_KEY,
|
||||||
|
array( 'sanitize_callback' => array( $this, 'sanitize' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-geo' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$clean['enabled'] = ! empty( $input['enabled'] );
|
||||||
|
$clean['regen_on_update'] = ! empty( $input['regen_on_update'] );
|
||||||
|
$clean['allow_prompt_addon'] = ! empty( $input['allow_prompt_addon'] );
|
||||||
|
|
||||||
|
$allowed_modes = array( 'auto_on_publish', 'manual_only', 'hybrid' );
|
||||||
|
$clean['mode'] = in_array( $input['mode'] ?? '', $allowed_modes, true )
|
||||||
|
? $input['mode'] : 'auto_on_publish';
|
||||||
|
|
||||||
|
$allowed_positions = array( 'after_first_p', 'top', 'bottom' );
|
||||||
|
$clean['position'] = in_array( $input['position'] ?? '', $allowed_positions, true )
|
||||||
|
? $input['position'] : 'after_first_p';
|
||||||
|
|
||||||
|
$allowed_styles = array( 'details_collapsible', 'open_always', 'store_only_no_frontend' );
|
||||||
|
$clean['output_style'] = in_array( $input['output_style'] ?? '', $allowed_styles, true )
|
||||||
|
? $input['output_style'] : 'details_collapsible';
|
||||||
|
|
||||||
|
$allowed_themes = array( 'light', 'dark', 'minimal', 'bavarian' );
|
||||||
|
$clean['theme'] = in_array( $input['theme'] ?? '', $allowed_themes, true )
|
||||||
|
? $input['theme'] : 'light';
|
||||||
|
|
||||||
|
$clean['accent_color'] = sanitize_hex_color( $input['accent_color'] ?? '' ) ?? '';
|
||||||
|
|
||||||
|
$clean['title'] = sanitize_text_field( $input['title'] ?? 'Quick Overview' );
|
||||||
|
$clean['label_summary'] = sanitize_text_field( $input['label_summary'] ?? 'Summary' );
|
||||||
|
$clean['label_bullets'] = sanitize_text_field( $input['label_bullets'] ?? 'Key Points' );
|
||||||
|
$clean['label_faq'] = sanitize_text_field( $input['label_faq'] ?? 'FAQ' );
|
||||||
|
$clean['prompt_default'] = sanitize_textarea_field(
|
||||||
|
! empty( $input['prompt_default'] ) ? $input['prompt_default'] : GeoBlock::getDefaultPrompt()
|
||||||
|
);
|
||||||
|
$clean['word_threshold'] = max( 50, (int) ( $input['word_threshold'] ?? 350 ) );
|
||||||
|
|
||||||
|
$all_post_types = array_keys( get_post_types( array( 'public' => true ) ) );
|
||||||
|
$clean['post_types'] = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_key', (array) ( $input['post_types'] ?? array() ) ),
|
||||||
|
$all_post_types
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( empty( $clean['post_types'] ) ) {
|
||||||
|
$clean['post_types'] = array( 'post', 'page' );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = GeoBlock::getSettings();
|
||||||
|
$post_types = get_post_types( array( 'public' => true ), 'objects' );
|
||||||
|
$global = SettingsPage::getSettings();
|
||||||
|
$api_key = $global['api_keys'][ $global['provider'] ] ?? '';
|
||||||
|
$has_ai = ( $global['ai_enabled'] ?? false ) && ! empty( $api_key );
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/geo.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
119
brezngeo/includes/Admin/LinkAnalysis.php
Normal file
119
brezngeo/includes/Admin/LinkAnalysis.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkAnalysis {
|
||||||
|
private const CACHE_KEY = 'brezngeo_link_analysis';
|
||||||
|
private const CACHE_TTL = 3600;
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'wp_ajax_brezngeo_link_analysis', array( $this, 'ajax_analyse' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_analyse(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( 'Insufficient permissions' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cached = get_transient( self::CACHE_KEY );
|
||||||
|
if ( $cached !== false ) {
|
||||||
|
wp_send_json_success( $cached );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$threshold = (int) get_option( 'brezngeo_ext_link_threshold', 5 );
|
||||||
|
|
||||||
|
$data = array(
|
||||||
|
'no_internal_links' => $this->posts_without_internal_links(),
|
||||||
|
'too_many_external' => $this->posts_with_many_external_links( $threshold ),
|
||||||
|
'pillar_pages' => $this->top_pillar_pages( 5 ),
|
||||||
|
'threshold' => $threshold,
|
||||||
|
);
|
||||||
|
|
||||||
|
set_transient( self::CACHE_KEY, $data, self::CACHE_TTL );
|
||||||
|
wp_send_json_success( $data );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function posts_without_internal_links(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$site = esc_sql( rtrim( home_url(), '/' ) );
|
||||||
|
$results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT ID, post_title FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish'
|
||||||
|
AND post_type IN ('post','page')
|
||||||
|
AND post_content NOT LIKE %s
|
||||||
|
ORDER BY post_date DESC
|
||||||
|
LIMIT 20",
|
||||||
|
'%href="' . $site . '%'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return array_map(
|
||||||
|
fn( $r ) => array(
|
||||||
|
'id' => (int) $r->ID,
|
||||||
|
'title' => $r->post_title,
|
||||||
|
),
|
||||||
|
$results
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function posts_with_many_external_links( int $threshold ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$host = wp_parse_url( home_url(), PHP_URL_HOST );
|
||||||
|
$posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
"SELECT ID, post_title, post_content FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish' AND post_type IN ('post','page')
|
||||||
|
ORDER BY post_date DESC LIMIT 200"
|
||||||
|
);
|
||||||
|
$over = array();
|
||||||
|
foreach ( $posts as $post ) {
|
||||||
|
preg_match_all( '/href="https?:\/\/([^"\/]+)/', $post->post_content, $m );
|
||||||
|
$external = array_filter( $m[1], fn( $h ) => $h !== $host );
|
||||||
|
$count = count( $external );
|
||||||
|
if ( $count >= $threshold ) {
|
||||||
|
$over[] = array(
|
||||||
|
'id' => (int) $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'count' => $count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort( $over, fn( $a, $b ) => $b['count'] <=> $a['count'] );
|
||||||
|
return array_slice( $over, 0, 20 );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function top_pillar_pages( int $top ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$site = rtrim( home_url(), '/' );
|
||||||
|
$posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
"SELECT post_content FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish' AND post_type IN ('post','page')"
|
||||||
|
);
|
||||||
|
$counts = array();
|
||||||
|
foreach ( $posts as $post ) {
|
||||||
|
preg_match_all(
|
||||||
|
'/href="(' . preg_quote( $site, '/' ) . '[^"]+)"/',
|
||||||
|
$post->post_content,
|
||||||
|
$m
|
||||||
|
);
|
||||||
|
foreach ( $m[1] as $url ) {
|
||||||
|
$url = rtrim( $url, '/' );
|
||||||
|
$counts[ $url ] = ( $counts[ $url ] ?? 0 ) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arsort( $counts );
|
||||||
|
$result = array();
|
||||||
|
foreach ( array_slice( $counts, 0, $top, true ) as $url => $count ) {
|
||||||
|
$result[] = array(
|
||||||
|
'url' => $url,
|
||||||
|
'count' => $count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
brezngeo/includes/Admin/LinkSuggestPage.php
Normal file
94
brezngeo/includes/Admin/LinkSuggestPage.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Features\LinkSuggest;
|
||||||
|
|
||||||
|
class LinkSuggestPage {
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void {
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_link_suggest',
|
||||||
|
LinkSuggest::OPTION_KEY,
|
||||||
|
array( 'sanitize_callback' => array( $this, 'sanitize' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-link-suggest' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
wp_enqueue_script( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-admin',
|
||||||
|
'brezngeoAdmin',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// link-suggest.js enthält den Post-Such-Code für Exclude/Boost.
|
||||||
|
// Minimales bavrankLinkSuggest-Objekt — reicht damit der Such-Block läuft.
|
||||||
|
wp_enqueue_script( 'brezngeo-link-suggest', BREZNGEO_URL . 'assets/link-suggest.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-link-suggest',
|
||||||
|
'bavrankLinkSuggest',
|
||||||
|
array(
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'restUrl' => get_rest_url( null, 'wp/v2/search' ),
|
||||||
|
'restNonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$allowed_triggers = array( 'manual', 'save', 'interval' );
|
||||||
|
$clean['trigger'] = in_array( $input['trigger'] ?? '', $allowed_triggers, true )
|
||||||
|
? $input['trigger'] : 'manual';
|
||||||
|
$clean['interval_min'] = max( 1, min( 60, (int) ( $input['interval_min'] ?? 2 ) ) );
|
||||||
|
$clean['ai_candidates'] = max( 1, min( 50, (int) ( $input['ai_candidates'] ?? 20 ) ) );
|
||||||
|
$clean['ai_max_tokens'] = max( 100, min( 2000, (int) ( $input['ai_max_tokens'] ?? 400 ) ) );
|
||||||
|
|
||||||
|
$clean['excluded_posts'] = array_values(
|
||||||
|
array_map( 'intval', (array) ( $input['excluded_posts'] ?? array() ) )
|
||||||
|
);
|
||||||
|
|
||||||
|
$clean['boosted_posts'] = array();
|
||||||
|
foreach ( (array) ( $input['boosted_posts'] ?? array() ) as $entry ) {
|
||||||
|
$id = (int) ( $entry['id'] ?? 0 );
|
||||||
|
$boost = (float) ( $entry['boost'] ?? 1.5 );
|
||||||
|
if ( $id > 0 ) {
|
||||||
|
$clean['boosted_posts'][] = array(
|
||||||
|
'id' => $id,
|
||||||
|
'boost' => max( 1.0, min( 10.0, $boost ) ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = LinkSuggest::get_settings();
|
||||||
|
$main = SettingsPage::getSettings();
|
||||||
|
$has_ai = ! empty( $main['ai_enabled'] )
|
||||||
|
&& ! empty( $main['api_keys'][ $main['provider'] ] );
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/link-suggest-settings.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
141
brezngeo/includes/Admin/MetaEditorBox.php
Normal file
141
brezngeo/includes/Admin/MetaEditorBox.php
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Features\MetaGenerator;
|
||||||
|
|
||||||
|
class MetaEditorBox {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'add_meta_boxes', array( $this, 'add_boxes' ) );
|
||||||
|
add_action( 'save_post', array( $this, 'save' ), 10, 2 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_regen_meta', array( $this, 'ajax_regen' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_boxes(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
|
||||||
|
foreach ( $post_types as $pt ) {
|
||||||
|
add_meta_box(
|
||||||
|
'brezngeo_meta_box',
|
||||||
|
__( 'Meta Description (BreznGEO)', 'brezngeo' ),
|
||||||
|
array( $this, 'render' ),
|
||||||
|
$pt,
|
||||||
|
'normal',
|
||||||
|
'high'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render( \WP_Post $post ): void {
|
||||||
|
$description = get_post_meta( $post->ID, '_brezngeo_meta_description', true ) ?: '';
|
||||||
|
$source = get_post_meta( $post->ID, '_brezngeo_meta_source', true ) ?: 'none';
|
||||||
|
|
||||||
|
$source_labels = array(
|
||||||
|
'ai' => __( 'AI generated', 'brezngeo' ),
|
||||||
|
'fallback' => __( 'Fallback (first paragraph)', 'brezngeo' ),
|
||||||
|
'manual' => __( 'Manually edited', 'brezngeo' ),
|
||||||
|
'none' => __( 'Not yet generated', 'brezngeo' ),
|
||||||
|
);
|
||||||
|
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
|
||||||
|
$has_key = ! empty( $api_key );
|
||||||
|
|
||||||
|
wp_nonce_field( 'brezngeo_save_meta_' . $post->ID, 'brezngeo_meta_nonce' );
|
||||||
|
?>
|
||||||
|
<p>
|
||||||
|
<span style="display:inline-block;background:#eee;padding:2px 8px;border-radius:3px;font-size:11px;color:#555;">
|
||||||
|
<?php echo esc_html( $source_labels[ $source ] ?? $source ); ?>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<textarea id="brezngeo-meta-description"
|
||||||
|
name="brezngeo_meta_description"
|
||||||
|
rows="3"
|
||||||
|
maxlength="160"
|
||||||
|
style="width:100%;box-sizing:border-box;"
|
||||||
|
><?php echo esc_textarea( $description ); ?></textarea>
|
||||||
|
<p style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;">
|
||||||
|
<span id="brezngeo-meta-count" style="font-size:11px;color:#666;">
|
||||||
|
<?php echo esc_html( mb_strlen( $description ) ); ?> / 160
|
||||||
|
</span>
|
||||||
|
<?php if ( $has_key ) : ?>
|
||||||
|
<button type="button"
|
||||||
|
id="brezngeo-regen-meta"
|
||||||
|
class="button button-small"
|
||||||
|
data-post-id="<?php echo esc_attr( $post->ID ); ?>"
|
||||||
|
data-nonce="<?php echo esc_attr( wp_create_nonce( 'brezngeo_admin' ) ); ?>">
|
||||||
|
<?php esc_html_e( 'Regenerate with AI', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save( int $post_id, \WP_Post $post ): void {
|
||||||
|
if ( ! isset( $_POST['brezngeo_meta_nonce'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['brezngeo_meta_nonce'] ) ), 'brezngeo_save_meta_' . $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! isset( $_POST['brezngeo_meta_description'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gen = new MetaGenerator();
|
||||||
|
$gen->saveMeta(
|
||||||
|
$post_id,
|
||||||
|
sanitize_textarea_field( wp_unslash( $_POST['brezngeo_meta_description'] ) ),
|
||||||
|
'manual'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue( string $hook ): void {
|
||||||
|
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_script(
|
||||||
|
'brezngeo-editor-meta',
|
||||||
|
BREZNGEO_URL . 'assets/editor-meta.js',
|
||||||
|
array( 'jquery' ),
|
||||||
|
BREZNGEO_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_regen(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||||
|
wp_send_json_error( 'Insufficient permissions' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
|
||||||
|
$post = $post_id ? get_post( $post_id ) : null;
|
||||||
|
if ( ! $post ) {
|
||||||
|
wp_send_json_error( 'Post not found' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$gen = new MetaGenerator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$desc = $gen->generate( $post, $settings );
|
||||||
|
$gen->saveMeta( $post_id, $desc, 'ai' );
|
||||||
|
wp_send_json_success( array( 'description' => $desc ) );
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
wp_send_json_error( $e->getMessage() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
brezngeo/includes/Admin/MetaPage.php
Normal file
72
brezngeo/includes/Admin/MetaPage.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetaPage {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void {
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_meta',
|
||||||
|
SettingsPage::OPTION_KEY_META,
|
||||||
|
array(
|
||||||
|
'sanitize_callback' => array( $this, 'sanitize' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-meta' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
wp_enqueue_script( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-admin',
|
||||||
|
'brezngeoAdmin',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$clean['meta_auto_enabled'] = ! empty( $input['meta_auto_enabled'] );
|
||||||
|
$clean['theme_has_h1'] = ! empty( $input['theme_has_h1'] );
|
||||||
|
$clean['token_mode'] = in_array( $input['token_mode'] ?? '', array( 'limit', 'full' ), true )
|
||||||
|
? $input['token_mode'] : 'limit';
|
||||||
|
$clean['token_limit'] = max( 100, (int) ( $input['token_limit'] ?? 1000 ) );
|
||||||
|
$clean['prompt'] = sanitize_textarea_field( $input['prompt'] ?? SettingsPage::getDefaultPrompt() );
|
||||||
|
|
||||||
|
$all_post_types = array_keys( get_post_types( array( 'public' => true ) ) );
|
||||||
|
$clean['meta_post_types'] = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_key', (array) ( $input['meta_post_types'] ?? array() ) ),
|
||||||
|
$all_post_types
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$post_types = get_post_types( array( 'public' => true ), 'objects' );
|
||||||
|
$api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
|
||||||
|
$has_ai = ( $settings['ai_enabled'] ?? false ) && ! empty( $api_key );
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/meta.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
144
brezngeo/includes/Admin/ProviderPage.php
Normal file
144
brezngeo/includes/Admin/ProviderPage.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\ProviderRegistry;
|
||||||
|
use BreznGEO\Helpers\KeyVault;
|
||||||
|
|
||||||
|
class ProviderPage {
|
||||||
|
private const PRICING_URLS = array(
|
||||||
|
'openai' => 'https://openai.com/de-DE/api/pricing',
|
||||||
|
'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing',
|
||||||
|
'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de',
|
||||||
|
'grok' => 'https://docs.x.ai/developers/models',
|
||||||
|
);
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_test_connection', array( $this, 'ajax_test_connection' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_get_default_prompt', array( $this, 'ajax_get_default_prompt' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void {
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_provider',
|
||||||
|
SettingsPage::OPTION_KEY_PROVIDER,
|
||||||
|
array(
|
||||||
|
'sanitize_callback' => array( $this, 'sanitize' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-provider' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
wp_enqueue_script( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-admin',
|
||||||
|
'brezngeoAdmin',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'testing' => __( 'Testing…', 'brezngeo' ),
|
||||||
|
'networkError' => __( 'Network error', 'brezngeo' ),
|
||||||
|
'resetConfirm' => __( 'Really reset the prompt?', 'brezngeo' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$raw_ex = get_option( SettingsPage::OPTION_KEY_PROVIDER, array() );
|
||||||
|
$existing = is_array( $raw_ex ) ? $raw_ex : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$clean['provider'] = sanitize_key( $input['provider'] ?? 'openai' );
|
||||||
|
$clean['ai_enabled'] = ! empty( $input['ai_enabled'] );
|
||||||
|
|
||||||
|
$clean['api_keys'] = array();
|
||||||
|
foreach ( ( $input['api_keys'] ?? array() ) as $provider_id => $raw ) {
|
||||||
|
$provider_id = sanitize_key( $provider_id );
|
||||||
|
$raw = sanitize_text_field( $raw );
|
||||||
|
if ( $raw !== '' ) {
|
||||||
|
$clean['api_keys'][ $provider_id ] = KeyVault::encrypt( $raw );
|
||||||
|
} elseif ( isset( $existing['api_keys'][ $provider_id ] ) ) {
|
||||||
|
$clean['api_keys'][ $provider_id ] = $existing['api_keys'][ $provider_id ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean['models'] = array();
|
||||||
|
foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) {
|
||||||
|
$clean['models'][ sanitize_key( $provider_id ) ] = sanitize_text_field( $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean['costs'] = array();
|
||||||
|
foreach ( ( $input['costs'] ?? array() ) as $provider_id => $models ) {
|
||||||
|
$provider_id = sanitize_key( $provider_id );
|
||||||
|
foreach ( (array) $models as $model_id => $prices ) {
|
||||||
|
$in = (float) str_replace( ',', '.', $prices['input'] ?? '0' );
|
||||||
|
$out = (float) str_replace( ',', '.', $prices['output'] ?? '0' );
|
||||||
|
if ( $in > 0 || $out > 0 ) {
|
||||||
|
$clean['costs'][ $provider_id ][ sanitize_text_field( $model_id ) ] = array(
|
||||||
|
'input' => max( 0.0, $in ),
|
||||||
|
'output' => max( 0.0, $out ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_test_connection(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
$provider_id = sanitize_key( $_POST['provider'] ?? '' );
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$api_key = $settings['api_keys'][ $provider_id ] ?? '';
|
||||||
|
if ( empty( $api_key ) ) {
|
||||||
|
wp_send_json_error( __( 'No API key saved. Please save first.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
$provider = ProviderRegistry::instance()->get( $provider_id );
|
||||||
|
if ( ! $provider ) {
|
||||||
|
wp_send_json_error( __( 'Unknown provider.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
$result = $provider->testConnection( $api_key );
|
||||||
|
if ( $result['success'] ) {
|
||||||
|
wp_send_json_success( $result['message'] );
|
||||||
|
} else {
|
||||||
|
wp_send_json_error( $result['message'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_get_default_prompt(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error();
|
||||||
|
}
|
||||||
|
wp_send_json_success( SettingsPage::getDefaultPrompt() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$providers = ProviderRegistry::instance()->all();
|
||||||
|
$masked_keys = array();
|
||||||
|
$raw_settings = get_option( SettingsPage::OPTION_KEY_PROVIDER, array() );
|
||||||
|
foreach ( ( $raw_settings['api_keys'] ?? array() ) as $id => $stored ) {
|
||||||
|
$plain = KeyVault::decrypt( $stored );
|
||||||
|
$masked_keys[ $id ] = KeyVault::mask( $plain );
|
||||||
|
}
|
||||||
|
$pricing_urls = self::PRICING_URLS;
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/provider.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
144
brezngeo/includes/Admin/SchemaMetaBox.php
Normal file
144
brezngeo/includes/Admin/SchemaMetaBox.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SchemaMetaBox {
|
||||||
|
public const META_TYPE = '_brezngeo_schema_type';
|
||||||
|
public const META_DATA = '_brezngeo_schema_data';
|
||||||
|
private const VALID_TYPES = array( 'howto', 'review', 'recipe', 'event', '' );
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'add_meta_boxes', array( $this, 'addMetaBox' ) );
|
||||||
|
add_action( 'save_post', array( $this, 'savePost' ), 10, 2 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addMetaBox(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$enabled = $settings['schema_enabled'] ?? array();
|
||||||
|
$needs_box = array_intersect( array( 'howto', 'review', 'recipe', 'event' ), $enabled );
|
||||||
|
if ( empty( $needs_box ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add_meta_box(
|
||||||
|
'brezngeo-schema-meta-box',
|
||||||
|
__( 'BreznGEO Schema', 'brezngeo' ),
|
||||||
|
array( $this, 'renderMetaBox' ),
|
||||||
|
array( 'post', 'page' ),
|
||||||
|
'side',
|
||||||
|
'default'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderMetaBox( \WP_Post $post ): void {
|
||||||
|
$type = get_post_meta( $post->ID, self::META_TYPE, true ) ?: '';
|
||||||
|
$raw_data = get_post_meta( $post->ID, self::META_DATA, true ) ?: '{}';
|
||||||
|
$data = json_decode( $raw_data, true ) ?: array();
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$enabled = $settings['schema_enabled'] ?? array();
|
||||||
|
wp_nonce_field( 'brezngeo_schema_meta_box', '_brezngeo_schema_nonce' );
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/schema-meta-box.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue( string $hook ): void {
|
||||||
|
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_script(
|
||||||
|
'brezngeo-schema-meta-box',
|
||||||
|
BREZNGEO_URL . 'assets/schema-meta-box.js',
|
||||||
|
array(),
|
||||||
|
BREZNGEO_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function savePost( int $post_id, \WP_Post $post ): void {
|
||||||
|
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||||
|
if ( ! isset( $_POST['_brezngeo_schema_nonce'] )
|
||||||
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||||
|
|| ! wp_verify_nonce( sanitize_key( $_POST['_brezngeo_schema_nonce'] ), 'brezngeo_schema_meta_box' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||||
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||||
|
$input = isset( $_POST['brezngeo_schema'] ) && is_array( $_POST['brezngeo_schema'] )
|
||||||
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||||
|
? wp_unslash( $_POST['brezngeo_schema'] )
|
||||||
|
: array();
|
||||||
|
$clean = self::sanitizeData( $input );
|
||||||
|
update_post_meta( $post_id, self::META_TYPE, $clean['schema_type'] );
|
||||||
|
update_post_meta(
|
||||||
|
$post_id,
|
||||||
|
self::META_DATA,
|
||||||
|
wp_json_encode( $clean['data'], JSON_UNESCAPED_UNICODE )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure sanitizer — public static for testability.
|
||||||
|
*
|
||||||
|
* @param array $input Raw $_POST['brezngeo_schema'] data.
|
||||||
|
* @return array{schema_type: string, data: array}
|
||||||
|
*/
|
||||||
|
public static function sanitizeData( array $input ): array {
|
||||||
|
$type = sanitize_key( $input['schema_type'] ?? '' );
|
||||||
|
if ( ! in_array( $type, self::VALID_TYPES, true ) ) {
|
||||||
|
$type = '';
|
||||||
|
}
|
||||||
|
$data = array();
|
||||||
|
|
||||||
|
if ( $type === 'howto' ) {
|
||||||
|
$raw_steps = sanitize_textarea_field( $input['howto_steps'] ?? '' );
|
||||||
|
$steps = array_values( array_filter( array_map( 'trim', explode( "\n", $raw_steps ) ) ) );
|
||||||
|
$data['howto'] = array(
|
||||||
|
'name' => sanitize_text_field( $input['howto_name'] ?? '' ),
|
||||||
|
'steps' => $steps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $type === 'review' ) {
|
||||||
|
$data['review'] = array(
|
||||||
|
'item' => sanitize_text_field( $input['review_item'] ?? '' ),
|
||||||
|
'rating' => max( 1, min( 5, (int) ( $input['review_rating'] ?? 3 ) ) ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $type === 'recipe' ) {
|
||||||
|
$raw_ing = sanitize_textarea_field( $input['recipe_ingredients'] ?? '' );
|
||||||
|
$raw_inst = sanitize_textarea_field( $input['recipe_instructions'] ?? '' );
|
||||||
|
$data['recipe'] = array(
|
||||||
|
'name' => sanitize_text_field( $input['recipe_name'] ?? '' ),
|
||||||
|
'prep' => max( 0, (int) ( $input['recipe_prep'] ?? 0 ) ),
|
||||||
|
'cook' => max( 0, (int) ( $input['recipe_cook'] ?? 0 ) ),
|
||||||
|
'servings' => sanitize_text_field( $input['recipe_servings'] ?? '' ),
|
||||||
|
'ingredients' => array_values( array_filter( array_map( 'trim', explode( "\n", $raw_ing ) ) ) ),
|
||||||
|
'instructions' => array_values( array_filter( array_map( 'trim', explode( "\n", $raw_inst ) ) ) ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $type === 'event' ) {
|
||||||
|
$data['event'] = array(
|
||||||
|
'name' => sanitize_text_field( $input['event_name'] ?? '' ),
|
||||||
|
'start' => sanitize_text_field( $input['event_start'] ?? '' ),
|
||||||
|
'end' => sanitize_text_field( $input['event_end'] ?? '' ),
|
||||||
|
'location' => sanitize_text_field( $input['event_location'] ?? '' ),
|
||||||
|
'online' => ! empty( $input['event_online'] ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'schema_type' => $type,
|
||||||
|
'data' => $data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
brezngeo/includes/Admin/SchemaPage.php
Normal file
99
brezngeo/includes/Admin/SchemaPage.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SchemaPage {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void {
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_schema',
|
||||||
|
SettingsPage::OPTION_KEY_SCHEMA,
|
||||||
|
array(
|
||||||
|
'sanitize_callback' => array( $this, 'sanitize' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-schema' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$schema_types = array(
|
||||||
|
'organization',
|
||||||
|
'author',
|
||||||
|
'speakable',
|
||||||
|
'article_about',
|
||||||
|
'breadcrumb',
|
||||||
|
'ai_meta_tags',
|
||||||
|
'faq_schema',
|
||||||
|
'blog_posting',
|
||||||
|
'image_object',
|
||||||
|
'video_object',
|
||||||
|
'howto',
|
||||||
|
'review',
|
||||||
|
'recipe',
|
||||||
|
'event',
|
||||||
|
);
|
||||||
|
$clean['schema_enabled'] = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_key', (array) ( $input['schema_enabled'] ?? array() ) ),
|
||||||
|
$schema_types
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$org_raw = $input['schema_same_as']['organization'] ?? '';
|
||||||
|
if ( is_array( $org_raw ) ) {
|
||||||
|
$org_raw = implode( "\n", $org_raw );
|
||||||
|
}
|
||||||
|
$clean['schema_same_as'] = array(
|
||||||
|
'organization' => array_values(
|
||||||
|
array_filter(
|
||||||
|
array_map(
|
||||||
|
'esc_url_raw',
|
||||||
|
array_map( 'trim', explode( "\n", $org_raw ) )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$schema_labels = array(
|
||||||
|
'organization' => __( 'Organization (sameAs Social Profiles)', 'brezngeo' ),
|
||||||
|
'author' => __( 'Author (sameAs Profile Links)', 'brezngeo' ),
|
||||||
|
'speakable' => __( 'Speakable (for AI assistants)', 'brezngeo' ),
|
||||||
|
'article_about' => __( 'Article about/mentions', 'brezngeo' ),
|
||||||
|
'breadcrumb' => __( 'BreadcrumbList', 'brezngeo' ),
|
||||||
|
'ai_meta_tags' => __( 'AI-optimized Meta Tags (max-snippet etc.)', 'brezngeo' ),
|
||||||
|
'faq_schema' => __( 'FAQPage (from GEO Quick Overview — automatic)', 'brezngeo' ),
|
||||||
|
'blog_posting' => __( 'BlogPosting / Article (with embedded Author + Image)', 'brezngeo' ),
|
||||||
|
'image_object' => __( 'ImageObject (Featured Image)', 'brezngeo' ),
|
||||||
|
'video_object' => __( 'VideoObject (auto-detect YouTube/Vimeo)', 'brezngeo' ),
|
||||||
|
'howto' => __( 'HowTo (Metabox in Post Editor)', 'brezngeo' ),
|
||||||
|
'review' => __( 'Review with Rating (Metabox in Post Editor)', 'brezngeo' ),
|
||||||
|
'recipe' => __( 'Recipe (Metabox in Post Editor)', 'brezngeo' ),
|
||||||
|
'event' => __( 'Event (Metabox in Post Editor)', 'brezngeo' ),
|
||||||
|
);
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/schema.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
91
brezngeo/includes/Admin/SeoWidget.php
Normal file
91
brezngeo/includes/Admin/SeoWidget.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SeoWidget {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'add_meta_boxes', array( $this, 'add_boxes' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_boxes(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
|
||||||
|
foreach ( $post_types as $pt ) {
|
||||||
|
add_meta_box(
|
||||||
|
'brezngeo_seo_widget',
|
||||||
|
__( 'SEO Analysis (BreznGEO)', 'brezngeo' ),
|
||||||
|
array( $this, 'render' ),
|
||||||
|
$pt,
|
||||||
|
'side',
|
||||||
|
'default'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render( \WP_Post $post ): void {
|
||||||
|
$title_len = mb_strlen( $post->post_title );
|
||||||
|
?>
|
||||||
|
<div id="brezngeo-seo-widget" data-site-url="<?php echo esc_attr( home_url() ); ?>">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;line-height:1.8;">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#888;"><?php esc_html_e( 'Title:', 'brezngeo' ); ?></td>
|
||||||
|
<td id="brezngeo-title-stat" style="text-align:right;font-weight:bold;">
|
||||||
|
<?php echo esc_html( $title_len ); ?> / 60
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#888;"><?php esc_html_e( 'Words:', 'brezngeo' ); ?></td>
|
||||||
|
<td id="brezngeo-words-stat" style="text-align:right;">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#888;"><?php esc_html_e( 'Reading Time:', 'brezngeo' ); ?></td>
|
||||||
|
<td id="brezngeo-read-stat" style="text-align:right;">—</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<hr style="margin:8px 0;border:none;border-top:1px solid #eee;">
|
||||||
|
<strong style="font-size:11px;color:#555;"><?php esc_html_e( 'Headings', 'brezngeo' ); ?></strong>
|
||||||
|
<div id="brezngeo-headings-stat" style="font-size:11px;margin-top:4px;color:#333;">—</div>
|
||||||
|
<hr style="margin:8px 0;border:none;border-top:1px solid #eee;">
|
||||||
|
<strong style="font-size:11px;color:#555;"><?php esc_html_e( 'Links', 'brezngeo' ); ?></strong>
|
||||||
|
<div id="brezngeo-links-stat" style="font-size:11px;margin-top:4px;color:#333;">—</div>
|
||||||
|
<div id="brezngeo-seo-warnings" style="margin-top:8px;font-size:11px;color:#d63638;line-height:1.6;"></div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue( string $hook ): void {
|
||||||
|
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_script(
|
||||||
|
'brezngeo-seo-widget',
|
||||||
|
BREZNGEO_URL . 'assets/seo-widget.js',
|
||||||
|
array( 'jquery' ),
|
||||||
|
BREZNGEO_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$locale = get_locale();
|
||||||
|
// Convert WP locale (de_DE) to BCP 47 (de-DE) for JS toLocaleString
|
||||||
|
$bcp47 = str_replace( '_', '-', $locale );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-seo-widget',
|
||||||
|
'brezngeoWidget',
|
||||||
|
array(
|
||||||
|
'none' => __( 'None', 'brezngeo' ),
|
||||||
|
'noH1' => __( 'No H1 heading', 'brezngeo' ),
|
||||||
|
'multipleH1' => __( 'Multiple H1 headings', 'brezngeo' ),
|
||||||
|
'noInternalLinks' => __( 'No internal links', 'brezngeo' ),
|
||||||
|
'internal' => __( 'internal', 'brezngeo' ),
|
||||||
|
'external' => __( 'external', 'brezngeo' ),
|
||||||
|
'minLabel' => __( 'min', 'brezngeo' ),
|
||||||
|
'locale' => $bcp47,
|
||||||
|
'themeHasH1' => ! empty( $settings['theme_has_h1'] ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
brezngeo/includes/Admin/SettingsPage.php
Normal file
181
brezngeo/includes/Admin/SettingsPage.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
use BreznGEO\Helpers\KeyVault;
|
||||||
|
|
||||||
|
class SettingsPage {
|
||||||
|
/**
|
||||||
|
* Option key for provider + API key data (retains old key name for data continuity).
|
||||||
|
*/
|
||||||
|
public const OPTION_KEY_PROVIDER = 'brezngeo_settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option key for meta generator settings.
|
||||||
|
*/
|
||||||
|
public const OPTION_KEY_META = 'brezngeo_meta_settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option key for schema settings.
|
||||||
|
*/
|
||||||
|
public const OPTION_KEY_SCHEMA = 'brezngeo_schema_settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns merged settings from both option keys with defaults applied.
|
||||||
|
* Called by MetaGenerator, SchemaEnhancer, BulkPage, and admin pages.
|
||||||
|
*/
|
||||||
|
public static function getSettings(): array {
|
||||||
|
$defaults = array(
|
||||||
|
'provider' => 'openai',
|
||||||
|
'api_keys' => array(),
|
||||||
|
'models' => array(),
|
||||||
|
'meta_auto_enabled' => true,
|
||||||
|
'meta_post_types' => array( 'post', 'page' ),
|
||||||
|
'token_mode' => 'limit',
|
||||||
|
'token_limit' => 1000,
|
||||||
|
'prompt' => self::getDefaultPrompt(),
|
||||||
|
'schema_enabled' => array(),
|
||||||
|
'schema_same_as' => array(),
|
||||||
|
'costs' => array(),
|
||||||
|
'ai_enabled' => false,
|
||||||
|
'theme_has_h1' => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$saved_provider = get_option( self::OPTION_KEY_PROVIDER, array() );
|
||||||
|
$saved_provider = is_array( $saved_provider ) ? $saved_provider : array();
|
||||||
|
|
||||||
|
$saved_meta = get_option( self::OPTION_KEY_META, array() );
|
||||||
|
$saved_meta = is_array( $saved_meta ) ? $saved_meta : array();
|
||||||
|
|
||||||
|
// Schema has its own option key since v1.3.0; falls back to bre_meta_settings for existing installs.
|
||||||
|
$saved_schema = get_option( self::OPTION_KEY_SCHEMA, array() );
|
||||||
|
$saved_schema = is_array( $saved_schema ) ? $saved_schema : array();
|
||||||
|
|
||||||
|
$settings = array_merge( $defaults, $saved_provider, $saved_meta, $saved_schema );
|
||||||
|
|
||||||
|
foreach ( $settings['api_keys'] as $id => $stored ) {
|
||||||
|
$decrypted = KeyVault::decrypt( $stored );
|
||||||
|
// Fallback: if decrypt returns empty, the stored value is a legacy plain-text key
|
||||||
|
$settings['api_keys'][ $id ] = $decrypted !== '' ? $decrypted : $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDefaultPrompt(): string {
|
||||||
|
$locale = get_locale();
|
||||||
|
$is_german = str_starts_with( $locale, 'de_' );
|
||||||
|
|
||||||
|
if ( $is_german ) {
|
||||||
|
return 'Schreibe eine SEO-optimierte Meta-Beschreibung für den folgenden Artikel.' . "\n"
|
||||||
|
. 'Die Beschreibung soll für menschliche Leser verständlich und hilfreich sein,' . "\n"
|
||||||
|
. 'den Inhalt treffend zusammenfassen und zwischen 150 und 160 Zeichen lang sein.' . "\n"
|
||||||
|
. 'Schreibe die Meta-Beschreibung auf {language}.' . "\n"
|
||||||
|
. 'Antworte ausschließlich mit der Meta-Beschreibung, ohne Erklärung.' . "\n\n"
|
||||||
|
. 'Titel: {title}' . "\n"
|
||||||
|
. 'Inhalt: {content}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Write an SEO-optimised meta description for the following article.' . "\n"
|
||||||
|
. 'The description should be easy to understand for human readers,' . "\n"
|
||||||
|
. 'accurately summarise the content, and be between 150 and 160 characters long.' . "\n"
|
||||||
|
. 'Write the meta description in {language}.' . "\n"
|
||||||
|
. 'Reply with the meta description only, without any explanation.' . "\n\n"
|
||||||
|
. 'Title: {title}' . "\n"
|
||||||
|
. 'Content: {content}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept for backwards compatibility — used by BulkPage and tests.
|
||||||
|
* Validates and sanitises a combined settings array (provider + meta fields).
|
||||||
|
*/
|
||||||
|
public function sanitize_settings( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$raw_existing = get_option( self::OPTION_KEY_PROVIDER, array() );
|
||||||
|
$existing = is_array( $raw_existing ) ? $raw_existing : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$clean['provider'] = sanitize_key( $input['provider'] ?? 'openai' );
|
||||||
|
$clean['meta_auto_enabled'] = ! empty( $input['meta_auto_enabled'] );
|
||||||
|
$clean['theme_has_h1'] = ! empty( $input['theme_has_h1'] );
|
||||||
|
$clean['token_mode'] = in_array( $input['token_mode'] ?? '', array( 'limit', 'full' ), true )
|
||||||
|
? $input['token_mode'] : 'limit';
|
||||||
|
$clean['token_limit'] = max( 100, (int) ( $input['token_limit'] ?? 1000 ) );
|
||||||
|
$clean['prompt'] = sanitize_textarea_field( $input['prompt'] ?? self::getDefaultPrompt() );
|
||||||
|
|
||||||
|
$clean['api_keys'] = array();
|
||||||
|
foreach ( ( $input['api_keys'] ?? array() ) as $provider_id => $raw ) {
|
||||||
|
$provider_id = sanitize_key( $provider_id );
|
||||||
|
$raw = sanitize_text_field( $raw );
|
||||||
|
if ( $raw !== '' ) {
|
||||||
|
$clean['api_keys'][ $provider_id ] = KeyVault::encrypt( $raw );
|
||||||
|
} elseif ( isset( $existing['api_keys'][ $provider_id ] ) ) {
|
||||||
|
$clean['api_keys'][ $provider_id ] = $existing['api_keys'][ $provider_id ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean['models'] = array();
|
||||||
|
foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) {
|
||||||
|
$clean['models'][ sanitize_key( $provider_id ) ] = sanitize_text_field( $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean['costs'] = array();
|
||||||
|
foreach ( ( $input['costs'] ?? array() ) as $provider_id => $models ) {
|
||||||
|
$provider_id = sanitize_key( $provider_id );
|
||||||
|
foreach ( (array) $models as $model_id => $prices ) {
|
||||||
|
$model_id = sanitize_text_field( $model_id );
|
||||||
|
$clean['costs'][ $provider_id ][ $model_id ] = array(
|
||||||
|
'input' => max( 0.0, (float) ( $prices['input'] ?? 0 ) ),
|
||||||
|
'output' => max( 0.0, (float) ( $prices['output'] ?? 0 ) ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$all_post_types = array_keys( get_post_types( array( 'public' => true ) ) );
|
||||||
|
$clean['meta_post_types'] = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_key', (array) ( $input['meta_post_types'] ?? array() ) ),
|
||||||
|
$all_post_types
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$schema_types = array(
|
||||||
|
'organization',
|
||||||
|
'author',
|
||||||
|
'speakable',
|
||||||
|
'article_about',
|
||||||
|
'breadcrumb',
|
||||||
|
'ai_meta_tags',
|
||||||
|
'faq_schema',
|
||||||
|
'blog_posting',
|
||||||
|
'image_object',
|
||||||
|
'video_object',
|
||||||
|
'howto',
|
||||||
|
'review',
|
||||||
|
'recipe',
|
||||||
|
'event',
|
||||||
|
);
|
||||||
|
$clean['schema_enabled'] = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_key', (array) ( $input['schema_enabled'] ?? array() ) ),
|
||||||
|
$schema_types
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$org_raw = $input['schema_same_as']['organization'] ?? '';
|
||||||
|
if ( is_array( $org_raw ) ) {
|
||||||
|
$org_raw = implode( "\n", $org_raw );
|
||||||
|
}
|
||||||
|
$clean['schema_same_as'] = array(
|
||||||
|
'organization' => array_values(
|
||||||
|
array_filter(
|
||||||
|
array_map(
|
||||||
|
'esc_url_raw',
|
||||||
|
array_map( 'trim', explode( "\n", $org_raw ) )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
brezngeo/includes/Admin/TxtPage.php
Normal file
111
brezngeo/includes/Admin/TxtPage.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Features\LlmsTxt;
|
||||||
|
use BreznGEO\Features\RobotsTxt;
|
||||||
|
|
||||||
|
class TxtPage {
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_llms_clear_cache', array( $this, 'ajax_clear_cache' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void {
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_llms',
|
||||||
|
'brezngeo_llms_settings',
|
||||||
|
array( 'sanitize_callback' => array( $this, 'sanitize_llms' ) )
|
||||||
|
);
|
||||||
|
register_setting(
|
||||||
|
'brezngeo_robots',
|
||||||
|
'brezngeo_robots_settings',
|
||||||
|
array( 'sanitize_callback' => array( $this, 'sanitize_robots' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( $hook !== 'brezngeo_page_brezngeo-txt' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||||
|
wp_enqueue_script( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-admin',
|
||||||
|
'brezngeoAdmin',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'cacheCleared' => __( 'Cache cleared.', 'brezngeo' ),
|
||||||
|
'error' => __( 'Error.', 'brezngeo' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize_llms( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$clean = array();
|
||||||
|
|
||||||
|
$clean['enabled'] = ! empty( $input['enabled'] );
|
||||||
|
$clean['title'] = sanitize_text_field( $input['title'] ?? '' );
|
||||||
|
$clean['description_before'] = sanitize_textarea_field( $input['description_before'] ?? '' );
|
||||||
|
$clean['description_after'] = sanitize_textarea_field( $input['description_after'] ?? '' );
|
||||||
|
$clean['description_footer'] = sanitize_textarea_field( $input['description_footer'] ?? '' );
|
||||||
|
$clean['custom_links'] = sanitize_textarea_field( $input['custom_links'] ?? '' );
|
||||||
|
|
||||||
|
$all_post_types = array_keys( get_post_types( array( 'public' => true ) ) );
|
||||||
|
$clean['post_types'] = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_key', (array) ( $input['post_types'] ?? array() ) ),
|
||||||
|
$all_post_types
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$clean['max_links'] = max( 50, (int) ( $input['max_links'] ?? 500 ) );
|
||||||
|
|
||||||
|
LlmsTxt::clear_cache();
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize_robots( mixed $input ): array {
|
||||||
|
$input = is_array( $input ) ? $input : array();
|
||||||
|
$blocked = array_values(
|
||||||
|
array_intersect(
|
||||||
|
array_map( 'sanitize_text_field', (array) ( $input['blocked_bots'] ?? array() ) ),
|
||||||
|
array_keys( RobotsTxt::KNOWN_BOTS )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return array( 'blocked_bots' => $blocked );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_clear_cache(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error();
|
||||||
|
}
|
||||||
|
LlmsTxt::clear_cache();
|
||||||
|
wp_send_json_success( __( 'Cache cleared.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$valid_tabs = array( 'llms', 'robots' );
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||||
|
$raw_tab = sanitize_key( $_GET['tab'] ?? 'llms' );
|
||||||
|
$active_tab = in_array( $raw_tab, $valid_tabs, true ) ? $raw_tab : 'llms';
|
||||||
|
|
||||||
|
$llms_settings = LlmsTxt::getSettings();
|
||||||
|
$robots_settings = RobotsTxt::getSettings();
|
||||||
|
$post_types = get_post_types( array( 'public' => true ), 'objects' );
|
||||||
|
$llms_url = home_url( '/llms.txt' );
|
||||||
|
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/txt.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
102
brezngeo/includes/Admin/views/bulk.php
Normal file
102
brezngeo/includes/Admin/views/bulk.php
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;} ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'Bulk Generator', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<div id="brezngeo-lock-warning" style="display:none;background:#fcf8e3;border:1px solid #faebcc;padding:10px 15px;margin-bottom:15px;border-radius:3px;color:#8a6d3b;"></div>
|
||||||
|
|
||||||
|
<p><?php esc_html_e( 'Generates meta descriptions for posts without an existing meta description.', 'brezngeo' ); ?></p>
|
||||||
|
|
||||||
|
<div id="brezngeo-bulk-stats" style="background:#fff;padding:15px;border:1px solid #ddd;margin-bottom:20px;">
|
||||||
|
<em><?php esc_html_e( 'Loading statistics…', 'brezngeo' ); ?></em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ( ! $has_ai ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<div style="background:#fff3cd;border:1px solid #ffc107;padding:10px 15px;margin-bottom:20px;border-radius:3px;color:#856404;">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
wp_kses(
|
||||||
|
/* translators: %s: URL to provider settings page */
|
||||||
|
__( 'No AI provider connected — descriptions will be generated from content without AI (fallback mode). <a href="%s">Configure a provider →</a>', 'brezngeo' ),
|
||||||
|
array( 'a' => array( 'href' => array() ) )
|
||||||
|
),
|
||||||
|
esc_url( admin_url( 'admin.php?page=brezngeo-provider' ) )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<table class="form-table">
|
||||||
|
<?php if ( $has_ai ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Active Provider', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select id="brezngeo-bulk-provider">
|
||||||
|
<?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<option value="<?php echo esc_attr( $id ); ?>"
|
||||||
|
<?php selected( $settings['provider'], $id ); ?>>
|
||||||
|
<?php echo esc_html( $provider->getName() ); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Model:', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select id="brezngeo-bulk-model">
|
||||||
|
<?php
|
||||||
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$active_provider = $registry->get( $settings['provider'] );
|
||||||
|
if ( $active_provider ) :
|
||||||
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$saved_model = $settings['models'][ $settings['provider'] ] ?? array_key_first( $active_provider->getModels() );
|
||||||
|
foreach ( $active_provider->getModels() as $mid => $mlabel ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
?>
|
||||||
|
<option value="<?php echo esc_attr( $mid ); ?>"
|
||||||
|
<?php selected( $saved_model, $mid ); ?>>
|
||||||
|
<?php echo esc_html( $mlabel ); ?>
|
||||||
|
</option>
|
||||||
|
<?php
|
||||||
|
endforeach;
|
||||||
|
endif;
|
||||||
|
?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Max. posts this run', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" id="brezngeo-bulk-limit" value="20" min="1" max="500">
|
||||||
|
<p class="description" id="brezngeo-cost-estimate"></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button id="brezngeo-bulk-start" class="button button-primary"><?php esc_html_e( 'Start Bulk Run', 'brezngeo' ); ?></button>
|
||||||
|
<button id="brezngeo-bulk-stop" class="button" style="display:none;"><?php esc_html_e( 'Cancel', 'brezngeo' ); ?></button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="brezngeo-progress-wrap" style="display:none;margin:15px 0;">
|
||||||
|
<div style="background:#ddd;border-radius:3px;height:20px;width:100%;">
|
||||||
|
<div id="brezngeo-progress-bar"
|
||||||
|
style="background:#0073aa;height:20px;border-radius:3px;width:0;transition:width .3s;"></div>
|
||||||
|
</div>
|
||||||
|
<p id="brezngeo-progress-text"><?php esc_html_e( '0 / 0 processed', 'brezngeo' ); ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="brezngeo-bulk-log"
|
||||||
|
style="background:#1e1e1e;color:#d4d4d4;padding:15px;font-family:monospace;font-size:12px;max-height:400px;overflow-y:auto;display:none;"></div>
|
||||||
|
|
||||||
|
<div id="brezngeo-failed-summary" style="display:none;background:#fdf2f2;border:1px solid #f5c6cb;padding:10px 15px;margin-top:15px;border-radius:3px;font-size:13px;"></div>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
227
brezngeo/includes/Admin/views/dashboard.php
Normal file
227
brezngeo/includes/Admin/views/dashboard.php
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;} ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'BreznGEO — Dashboard', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<?php if ( $bre_show_welcome ) : ?>
|
||||||
|
<div class="brezngeo-welcome-notice" id="brezngeo-welcome-notice">
|
||||||
|
<button type="button" class="brezngeo-dismiss" id="brezngeo-dismiss-welcome"
|
||||||
|
aria-label="<?php esc_attr_e( 'Dismiss', 'brezngeo' ); ?>">×</button>
|
||||||
|
<p style="margin:0 0 6px;font-size:15px;">
|
||||||
|
🍺 <strong><?php esc_html_e( 'Servus! Welcome to BreznGEO.', 'brezngeo' ); ?></strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:#444;">
|
||||||
|
<?php esc_html_e( 'No Lederhosen required — your SEO is already in good hands.', 'brezngeo' ); ?>
|
||||||
|
<a href="<?php echo esc_url( 'https://brezngeo.com/howto.html' ); ?>" target="_blank" rel="noopener">
|
||||||
|
<?php esc_html_e( 'Read the setup guide and be running in five minutes →', 'brezngeo' ); ?>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="brezngeo-dashboard-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px;margin-top:20px;">
|
||||||
|
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'Meta Coverage', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside">
|
||||||
|
<?php if ( empty( $meta_stats ) ) : ?>
|
||||||
|
<p><?php esc_html_e( 'No post types configured.', 'brezngeo' ); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php foreach ( $meta_stats as $pt => $stat ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<div class="brezngeo-coverage-row">
|
||||||
|
<div class="brezngeo-coverage-label">
|
||||||
|
<strong><?php echo esc_html( $pt ); ?></strong>
|
||||||
|
<span class="brezngeo-coverage-stat">
|
||||||
|
<?php echo esc_html( $stat['with_meta'] ); ?>/<?php echo esc_html( $stat['total'] ); ?>
|
||||||
|
— <?php echo esc_html( $stat['pct'] ); ?>%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="brezngeo-progress-bar">
|
||||||
|
<div class="brezngeo-progress-fill <?php echo esc_attr( $stat['pct'] >= 80 ? 'brezngeo-ok' : ( $stat['pct'] >= 40 ? 'brezngeo-warn' : 'brezngeo-bad' ) ); ?>"
|
||||||
|
style="width:<?php echo esc_attr( $stat['pct'] ); ?>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'Quick Links', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside">
|
||||||
|
<ul class="brezngeo-quick-links-list">
|
||||||
|
<li><a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-provider' ) ); ?>">
|
||||||
|
🔑 <?php esc_html_e( 'AI Provider Settings', 'brezngeo' ); ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-meta' ) ); ?>">
|
||||||
|
✏️ <?php esc_html_e( 'Meta Generator Settings', 'brezngeo' ); ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-txt&tab=llms' ) ); ?>">
|
||||||
|
📄 llms.txt
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-bulk' ) ); ?>">
|
||||||
|
⚡ <?php esc_html_e( 'Bulk Generator', 'brezngeo' ); ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?php echo esc_url( 'https://brezngeo.com/howto.html' ); ?>" target="_blank" rel="noopener">
|
||||||
|
📖 <?php esc_html_e( 'Documentation & How To', 'brezngeo' ); ?>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'Status', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td class="brezngeo-stat-label"><?php esc_html_e( 'Version', 'brezngeo' ); ?></td>
|
||||||
|
<td class="brezngeo-stat-value"><?php echo esc_html( BREZNGEO_VERSION ); ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="brezngeo-stat-label"><?php esc_html_e( 'Active Provider', 'brezngeo' ); ?></td>
|
||||||
|
<td class="brezngeo-stat-value"><?php echo esc_html( $provider ); ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="brezngeo-stat-label"><?php esc_html_e( 'AI metas generated', 'brezngeo' ); ?></td>
|
||||||
|
<td class="brezngeo-stat-value"><?php echo esc_html( number_format_i18n( (int) ( $usage_stats['count'] ?? 0 ) ) ); ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="brezngeo-stat-label"><?php esc_html_e( 'Tokens used (est.)', 'brezngeo' ); ?></td>
|
||||||
|
<td class="brezngeo-stat-value">
|
||||||
|
~<?php echo esc_html( number_format_i18n( (int) ( $usage_stats['tokens_in'] ?? 0 ) + (int) ( $usage_stats['tokens_out'] ?? 0 ) ) ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php if ( null !== $cost_usd ) : ?>
|
||||||
|
<tr>
|
||||||
|
<td class="brezngeo-stat-label"><?php esc_html_e( 'Est. cost (USD)', 'brezngeo' ); ?></td>
|
||||||
|
<td class="brezngeo-stat-value">~$<?php echo esc_html( number_format( $cost_usd, 4 ) ); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</table>
|
||||||
|
<p style="margin:12px 0 0;">
|
||||||
|
<a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-provider' ) ); ?>" class="button button-secondary" style="font-size:12px;">
|
||||||
|
<?php esc_html_e( 'Configure AI Provider', 'brezngeo' ); ?>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ( $has_ai ) : ?>
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'AI Features', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside">
|
||||||
|
<?php if ( isset( $_GET['brezngeo-saved'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
|
||||||
|
<div class="notice notice-success inline" style="margin:0 0 12px;"><p><?php esc_html_e( 'Settings saved.', 'brezngeo' ); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p style="color:#666;margin-top:0;">
|
||||||
|
<?php esc_html_e( 'Choose which features may use your connected AI provider. All options are opt-in and disabled by default.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||||
|
<input type="hidden" name="action" value="brezngeo_save_ai_features">
|
||||||
|
<?php wp_nonce_field( 'brezngeo_save_ai_features' ); ?>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_ai_features[meta]" value="1"
|
||||||
|
<?php checked( $ai_features['meta'] ); ?>>
|
||||||
|
<strong><?php esc_html_e( 'Meta Descriptions', 'brezngeo' ); ?></strong>
|
||||||
|
</label>
|
||||||
|
<p style="margin:2px 0 0 22px;color:#777;font-size:12px;">
|
||||||
|
<?php esc_html_e( 'Generate meta descriptions with AI when editing or using the Bulk Generator.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_ai_features[links]" value="1"
|
||||||
|
<?php checked( $ai_features['links'] ); ?>>
|
||||||
|
<strong><?php esc_html_e( 'Internal Link Suggestions', 'brezngeo' ); ?></strong>
|
||||||
|
</label>
|
||||||
|
<p style="margin:2px 0 0 22px;color:#777;font-size:12px;">
|
||||||
|
<?php esc_html_e( 'Let AI pick the most natural anchor phrases and rank candidates semantically.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_ai_features[geo]" value="1"
|
||||||
|
<?php checked( $ai_features['geo'] ); ?>>
|
||||||
|
<strong><?php esc_html_e( 'GEO Block', 'brezngeo' ); ?></strong>
|
||||||
|
</label>
|
||||||
|
<p style="margin:2px 0 0 22px;color:#777;font-size:12px;">
|
||||||
|
<?php esc_html_e( 'Use AI to generate GEO-optimised content blocks for LLM visibility.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top:12px;">
|
||||||
|
<?php submit_button( __( 'Save', 'brezngeo' ), 'secondary', 'submit', false ); ?>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $bre_compat ) ) : ?>
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'Plugin Compatibility', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside">
|
||||||
|
<p style="color:#666;margin-top:0;"><?php esc_html_e( 'The following SEO plugins were detected. BreznGEO adapts automatically.', 'brezngeo' ); ?></p>
|
||||||
|
<?php foreach ( $bre_compat as $plugin ) : ?>
|
||||||
|
<p style="margin-bottom:4px;"><strong><?php echo esc_html( $plugin['name'] ); ?></strong></p>
|
||||||
|
<ul style="margin:0 0 12px 20px;">
|
||||||
|
<?php foreach ( $plugin['notes'] as $note ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<li><?php echo esc_html( $note ); ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'Internal Link Analysis', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside" id="brezngeo-link-analysis-content">
|
||||||
|
<em><?php esc_html_e( 'Loading…', 'brezngeo' ); ?></em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="postbox">
|
||||||
|
<div class="postbox-header"><h2><?php esc_html_e( 'AI Crawlers — Last 30 Days', 'brezngeo' ); ?></h2></div>
|
||||||
|
<div class="inside">
|
||||||
|
<?php if ( empty( $crawlers ) ) : ?>
|
||||||
|
<p><?php esc_html_e( 'No AI crawlers recorded yet.', 'brezngeo' ); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="widefat striped">
|
||||||
|
<thead><tr>
|
||||||
|
<th><?php esc_html_e( 'Bot', 'brezngeo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Visits', 'brezngeo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Last Seen', 'brezngeo' ); ?></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ( $crawlers as $row ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<tr>
|
||||||
|
<td><span class="brezngeo-bot-dot"></span><code><?php echo esc_html( $row['bot_name'] ); ?></code></td>
|
||||||
|
<td><?php echo esc_html( $row['visits'] ); ?></td>
|
||||||
|
<td><?php echo esc_html( $row['last_seen'] ); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
253
brezngeo/includes/Admin/views/geo.php
Normal file
253
brezngeo/includes/Admin/views/geo.php
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;}
|
||||||
|
?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'GEO Quick Overview', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_geo' ); ?>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_geo' ); ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Activation', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Enable GEO Block', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_geo_settings[enabled]" value="1"
|
||||||
|
<?php checked( $settings['enabled'], true ); ?>>
|
||||||
|
<?php esc_html_e( 'Output the Quick Overview block on the frontend', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Mode', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select name="brezngeo_geo_settings[mode]">
|
||||||
|
<option value="auto_on_publish" <?php selected( $settings['mode'], 'auto_on_publish' ); ?>>
|
||||||
|
<?php esc_html_e( 'Auto on publish / update (recommended)', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="hybrid" <?php selected( $settings['mode'], 'hybrid' ); ?>>
|
||||||
|
<?php esc_html_e( 'Hybrid: auto only when fields are empty', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="manual_only" <?php selected( $settings['mode'], 'manual_only' ); ?>>
|
||||||
|
<?php esc_html_e( 'Manual only (editor button)', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Post Types', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php foreach ( $post_types as $pt_slug => $pt_obj ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<label style="margin-right:15px;">
|
||||||
|
<input type="checkbox" name="brezngeo_geo_settings[post_types][]"
|
||||||
|
value="<?php echo esc_attr( $pt_slug ); ?>"
|
||||||
|
<?php checked( in_array( $pt_slug, $settings['post_types'], true ), true ); ?>>
|
||||||
|
<?php echo esc_html( $pt_obj->labels->singular_name ); ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Regenerate on update', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_geo_settings[regen_on_update]" value="1"
|
||||||
|
<?php checked( $settings['regen_on_update'], true ); ?>>
|
||||||
|
<?php esc_html_e( 'Regenerate on every save of a published post', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Word threshold for FAQ', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="brezngeo_geo_settings[word_threshold]"
|
||||||
|
value="<?php echo esc_attr( $settings['word_threshold'] ); ?>"
|
||||||
|
min="50" max="2000" style="width:80px;">
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Below this word count, no FAQ is generated. Default: 350', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Output', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Position', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select name="brezngeo_geo_settings[position]">
|
||||||
|
<option value="after_first_p" <?php selected( $settings['position'], 'after_first_p' ); ?>>
|
||||||
|
<?php esc_html_e( 'After first paragraph (default)', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="top" <?php selected( $settings['position'], 'top' ); ?>>
|
||||||
|
<?php esc_html_e( 'Top of post', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="bottom" <?php selected( $settings['position'], 'bottom' ); ?>>
|
||||||
|
<?php esc_html_e( 'Bottom of post', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Output style', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select name="brezngeo_geo_settings[output_style]">
|
||||||
|
<option value="details_collapsible" <?php selected( $settings['output_style'], 'details_collapsible' ); ?>>
|
||||||
|
<?php esc_html_e( 'Collapsible <details> (default)', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="open_always" <?php selected( $settings['output_style'], 'open_always' ); ?>>
|
||||||
|
<?php esc_html_e( 'Always open', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="store_only_no_frontend" <?php selected( $settings['output_style'], 'store_only_no_frontend' ); ?>>
|
||||||
|
<?php esc_html_e( 'Store only, no frontend output', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Labels', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Block title', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="brezngeo_geo_settings[title]"
|
||||||
|
value="<?php echo esc_attr( $settings['title'] ); ?>"
|
||||||
|
class="regular-text" placeholder="Quick Overview">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Summary label', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="brezngeo_geo_settings[label_summary]"
|
||||||
|
value="<?php echo esc_attr( $settings['label_summary'] ); ?>"
|
||||||
|
class="regular-text" placeholder="Summary">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Key Points label', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="brezngeo_geo_settings[label_bullets]"
|
||||||
|
value="<?php echo esc_attr( $settings['label_bullets'] ); ?>"
|
||||||
|
class="regular-text" placeholder="Key Points">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'FAQ label', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="brezngeo_geo_settings[label_faq]"
|
||||||
|
value="<?php echo esc_attr( $settings['label_faq'] ); ?>"
|
||||||
|
class="regular-text" placeholder="FAQ">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Styling', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Accent color', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="color" id="brezngeo-accent-picker" name="brezngeo_geo_settings[accent_color]"
|
||||||
|
value="<?php echo esc_attr( $settings['accent_color'] ?: '#0073aa' ); ?>"
|
||||||
|
style="width:60px;height:34px;padding:2px;border:1px solid #ccd0d4;border-radius:3px;cursor:pointer;"
|
||||||
|
oninput="document.getElementById('brezngeo-accent-text').value=this.value">
|
||||||
|
<input type="text" id="brezngeo-accent-text"
|
||||||
|
value="<?php echo esc_attr( $settings['accent_color'] ?: '#0073aa' ); ?>"
|
||||||
|
placeholder="#0073aa" maxlength="7" style="width:90px;vertical-align:middle;"
|
||||||
|
oninput="document.getElementById('brezngeo-accent-picker').value=this.value">
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Left border stripe and expand arrow colour. Leave empty for the default blue. Not used by the Minimal theme.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Theme', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select name="brezngeo_geo_settings[theme]">
|
||||||
|
<option value="light" <?php selected( $settings['theme'] ?? 'light', 'light' ); ?>>
|
||||||
|
<?php esc_html_e( 'Light', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="dark" <?php selected( $settings['theme'] ?? 'light', 'dark' ); ?>>
|
||||||
|
<?php esc_html_e( 'Dark', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="minimal" <?php selected( $settings['theme'] ?? 'light', 'minimal' ); ?>>
|
||||||
|
<?php esc_html_e( 'Minimal', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
<option value="bavarian" <?php selected( $settings['theme'] ?? 'light', 'bavarian' ); ?>>
|
||||||
|
<?php esc_html_e( 'Bavarian', 'brezngeo' ); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Light — clean card with a blue accent. Dark — same for dark-mode sites. Minimal — borderless, left stripe only. Bavarian — Bavarian blue with diamond header pattern.', 'brezngeo' ); ?>
|
||||||
|
<br>
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
wp_kses(
|
||||||
|
/* translators: %s: URL to the how-to page */
|
||||||
|
__( 'Want to customise further? <a href="%s" target="_blank" rel="noopener">Learn how to style the block via your theme →</a>', 'brezngeo' ),
|
||||||
|
array(
|
||||||
|
'a' => array(
|
||||||
|
'href' => array(),
|
||||||
|
'target' => array(),
|
||||||
|
'rel' => array(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
esc_url( 'https://brezngeo.com/howto.html#geo-block' )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'AI Prompt', 'brezngeo' ); ?></h2>
|
||||||
|
<?php if ( ! $has_ai ) : ?>
|
||||||
|
<div class="notice notice-warning inline" style="margin:0 0 12px;">
|
||||||
|
<p>
|
||||||
|
<strong><?php esc_html_e( 'No AI provider active.', 'brezngeo' ); ?></strong>
|
||||||
|
<?php esc_html_e( 'The GEO block will not be generated automatically until an API key is configured and AI generation is enabled.', 'brezngeo' ); ?>
|
||||||
|
<a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-provider' ) ); ?>"><?php esc_html_e( 'Configure AI Provider →', 'brezngeo' ); ?></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Default prompt', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<textarea name="brezngeo_geo_settings[prompt_default]" rows="12" class="large-text code">
|
||||||
|
<?php
|
||||||
|
echo esc_textarea( $settings['prompt_default'] );
|
||||||
|
?>
|
||||||
|
</textarea>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Variables: {title}, {content}, {language}', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Per-post prompt add-on', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_geo_settings[allow_prompt_addon]" value="1"
|
||||||
|
<?php checked( $settings['allow_prompt_addon'], true ); ?>>
|
||||||
|
<?php esc_html_e( 'Authors can enter a prompt add-on per post in the editor', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
29
brezngeo/includes/Admin/views/link-suggest-box.php
Normal file
29
brezngeo/includes/Admin/views/link-suggest-box.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit; } ?>
|
||||||
|
<div id="brezngeo-link-suggest-box">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<span style="color:#888;font-size:12px;" id="brezngeo-ls-status">
|
||||||
|
<?php esc_html_e( 'Click Analyse to find internal link opportunities.', 'brezngeo' ); ?>
|
||||||
|
</span>
|
||||||
|
<button type="button" id="brezngeo-ls-analyse" class="button">
|
||||||
|
<?php esc_html_e( 'Analyse', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="brezngeo-ls-results" style="display:none;margin-top:10px;">
|
||||||
|
<div id="brezngeo-ls-list"></div>
|
||||||
|
<div id="brezngeo-ls-actions" style="display:none;margin-top:8px;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||||
|
<button type="button" id="brezngeo-ls-select-all" class="button button-small">
|
||||||
|
<?php esc_html_e( 'All', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="brezngeo-ls-select-none" class="button button-small">
|
||||||
|
<?php esc_html_e( 'None', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="brezngeo-ls-apply" class="button button-primary" style="margin-left:auto;" disabled>
|
||||||
|
<?php esc_html_e( 'Apply (0 links)', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="brezngeo-ls-applied" style="display:none;color:#46b450;margin-top:8px;font-size:12px;"></div>
|
||||||
|
</div>
|
||||||
149
brezngeo/includes/Admin/views/link-suggest-settings.php
Normal file
149
brezngeo/includes/Admin/views/link-suggest-settings.php
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit; } ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'Link Suggestions', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_link_suggest' ); ?>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_link_suggest' ); ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Analysis Trigger', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'When to analyse', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="radio"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[trigger]"
|
||||||
|
value="manual" <?php checked( $settings['trigger'], 'manual' ); ?>>
|
||||||
|
<?php esc_html_e( 'Manual only (button)', 'brezngeo' ); ?>
|
||||||
|
</label><br>
|
||||||
|
<label>
|
||||||
|
<input type="radio"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[trigger]"
|
||||||
|
value="save" <?php checked( $settings['trigger'], 'save' ); ?>>
|
||||||
|
<?php esc_html_e( 'On post save', 'brezngeo' ); ?>
|
||||||
|
</label><br>
|
||||||
|
<label>
|
||||||
|
<input type="radio"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[trigger]"
|
||||||
|
value="interval" <?php checked( $settings['trigger'], 'interval' ); ?>>
|
||||||
|
<?php esc_html_e( 'Every', 'brezngeo' ); ?>
|
||||||
|
<input type="number" min="1" max="60"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[interval_min]"
|
||||||
|
value="<?php echo esc_attr( $settings['interval_min'] ); ?>"
|
||||||
|
style="width:55px;">
|
||||||
|
<?php esc_html_e( 'minutes', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Exclude Posts / Pages', 'brezngeo' ); ?></h2>
|
||||||
|
<p class="description"><?php esc_html_e( 'These posts will never appear as link suggestions (e.g. Imprint, Contact, Terms).', 'brezngeo' ); ?></p>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Excluded', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<div id="brezngeo-ls-excluded-list">
|
||||||
|
<?php
|
||||||
|
foreach ( $settings['excluded_posts'] as $pid ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$ptitle = get_the_title( $pid ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
if ( ! $ptitle ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<span class="brezngeo-ls-tag" style="display:inline-flex;align-items:center;gap:4px;background:#e0e0e0;padding:2px 8px;border-radius:3px;margin:2px;">
|
||||||
|
<?php echo esc_html( $ptitle ); ?>
|
||||||
|
<input type="hidden"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[excluded_posts][]"
|
||||||
|
value="<?php echo esc_attr( $pid ); ?>">
|
||||||
|
<button type="button" class="brezngeo-ls-remove" style="background:none;border:none;cursor:pointer;color:#555;" aria-label="<?php esc_attr_e( 'Remove', 'brezngeo' ); ?>">✕</button>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="brezngeo-ls-exclude-search"
|
||||||
|
placeholder="<?php esc_attr_e( 'Search posts…', 'brezngeo' ); ?>"
|
||||||
|
style="width:300px;margin-top:6px;">
|
||||||
|
<div id="brezngeo-ls-exclude-results"
|
||||||
|
style="display:none;border:1px solid #ddd;background:#fff;max-height:200px;overflow-y:auto;width:300px;position:absolute;z-index:100;"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Prioritise Posts / Pages', 'brezngeo' ); ?></h2>
|
||||||
|
<p class="description"><?php esc_html_e( 'Boosted posts rank higher when thematically relevant. A boost of 1.0 = no change.', 'brezngeo' ); ?></p>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Boosted', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<div id="brezngeo-ls-boosted-list">
|
||||||
|
<?php
|
||||||
|
foreach ( $settings['boosted_posts'] as $idx => $entry ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$ptitle = get_the_title( $entry['id'] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
if ( ! $ptitle ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="brezngeo-ls-boost-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||||
|
<span>★ <?php echo esc_html( $ptitle ); ?></span>
|
||||||
|
<input type="hidden"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[boosted_posts][<?php echo esc_attr( (string) (int) $idx ); ?>][id]"
|
||||||
|
value="<?php echo esc_attr( $entry['id'] ); ?>">
|
||||||
|
<label><?php esc_html_e( 'Boost:', 'brezngeo' ); ?>
|
||||||
|
<input type="number" step="0.1" min="1" max="10"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[boosted_posts][<?php echo esc_attr( (string) (int) $idx ); ?>][boost]"
|
||||||
|
value="<?php echo esc_attr( $entry['boost'] ); ?>"
|
||||||
|
style="width:60px;">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="button brezngeo-ls-remove"><?php esc_html_e( 'Remove', 'brezngeo' ); ?></button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="brezngeo-ls-boost-search"
|
||||||
|
placeholder="<?php esc_attr_e( 'Search posts…', 'brezngeo' ); ?>"
|
||||||
|
style="width:300px;margin-top:6px;">
|
||||||
|
<div id="brezngeo-ls-boost-results"
|
||||||
|
style="display:none;border:1px solid #ddd;background:#fff;max-height:200px;overflow-y:auto;width:300px;position:absolute;z-index:100;"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php if ( $has_ai ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<h2><?php esc_html_e( 'AI Options (optional)', 'brezngeo' ); ?></h2>
|
||||||
|
<p class="description"><?php esc_html_e( 'AI is connected — these settings control how many candidates are sent for semantic analysis.', 'brezngeo' ); ?></p>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Candidates to AI', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" min="1" max="50"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[ai_candidates]"
|
||||||
|
value="<?php echo esc_attr( $settings['ai_candidates'] ); ?>"
|
||||||
|
style="width:70px;">
|
||||||
|
<p class="description"><?php esc_html_e( 'How many pre-scored candidates are passed to the AI (max 50).', 'brezngeo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Max output tokens', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" min="100" max="2000"
|
||||||
|
name="<?php echo esc_attr( \BreznGEO\Features\LinkSuggest::OPTION_KEY ); ?>[ai_max_tokens]"
|
||||||
|
value="<?php echo esc_attr( $settings['ai_max_tokens'] ); ?>"
|
||||||
|
style="width:70px;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
117
brezngeo/includes/Admin/views/meta.php
Normal file
117
brezngeo/includes/Admin/views/meta.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;} ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'Meta Generator', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<?php if ( ! $has_ai ) : ?>
|
||||||
|
<div class="notice notice-warning inline" style="margin:12px 0;">
|
||||||
|
<p>
|
||||||
|
<strong><?php esc_html_e( 'No AI provider active.', 'brezngeo' ); ?></strong>
|
||||||
|
<?php esc_html_e( 'Meta descriptions will use the fallback method (first paragraph of the post) until an API key is configured and AI generation is enabled.', 'brezngeo' ); ?>
|
||||||
|
<a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-provider' ) ); ?>">
|
||||||
|
<?php esc_html_e( 'Configure AI Provider →', 'brezngeo' ); ?>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_meta' ); ?>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_meta' ); ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Meta Generator', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Auto Mode', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="brezngeo_meta_settings[meta_auto_enabled]"
|
||||||
|
value="1"
|
||||||
|
<?php checked( $settings['meta_auto_enabled'], true ); ?>>
|
||||||
|
<?php esc_html_e( 'Automatically generate meta description on publish', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Post Types', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php foreach ( $post_types as $pt_slug => $pt_obj ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<label style="margin-right:15px;">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="brezngeo_meta_settings[meta_post_types][]"
|
||||||
|
value="<?php echo esc_attr( $pt_slug ); ?>"
|
||||||
|
<?php checked( in_array( $pt_slug, $settings['meta_post_types'], true ), true ); ?>>
|
||||||
|
<?php echo esc_html( $pt_obj->labels->singular_name ); ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Token Mode', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php if ( ! $has_ai ) : ?>
|
||||||
|
<p class="description" style="margin-bottom:6px;color:#996800;">
|
||||||
|
<?php esc_html_e( 'Fallback mode active — configure an AI provider to enable AI generation.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="brezngeo_meta_settings[token_mode]" value="full"
|
||||||
|
<?php checked( $settings['token_mode'], 'full' ); ?>>
|
||||||
|
<?php esc_html_e( 'Send full article', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="brezngeo_meta_settings[token_mode]" value="limit"
|
||||||
|
<?php checked( $settings['token_mode'], 'limit' ); ?>>
|
||||||
|
<?php esc_html_e( 'Limit to', 'brezngeo' ); ?>
|
||||||
|
<input type="number"
|
||||||
|
name="brezngeo_meta_settings[token_limit]"
|
||||||
|
value="<?php echo esc_attr( $settings['token_limit'] ); ?>"
|
||||||
|
min="100" max="8000" style="width:80px;">
|
||||||
|
<?php esc_html_e( 'tokens', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Prompt', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<textarea name="brezngeo_meta_settings[prompt]"
|
||||||
|
rows="8"
|
||||||
|
class="large-text code"><?php echo esc_textarea( $settings['prompt'] ); ?></textarea>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Variables:', 'brezngeo' ); ?>
|
||||||
|
<code>{title}</code>, <code>{content}</code>, <code>{excerpt}</code>, <code>{language}</code><br>
|
||||||
|
<button type="button" class="button" id="brezngeo-reset-prompt"><?php esc_html_e( 'Reset prompt', 'brezngeo' ); ?></button>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'SEO Widget', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="brezngeo_meta_settings[theme_has_h1]"
|
||||||
|
value="1"
|
||||||
|
<?php checked( array( 'theme_has_h1' ) ?? true, true ); ?>>
|
||||||
|
<?php esc_html_e( 'Theme outputs post title as H1 (suppresses "no H1" warning in editor)', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Most themes render the post title as an H1 tag on the front end. Enable this to avoid false warnings in the SEO Widget when the content itself contains no H1.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
117
brezngeo/includes/Admin/views/provider.php
Normal file
117
brezngeo/includes/Admin/views/provider.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;} ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'AI Provider', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_provider' ); ?>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_provider' ); ?>
|
||||||
|
|
||||||
|
<div class="brezngeo-ai-toggle-wrap">
|
||||||
|
<label style="font-size:14px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:8px;">
|
||||||
|
<input type="checkbox" name="brezngeo_settings[ai_enabled]" value="1" id="brezngeo-ai-enabled"
|
||||||
|
<?php checked( $settings['ai_enabled'] ?? false, true ); ?>>
|
||||||
|
<?php esc_html_e( 'Enable AI generation', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
<p class="brezngeo-ai-cost-notice">
|
||||||
|
⚠ <?php esc_html_e( 'This feature will incur costs with your AI provider. Make sure you understand the pricing before entering an API key.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="brezngeo-ai-fields">
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'AI Provider', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Active Provider', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<select name="brezngeo_settings[provider]" id="brezngeo-provider">
|
||||||
|
<?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<option value="<?php echo esc_attr( $id ); ?>"
|
||||||
|
<?php selected( $settings['provider'], $id ); ?>>
|
||||||
|
<?php echo esc_html( $provider->getName() ); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<tr class="brezngeo-provider-row" data-provider="<?php echo esc_attr( $id ); ?>">
|
||||||
|
<th scope="row"><?php echo esc_html( $provider->getName() ); ?> <?php esc_html_e( 'API Key', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php if ( ! empty( $masked_keys[ $id ] ) ) : ?>
|
||||||
|
<span class="brezngeo-key-saved">
|
||||||
|
<?php esc_html_e( 'Saved:', 'brezngeo' ); ?> <code><?php echo esc_html( $masked_keys[ $id ] ); ?></code>
|
||||||
|
</span><br>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="password"
|
||||||
|
name="brezngeo_settings[api_keys][<?php echo esc_attr( $id ); ?>]"
|
||||||
|
value=""
|
||||||
|
placeholder="<?php echo ! empty( $masked_keys[ $id ] ) ? esc_attr__( 'Enter new key to overwrite', 'brezngeo' ) : esc_attr__( 'Enter API key', 'brezngeo' ); ?>"
|
||||||
|
class="regular-text"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<button type="button" class="button brezngeo-test-btn" data-provider="<?php echo esc_attr( $id ); ?>">
|
||||||
|
<?php esc_html_e( 'Test connection', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
<span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span>
|
||||||
|
<br><br>
|
||||||
|
<label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label>
|
||||||
|
<select name="brezngeo_settings[models][<?php echo esc_attr( $id ); ?>]">
|
||||||
|
<?php
|
||||||
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$saved_model = $settings['models'][ $id ] ?? array_key_first( $provider->getModels() );
|
||||||
|
foreach ( $provider->getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
?>
|
||||||
|
<option value="<?php echo esc_attr( $model_id ); ?>"
|
||||||
|
<?php selected( $saved_model, $model_id ); ?>>
|
||||||
|
<?php echo esc_html( $model_label ); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php
|
||||||
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$pricing_url = $pricing_urls[ $id ] ?? '';
|
||||||
|
if ( $pricing_url ) :
|
||||||
|
?>
|
||||||
|
<p style="margin-top:8px;">
|
||||||
|
<a href="<?php echo esc_url( $pricing_url ); ?>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<?php esc_html_e( 'View current pricing →', 'brezngeo' ); ?>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p style="margin-top:12px;"><strong><?php esc_html_e( 'Cost per 1 million tokens (for the Bulk cost overview):', 'brezngeo' ); ?></strong></p>
|
||||||
|
<?php
|
||||||
|
foreach ( $provider->getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
|
||||||
|
$saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array();
|
||||||
|
?>
|
||||||
|
<div style="margin-bottom:6px;display:flex;align-items:center;gap:12px;">
|
||||||
|
<label style="min-width:180px;font-size:12px;"><?php echo esc_html( $model_label ); ?>:</label>
|
||||||
|
<span>Input $<input type="number" step="0.0001" min="0"
|
||||||
|
name="brezngeo_settings[costs][<?php echo esc_attr( $id ); ?>][<?php echo esc_attr( $model_id ); ?>][input]"
|
||||||
|
value="<?php echo esc_attr( $saved_costs['input'] ?? '' ); ?>"
|
||||||
|
placeholder="z.B. 0.15" style="width:75px;"> / 1M</span>
|
||||||
|
<span>Output $<input type="number" step="0.0001" min="0"
|
||||||
|
name="brezngeo_settings[costs][<?php echo esc_attr( $id ); ?>][<?php echo esc_attr( $model_id ); ?>][output]"
|
||||||
|
value="<?php echo esc_attr( $saved_costs['output'] ?? '' ); ?>"
|
||||||
|
placeholder="z.B. 0.60" style="width:75px;"> / 1M</span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div><!-- /#brezngeo-ai-fields -->
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
135
brezngeo/includes/Admin/views/schema-meta-box.php
Normal file
135
brezngeo/includes/Admin/views/schema-meta-box.php
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* BreznGEO Schema Metabox view.
|
||||||
|
*
|
||||||
|
* Variables available: $type (string), $data (array), $enabled (array).
|
||||||
|
*/
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="brezngeo-schema-metabox">
|
||||||
|
<p>
|
||||||
|
<label for="brezngeo-schema-type"><strong><?php esc_html_e( 'Schema Type', 'brezngeo' ); ?></strong></label><br>
|
||||||
|
<select name="brezngeo_schema[schema_type]" id="brezngeo-schema-type">
|
||||||
|
<option value="" <?php selected( $type, '' ); ?>><?php esc_html_e( '— No Schema —', 'brezngeo' ); ?></option>
|
||||||
|
<?php if ( in_array( 'howto', $enabled, true ) ) : ?>
|
||||||
|
<option value="howto" <?php selected( $type, 'howto' ); ?>><?php esc_html_e( 'HowTo Guide', 'brezngeo' ); ?></option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( in_array( 'review', $enabled, true ) ) : ?>
|
||||||
|
<option value="review" <?php selected( $type, 'review' ); ?>><?php esc_html_e( 'Review / Rating', 'brezngeo' ); ?></option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( in_array( 'recipe', $enabled, true ) ) : ?>
|
||||||
|
<option value="recipe" <?php selected( $type, 'recipe' ); ?>><?php esc_html_e( 'Recipe', 'brezngeo' ); ?></option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( in_array( 'event', $enabled, true ) ) : ?>
|
||||||
|
<option value="event" <?php selected( $type, 'event' ); ?>><?php esc_html_e( 'Event', 'brezngeo' ); ?></option>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<!-- HowTo fields -->
|
||||||
|
<div class="brezngeo-schema-fields" data-brezngeo-type="howto" style="display:none;">
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Guide Name', 'brezngeo' ); ?></strong><br>
|
||||||
|
<input type="text" name="brezngeo_schema[howto_name]"
|
||||||
|
value="<?php echo esc_attr( $data['howto']['name'] ?? '' ); ?>"
|
||||||
|
class="widefat"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Steps (one line = one step)', 'brezngeo' ); ?></strong><br>
|
||||||
|
<textarea name="brezngeo_schema[howto_steps]" rows="5" class="widefat">
|
||||||
|
<?php
|
||||||
|
echo esc_textarea( implode( "\n", $data['howto']['steps'] ?? array() ) );
|
||||||
|
?>
|
||||||
|
</textarea></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Review fields -->
|
||||||
|
<div class="brezngeo-schema-fields" data-brezngeo-type="review" style="display:none;">
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Reviewed Product / Service', 'brezngeo' ); ?></strong><br>
|
||||||
|
<input type="text" name="brezngeo_schema[review_item]"
|
||||||
|
value="<?php echo esc_attr( $data['review']['item'] ?? '' ); ?>"
|
||||||
|
class="widefat"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Rating (1–5)', 'brezngeo' ); ?></strong><br>
|
||||||
|
<input type="number" name="brezngeo_schema[review_rating]" min="1" max="5" step="1"
|
||||||
|
value="<?php echo esc_attr( $data['review']['rating'] ?? 3 ); ?>"
|
||||||
|
style="width:60px;"></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Recipe fields -->
|
||||||
|
<div class="brezngeo-schema-fields" data-brezngeo-type="recipe" style="display:none;">
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Recipe Name', 'brezngeo' ); ?></strong><br>
|
||||||
|
<input type="text" name="brezngeo_schema[recipe_name]"
|
||||||
|
value="<?php echo esc_attr( $data['recipe']['name'] ?? '' ); ?>"
|
||||||
|
class="widefat"></label>
|
||||||
|
</p>
|
||||||
|
<p style="display:flex;gap:8px;">
|
||||||
|
<label style="flex:1;"><?php esc_html_e( 'Prep Time (min)', 'brezngeo' ); ?><br>
|
||||||
|
<input type="number" name="brezngeo_schema[recipe_prep]" min="0"
|
||||||
|
value="<?php echo esc_attr( $data['recipe']['prep'] ?? '' ); ?>"
|
||||||
|
style="width:100%;"></label>
|
||||||
|
<label style="flex:1;"><?php esc_html_e( 'Cook Time (min)', 'brezngeo' ); ?><br>
|
||||||
|
<input type="number" name="brezngeo_schema[recipe_cook]" min="0"
|
||||||
|
value="<?php echo esc_attr( $data['recipe']['cook'] ?? '' ); ?>"
|
||||||
|
style="width:100%;"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><?php esc_html_e( 'Servings', 'brezngeo' ); ?><br>
|
||||||
|
<input type="text" name="brezngeo_schema[recipe_servings]"
|
||||||
|
value="<?php echo esc_attr( $data['recipe']['servings'] ?? '' ); ?>"
|
||||||
|
class="widefat"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Ingredients (one per line)', 'brezngeo' ); ?></strong><br>
|
||||||
|
<textarea name="brezngeo_schema[recipe_ingredients]" rows="4" class="widefat">
|
||||||
|
<?php
|
||||||
|
echo esc_textarea( implode( "\n", $data['recipe']['ingredients'] ?? array() ) );
|
||||||
|
?>
|
||||||
|
</textarea></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Instructions (one step per line)', 'brezngeo' ); ?></strong><br>
|
||||||
|
<textarea name="brezngeo_schema[recipe_instructions]" rows="5" class="widefat">
|
||||||
|
<?php
|
||||||
|
echo esc_textarea( implode( "\n", $data['recipe']['instructions'] ?? array() ) );
|
||||||
|
?>
|
||||||
|
</textarea></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Event fields -->
|
||||||
|
<div class="brezngeo-schema-fields" data-brezngeo-type="event" style="display:none;">
|
||||||
|
<p>
|
||||||
|
<label><strong><?php esc_html_e( 'Event Name', 'brezngeo' ); ?></strong><br>
|
||||||
|
<input type="text" name="brezngeo_schema[event_name]"
|
||||||
|
value="<?php echo esc_attr( $data['event']['name'] ?? '' ); ?>"
|
||||||
|
class="widefat"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><?php esc_html_e( 'Start Date', 'brezngeo' ); ?><br>
|
||||||
|
<input type="date" name="brezngeo_schema[event_start]"
|
||||||
|
value="<?php echo esc_attr( $data['event']['start'] ?? '' ); ?>"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><?php esc_html_e( 'End Date (optional)', 'brezngeo' ); ?><br>
|
||||||
|
<input type="date" name="brezngeo_schema[event_end]"
|
||||||
|
value="<?php echo esc_attr( $data['event']['end'] ?? '' ); ?>"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><?php esc_html_e( 'Location or URL', 'brezngeo' ); ?><br>
|
||||||
|
<input type="text" name="brezngeo_schema[event_location]"
|
||||||
|
value="<?php echo esc_attr( $data['event']['location'] ?? '' ); ?>"
|
||||||
|
class="widefat"></label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_schema[event_online]" value="1"
|
||||||
|
<?php checked( ! empty( $data['event']['online'] ) ); ?>>
|
||||||
|
<?php esc_html_e( 'Online Event', 'brezngeo' ); ?>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
48
brezngeo/includes/Admin/views/schema.php
Normal file
48
brezngeo/includes/Admin/views/schema.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;} ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'Schema.org', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_schema' ); ?>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_schema' ); ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Schema.org Enhancer (GEO)', 'brezngeo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Enabled Schema Types', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php foreach ( $schema_labels as $type => $label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<label style="display:block;margin-bottom:8px;">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="brezngeo_schema_settings[schema_enabled][]"
|
||||||
|
value="<?php echo esc_attr( $type ); ?>"
|
||||||
|
<?php checked( in_array( $type, $settings['schema_enabled'], true ), true ); ?>>
|
||||||
|
<?php echo esc_html( $label ); ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Organization sameAs URLs', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<p class="description"><?php esc_html_e( 'One URL per line (Twitter, LinkedIn, GitHub, Facebook…)', 'brezngeo' ); ?></p>
|
||||||
|
<textarea name="brezngeo_schema_settings[schema_same_as][organization]"
|
||||||
|
rows="5"
|
||||||
|
class="large-text"><?php echo esc_textarea( implode( "\n", $settings['schema_same_as']['organization'] ?? array() ) ); ?></textarea>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
205
brezngeo/includes/Admin/views/txt.php
Normal file
205
brezngeo/includes/Admin/views/txt.php
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
<?php if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;} ?>
|
||||||
|
<div class="wrap brezngeo-settings">
|
||||||
|
<h1><?php esc_html_e( 'TXT Files', 'brezngeo' ); ?></h1>
|
||||||
|
|
||||||
|
<nav class="nav-tab-wrapper" style="margin-bottom:0;">
|
||||||
|
<a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-txt&tab=llms' ) ); ?>"
|
||||||
|
class="nav-tab <?php echo esc_attr( $active_tab === 'llms' ? 'nav-tab-active' : '' ); ?>">
|
||||||
|
llms.txt
|
||||||
|
<?php if ( $llms_settings['enabled'] ) : ?>
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#46b450;margin-left:5px;vertical-align:middle;"></span>
|
||||||
|
<?php else : ?>
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ccc;margin-left:5px;vertical-align:middle;"></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<a href="<?php echo esc_url( admin_url( 'admin.php?page=brezngeo-txt&tab=robots' ) ); ?>"
|
||||||
|
class="nav-tab <?php echo esc_attr( $active_tab === 'robots' ? 'nav-tab-active' : '' ); ?>">
|
||||||
|
robots.txt
|
||||||
|
<?php $bre_blocked_count = count( $robots_settings['blocked_bots'] ?? array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<?php if ( $bre_blocked_count > 0 ) : ?>
|
||||||
|
<span style="display:inline-block;background:#2271b1;color:#fff;border-radius:10px;font-size:11px;padding:1px 7px;margin-left:6px;vertical-align:middle;line-height:1.6;">
|
||||||
|
<?php echo esc_html( $bre_blocked_count ); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style="background:#fff;border:1px solid #c3c4c7;border-top:none;padding:20px 24px 0;margin-bottom:20px;">
|
||||||
|
|
||||||
|
<?php if ( $active_tab === 'llms' ) : ?>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_llms' ); ?>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<?php esc_html_e( 'URL:', 'brezngeo' ); ?>
|
||||||
|
<a href="<?php echo esc_url( $llms_url ); ?>" target="_blank" rel="noopener">
|
||||||
|
<?php echo esc_html( $llms_url ); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button id="brezngeo-llms-clear-cache" class="button button-small">
|
||||||
|
<?php esc_html_e( 'Clear Cache', 'brezngeo' ); ?>
|
||||||
|
</button>
|
||||||
|
<span id="brezngeo-cache-result" style="color:#46b450;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_llms' ); ?>
|
||||||
|
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Enable llms.txt', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="brezngeo_llms_settings[enabled]" value="1"
|
||||||
|
<?php checked( $llms_settings['enabled'], true ); ?>>
|
||||||
|
<?php esc_html_e( 'Serve llms.txt at', 'brezngeo' ); ?>
|
||||||
|
<code>/llms.txt</code>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Title', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="text"
|
||||||
|
name="brezngeo_llms_settings[title]"
|
||||||
|
value="<?php echo esc_attr( $llms_settings['title'] ); ?>"
|
||||||
|
class="regular-text"
|
||||||
|
placeholder="<?php echo esc_attr( get_bloginfo( 'name' ) ); ?>">
|
||||||
|
<p class="description"><?php esc_html_e( 'Appears as the # heading in llms.txt', 'brezngeo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Description (before links)', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<textarea name="brezngeo_llms_settings[description_before]"
|
||||||
|
rows="4" class="large-text"><?php echo esc_textarea( $llms_settings['description_before'] ); ?></textarea>
|
||||||
|
<p class="description"><?php esc_html_e( 'Text shown after the title, before featured links.', 'brezngeo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Featured Links', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<textarea name="brezngeo_llms_settings[custom_links]"
|
||||||
|
rows="5" class="large-text"><?php echo esc_textarea( $llms_settings['custom_links'] ); ?></textarea>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Important links to highlight for AI models. One per line.', 'brezngeo' ); ?>
|
||||||
|
<?php esc_html_e( 'Markdown format recommended:', 'brezngeo' ); ?>
|
||||||
|
<code>- [Link Name](https://url.com)</code>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Post Types', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php foreach ( $post_types as $pt_slug => $pt_obj ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<label style="margin-right:15px;">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="brezngeo_llms_settings[post_types][]"
|
||||||
|
value="<?php echo esc_attr( $pt_slug ); ?>"
|
||||||
|
<?php checked( in_array( $pt_slug, $llms_settings['post_types'], true ), true ); ?>>
|
||||||
|
<?php echo esc_html( $pt_obj->labels->singular_name ); ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<p class="description"><?php esc_html_e( 'Which post types to include in the content list.', 'brezngeo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Max. links per page', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="brezngeo_llms_settings[max_links]"
|
||||||
|
value="<?php echo esc_attr( $llms_settings['max_links'] ?? 500 ); ?>"
|
||||||
|
min="50" max="5000" style="width:80px;">
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'With more posts, llms-2.txt, llms-3.txt etc. are created and linked automatically.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Description (after content)', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<textarea name="brezngeo_llms_settings[description_after]"
|
||||||
|
rows="4" class="large-text"><?php echo esc_textarea( $llms_settings['description_after'] ); ?></textarea>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><?php esc_html_e( 'Footer Description', 'brezngeo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<textarea name="brezngeo_llms_settings[description_footer]"
|
||||||
|
rows="4" class="large-text"><?php echo esc_textarea( $llms_settings['description_footer'] ); ?></textarea>
|
||||||
|
<p class="description"><?php esc_html_e( 'Appears at the end of llms.txt after a --- separator.', 'brezngeo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save llms.txt Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="description" style="padding-bottom:4px;">
|
||||||
|
<?php esc_html_e( 'Note: If the URL shows a 404, go to Settings → Permalinks and click Save to flush rewrite rules.', 'brezngeo' ); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php else : ?>
|
||||||
|
|
||||||
|
<?php settings_errors( 'brezngeo_robots' ); ?>
|
||||||
|
|
||||||
|
<div style="margin-bottom:18px;">
|
||||||
|
<p style="margin:0 0 6px;">
|
||||||
|
<?php esc_html_e( 'Block known AI bots for this site.', 'brezngeo' ); ?>
|
||||||
|
<strong><?php esc_html_e( 'Note: Bots are not required to comply.', 'brezngeo' ); ?></strong>
|
||||||
|
</p>
|
||||||
|
<a href="<?php echo esc_url( home_url( '/robots.txt' ) ); ?>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<?php esc_html_e( 'View current robots.txt →', 'brezngeo' ); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'brezngeo_robots' ); ?>
|
||||||
|
|
||||||
|
<table class="widefat striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'User-Agent', 'brezngeo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Description', 'brezngeo' ); ?></th>
|
||||||
|
<th style="width:80px;text-align:center;"><?php esc_html_e( 'Block', 'brezngeo' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ( \BreznGEO\Features\RobotsTxt::KNOWN_BOTS as $bot_key => $bot_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
|
||||||
|
<tr>
|
||||||
|
<td><code><?php echo esc_html( $bot_key ); ?></code></td>
|
||||||
|
<td><?php echo esc_html( $bot_label ); ?></td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="brezngeo_robots_settings[blocked_bots][]"
|
||||||
|
value="<?php echo esc_attr( $bot_key ); ?>"
|
||||||
|
<?php checked( in_array( $bot_key, $robots_settings['blocked_bots'], true ) ); ?>>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php submit_button( __( 'Save robots.txt Settings', 'brezngeo' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="brezngeo-footer">
|
||||||
|
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||||
|
<?php esc_html_e( 'developed by', 'brezngeo' ); ?> 🍺
|
||||||
|
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||||
|
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||||
|
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
85
brezngeo/includes/Core.php
Normal file
85
brezngeo/includes/Core.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO;
|
||||||
|
|
||||||
|
class Core {
|
||||||
|
private static ?Core $instance = null;
|
||||||
|
|
||||||
|
public static function instance(): self {
|
||||||
|
if ( null === self::$instance ) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function init(): void {
|
||||||
|
$this->load_dependencies();
|
||||||
|
$this->register_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load_dependencies(): void {
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Providers/ProviderInterface.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Providers/ProviderRegistry.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Providers/OpenAIProvider.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Providers/AnthropicProvider.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Providers/GeminiProvider.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Providers/GrokProvider.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Helpers/KeyVault.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Helpers/TokenEstimator.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Helpers/FallbackMeta.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Helpers/BulkQueue.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/MetaGenerator.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/SchemaEnhancer.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/LlmsTxt.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/RobotsTxt.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/CrawlerLog.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/GeoBlock.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/SettingsPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/AdminMenu.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/ProviderPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/MetaPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/BulkPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/TxtPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/MetaEditorBox.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/SeoWidget.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/LinkAnalysis.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Features/LinkSuggest.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/LinkSuggestPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/GeoPage.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/GeoEditorBox.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/SchemaMetaBox.php';
|
||||||
|
require_once BREZNGEO_DIR . 'includes/Admin/SchemaPage.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function register_hooks(): void {
|
||||||
|
$registry = ProviderRegistry::instance();
|
||||||
|
$registry->register( new Providers\OpenAIProvider() );
|
||||||
|
$registry->register( new Providers\AnthropicProvider() );
|
||||||
|
$registry->register( new Providers\GeminiProvider() );
|
||||||
|
$registry->register( new Providers\GrokProvider() );
|
||||||
|
|
||||||
|
( new Features\MetaGenerator() )->register();
|
||||||
|
( new Features\SchemaEnhancer() )->register();
|
||||||
|
( new Features\LlmsTxt() )->register();
|
||||||
|
( new Features\RobotsTxt() )->register();
|
||||||
|
( new Features\CrawlerLog() )->register();
|
||||||
|
( new Features\GeoBlock() )->register();
|
||||||
|
|
||||||
|
if ( is_admin() ) {
|
||||||
|
$menu = new Admin\AdminMenu();
|
||||||
|
$menu->register();
|
||||||
|
( new Admin\ProviderPage() )->register();
|
||||||
|
( new Admin\MetaPage() )->register();
|
||||||
|
( new Admin\BulkPage() )->register();
|
||||||
|
( new Admin\TxtPage() )->register();
|
||||||
|
( new Admin\MetaEditorBox() )->register();
|
||||||
|
( new Admin\SeoWidget() )->register();
|
||||||
|
( new Admin\LinkAnalysis() )->register();
|
||||||
|
( new Features\LinkSuggest() )->register();
|
||||||
|
( new Admin\LinkSuggestPage() )->register();
|
||||||
|
( new Admin\GeoPage() )->register();
|
||||||
|
( new Admin\GeoEditorBox() )->register();
|
||||||
|
( new Admin\SchemaMetaBox() )->register();
|
||||||
|
( new Admin\SchemaPage() )->register();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
brezngeo/includes/Features/CrawlerLog.php
Normal file
104
brezngeo/includes/Features/CrawlerLog.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CrawlerLog {
|
||||||
|
private const TABLE = 'brezngeo_crawler_log';
|
||||||
|
private const CRON = 'brezngeo_purge_crawler_log';
|
||||||
|
|
||||||
|
public static function install(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . self::TABLE;
|
||||||
|
$charset = $wpdb->get_charset_collate();
|
||||||
|
|
||||||
|
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
bot_name VARCHAR(64) NOT NULL,
|
||||||
|
ip_hash VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
url VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
visited_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY bot_name (bot_name),
|
||||||
|
KEY visited_at (visited_at)
|
||||||
|
) {$charset};";
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
dbDelta( $sql );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'init', array( $this, 'maybe_log' ), 1 );
|
||||||
|
add_action( self::CRON, array( $this, 'purge_old' ) );
|
||||||
|
|
||||||
|
if ( ! wp_next_scheduled( self::CRON ) ) {
|
||||||
|
wp_schedule_event( time(), 'weekly', self::CRON );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybe_log(): void {
|
||||||
|
$ua = isset( $_SERVER['HTTP_USER_AGENT'] )
|
||||||
|
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
|
||||||
|
: '';
|
||||||
|
if ( empty( $ua ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bot = $this->detect_bot( $ua );
|
||||||
|
if ( null === $bot ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->prefix . self::TABLE,
|
||||||
|
array(
|
||||||
|
'bot_name' => $bot,
|
||||||
|
'ip_hash' => hash( 'sha256', isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ),
|
||||||
|
'url' => mb_substr( isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', 0, 512 ),
|
||||||
|
'visited_at' => current_time( 'mysql' ),
|
||||||
|
),
|
||||||
|
array( '%s', '%s', '%s', '%s' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detect_bot( string $ua ): ?string {
|
||||||
|
foreach ( array_keys( RobotsTxt::KNOWN_BOTS ) as $bot ) {
|
||||||
|
if ( false !== stripos( $ua, $bot ) ) {
|
||||||
|
return $bot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purge_old(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . self::TABLE;
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
"DELETE FROM `{$table}` WHERE visited_at < DATE_SUB(NOW(), INTERVAL 90 DAY)"
|
||||||
|
);
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get_recent_summary( int $days = 30 ): array {
|
||||||
|
global $wpdb;
|
||||||
|
if ( ! isset( $wpdb ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$table = $wpdb->prefix . self::TABLE;
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$sql = "SELECT bot_name, COUNT(*) as visits, MAX(visited_at) as last_seen
|
||||||
|
FROM `{$table}`
|
||||||
|
WHERE visited_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
|
||||||
|
GROUP BY bot_name
|
||||||
|
ORDER BY visits DESC";
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->prepare( $sql, $days ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
ARRAY_A
|
||||||
|
) ?: array();
|
||||||
|
}
|
||||||
|
}
|
||||||
486
brezngeo/includes/Features/GeoBlock.php
Normal file
486
brezngeo/includes/Features/GeoBlock.php
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Admin\SettingsPage;
|
||||||
|
use BreznGEO\Helpers\TokenEstimator;
|
||||||
|
use BreznGEO\ProviderRegistry;
|
||||||
|
|
||||||
|
class GeoBlock {
|
||||||
|
public const OPTION_KEY = 'brezngeo_geo_settings';
|
||||||
|
|
||||||
|
// Post meta keys
|
||||||
|
public const META_ENABLED = '_brezngeo_geo_enabled';
|
||||||
|
public const META_LOCK = '_brezngeo_geo_lock';
|
||||||
|
public const META_GENERATED = '_brezngeo_geo_last_generated_at';
|
||||||
|
public const META_SUMMARY = '_brezngeo_geo_summary';
|
||||||
|
public const META_BULLETS = '_brezngeo_geo_bullets';
|
||||||
|
public const META_FAQ = '_brezngeo_geo_faq';
|
||||||
|
public const META_ADDON = '_brezngeo_geo_prompt_addon';
|
||||||
|
|
||||||
|
// Fluff phrases to detect in AI output
|
||||||
|
private const FLUFF_PHRASES = array(
|
||||||
|
'ultimativ',
|
||||||
|
'gamechanger',
|
||||||
|
'in diesem artikel',
|
||||||
|
'wir schauen uns an',
|
||||||
|
'in this article',
|
||||||
|
'ultimate guide',
|
||||||
|
'game changer',
|
||||||
|
'game-changer',
|
||||||
|
);
|
||||||
|
|
||||||
|
public static function getSettings(): array {
|
||||||
|
$defaults = array(
|
||||||
|
'enabled' => false,
|
||||||
|
'mode' => 'auto_on_publish',
|
||||||
|
'post_types' => array( 'post', 'page' ),
|
||||||
|
'position' => 'after_first_p',
|
||||||
|
'output_style' => 'details_collapsible',
|
||||||
|
'title' => 'Quick Overview',
|
||||||
|
'label_summary' => 'Summary',
|
||||||
|
'label_bullets' => 'Key Points',
|
||||||
|
'label_faq' => 'FAQ',
|
||||||
|
'theme' => 'light',
|
||||||
|
'accent_color' => '',
|
||||||
|
'prompt_default' => self::getDefaultPrompt(),
|
||||||
|
'word_threshold' => 350,
|
||||||
|
'regen_on_update' => false,
|
||||||
|
'allow_prompt_addon' => false,
|
||||||
|
);
|
||||||
|
$saved = get_option( self::OPTION_KEY, array() );
|
||||||
|
return array_merge( $defaults, is_array( $saved ) ? $saved : array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDefaultPrompt(): string {
|
||||||
|
return 'Analyze the following article and create a structured quick overview.' . "\n"
|
||||||
|
. 'Respond exclusively with a valid JSON object (no Markdown code fences, no text before or after).' . "\n\n"
|
||||||
|
. 'Language: {language}' . "\n"
|
||||||
|
. 'Article title: {title}' . "\n\n"
|
||||||
|
. 'Rules:' . "\n"
|
||||||
|
. '- summary: 40–90 words, neutral, factual, no advertising, no superlatives.' . "\n"
|
||||||
|
. '- bullets: 3–7 short key points. No repetition from the summary.' . "\n"
|
||||||
|
. '- faq: 0–5 question-answer pairs, ONLY if the article genuinely answers questions. Otherwise empty array [].' . "\n"
|
||||||
|
. '- Do not invent anything. No keyword stuffing. Short, clear sentences.' . "\n"
|
||||||
|
. '- No phrases like "In this article", "ultimate", "game changer".' . "\n\n"
|
||||||
|
. 'JSON format (exact):' . "\n"
|
||||||
|
. '{"summary":"...","bullets":["...","..."],"faq":[{"q":"...","a":"..."}]}' . "\n\n"
|
||||||
|
. 'Article content:' . "\n"
|
||||||
|
. '{content}';
|
||||||
|
}
|
||||||
|
public function generate( int $post_id, bool $force = false ): bool {
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! $post ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = self::getSettings();
|
||||||
|
|
||||||
|
// Check lock
|
||||||
|
if ( ! $force && get_post_meta( $post_id, self::META_LOCK, true ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$global = SettingsPage::getSettings();
|
||||||
|
$provider = ProviderRegistry::instance()->get( $global['provider'] );
|
||||||
|
$api_key = $global['api_keys'][ $global['provider'] ] ?? '';
|
||||||
|
|
||||||
|
if ( ! $provider || empty( $api_key ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = $global['models'][ $global['provider'] ] ?? array_key_first( $provider->getModels() );
|
||||||
|
$content = wp_strip_all_tags( do_shortcode( $post->post_content ) );
|
||||||
|
|
||||||
|
// Token-limit the content input
|
||||||
|
$content = TokenEstimator::truncate( $content, 2000 );
|
||||||
|
|
||||||
|
$word_count = str_word_count( $content );
|
||||||
|
$force_no_faq = $word_count < (int) $settings['word_threshold'];
|
||||||
|
$addon = $settings['allow_prompt_addon']
|
||||||
|
? sanitize_textarea_field( get_post_meta( $post_id, self::META_ADDON, true ) )
|
||||||
|
: '';
|
||||||
|
$prompt = $this->buildPrompt( $post, $content, $settings, $addon, $force_no_faq );
|
||||||
|
|
||||||
|
try {
|
||||||
|
$raw = $provider->generateText( $prompt, $api_key, $model, 800 );
|
||||||
|
$parsed = $this->parseResponse( $raw );
|
||||||
|
if ( null === $parsed ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$data = $this->qualityGate( $parsed, $force_no_faq );
|
||||||
|
$this->saveMeta( $post_id, $data );
|
||||||
|
return true;
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||||
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||||
|
error_log( '[BreznGEO GEO] Generation failed for post ' . $post_id . ': ' . $e->getMessage() );
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPrompt( \WP_Post $post, string $content, array $settings, string $addon, bool $force_no_faq ): string {
|
||||||
|
$locale_map = array(
|
||||||
|
'de_DE' => 'Deutsch',
|
||||||
|
'de_DE_formal' => 'Deutsch',
|
||||||
|
'de_AT' => 'Deutsch',
|
||||||
|
'de_CH' => 'Deutsch',
|
||||||
|
'de_CH_informal' => 'Deutsch',
|
||||||
|
'en_US' => 'English',
|
||||||
|
'en_GB' => 'English',
|
||||||
|
'en_AU' => 'English',
|
||||||
|
'en_CA' => 'English',
|
||||||
|
'fr_FR' => 'Français',
|
||||||
|
'fr_BE' => 'Français',
|
||||||
|
'fr_CA' => 'Français',
|
||||||
|
'es_ES' => 'Español',
|
||||||
|
'es_MX' => 'Español',
|
||||||
|
'it_IT' => 'Italiano',
|
||||||
|
'nl_NL' => 'Nederlands',
|
||||||
|
'nl_NL_formal' => 'Nederlands',
|
||||||
|
'pt_PT' => 'Português',
|
||||||
|
'pt_BR' => 'Português do Brasil',
|
||||||
|
'pl_PL' => 'Polski',
|
||||||
|
'ru_RU' => 'Русский',
|
||||||
|
'sv_SE' => 'Svenska',
|
||||||
|
'da_DK' => 'Dansk',
|
||||||
|
'nb_NO' => 'Norsk',
|
||||||
|
'fi' => 'Suomi',
|
||||||
|
'cs_CZ' => 'Čeština',
|
||||||
|
'sk_SK' => 'Slovenčina',
|
||||||
|
'hu_HU' => 'Magyar',
|
||||||
|
'ro_RO' => 'Română',
|
||||||
|
'bg_BG' => 'Български',
|
||||||
|
'el' => 'Ελληνικά',
|
||||||
|
'hr' => 'Hrvatski',
|
||||||
|
'tr_TR' => 'Türkçe',
|
||||||
|
'ar' => 'العربية',
|
||||||
|
'he_IL' => 'עברית',
|
||||||
|
'zh_CN' => '中文(简体)',
|
||||||
|
'zh_TW' => '中文(繁體)',
|
||||||
|
'ja' => '日本語',
|
||||||
|
'ko_KR' => '한국어',
|
||||||
|
);
|
||||||
|
$prefix_map = array(
|
||||||
|
'de' => 'Deutsch',
|
||||||
|
'en' => 'English',
|
||||||
|
'fr' => 'Français',
|
||||||
|
'es' => 'Español',
|
||||||
|
'it' => 'Italiano',
|
||||||
|
'nl' => 'Nederlands',
|
||||||
|
'pt' => 'Português',
|
||||||
|
'pl' => 'Polski',
|
||||||
|
'ru' => 'Русский',
|
||||||
|
'sv' => 'Svenska',
|
||||||
|
'da' => 'Dansk',
|
||||||
|
'nb' => 'Norsk',
|
||||||
|
'no' => 'Norsk',
|
||||||
|
'fi' => 'Suomi',
|
||||||
|
'cs' => 'Čeština',
|
||||||
|
'tr' => 'Türkçe',
|
||||||
|
'ja' => '日本語',
|
||||||
|
'ko' => '한국어',
|
||||||
|
'zh' => '中文',
|
||||||
|
'ar' => 'العربية',
|
||||||
|
'he' => 'עברית',
|
||||||
|
'hu' => 'Magyar',
|
||||||
|
'ro' => 'Română',
|
||||||
|
'bg' => 'Български',
|
||||||
|
'el' => 'Ελληνικά',
|
||||||
|
'hr' => 'Hrvatski',
|
||||||
|
);
|
||||||
|
|
||||||
|
$locale = get_locale();
|
||||||
|
$language = $locale_map[ $locale ]
|
||||||
|
?? $prefix_map[ strtolower( substr( $locale, 0, 2 ) ) ]
|
||||||
|
?? $locale;
|
||||||
|
|
||||||
|
if ( function_exists( 'pll_get_post_language' ) ) {
|
||||||
|
$pll_lang = pll_get_post_language( $post->ID, 'name' );
|
||||||
|
if ( $pll_lang ) {
|
||||||
|
$language = $pll_lang;
|
||||||
|
}
|
||||||
|
} elseif ( defined( 'ICL_LANGUAGE_CODE' ) ) {
|
||||||
|
$wpml_code = strtolower( (string) ICL_LANGUAGE_CODE );
|
||||||
|
$language = $prefix_map[ $wpml_code ] ?? $language;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = $settings['prompt_default'];
|
||||||
|
$prompt = str_replace( '{title}', $post->post_title, $prompt );
|
||||||
|
$prompt = str_replace( '{content}', $content, $prompt );
|
||||||
|
$prompt = str_replace( '{language}', $language, $prompt );
|
||||||
|
|
||||||
|
if ( $force_no_faq ) {
|
||||||
|
$prompt .= "\n\nIMPORTANT: Always set faq to an empty array: []";
|
||||||
|
}
|
||||||
|
if ( ! empty( $addon ) ) {
|
||||||
|
$prompt .= "\n\nAdditional instruction: " . $addon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseResponse( string $raw ): ?array {
|
||||||
|
// Strip markdown code fences if present
|
||||||
|
$raw = preg_replace( '/^```(?:json)?\s*/i', '', trim( $raw ) );
|
||||||
|
$raw = preg_replace( '/\s*```$/', '', $raw );
|
||||||
|
$raw = trim( $raw );
|
||||||
|
|
||||||
|
$data = json_decode( $raw, true );
|
||||||
|
if ( ! is_array( $data ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some AI providers double-encode unicode in string values
|
||||||
|
// (e.g. ä becomes the literal 6-char sequence \u00e4).
|
||||||
|
// Decode those residual sequences so ä/ö/ü store and display correctly.
|
||||||
|
$data = $this->decodeUnicode( $data );
|
||||||
|
|
||||||
|
// Require at minimum a summary
|
||||||
|
if ( empty( $data['summary'] ) || ! is_string( $data['summary'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeUnicode( mixed $val ): mixed {
|
||||||
|
if ( is_string( $val ) ) {
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/\\\\u([0-9a-fA-F]{4})/',
|
||||||
|
static function ( array $m ): string {
|
||||||
|
$char = mb_chr( hexdec( $m[1] ), 'UTF-8' );
|
||||||
|
return $char !== false ? $char : $m[0];
|
||||||
|
},
|
||||||
|
$val
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( is_array( $val ) ) {
|
||||||
|
return array_map( array( $this, 'decodeUnicode' ), $val );
|
||||||
|
}
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function qualityGate( array $data, bool $force_no_faq ): array {
|
||||||
|
$summary = trim( $data['summary'] ?? '' );
|
||||||
|
$bullets = array_values( array_filter( (array) ( $data['bullets'] ?? array() ), 'is_string' ) );
|
||||||
|
$faq = $force_no_faq ? array() : array_values(
|
||||||
|
array_filter(
|
||||||
|
(array) ( $data['faq'] ?? array() ),
|
||||||
|
function ( $item ) {
|
||||||
|
return is_array( $item ) && ! empty( $item['q'] ) && ! empty( $item['a'] );
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hard bounds: trim summary if too long
|
||||||
|
$word_count = str_word_count( $summary );
|
||||||
|
if ( $word_count > 140 ) {
|
||||||
|
$words = explode( ' ', $summary );
|
||||||
|
$summary = implode( ' ', array_slice( $words, 0, 140 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim bullets/FAQ to soft max
|
||||||
|
if ( count( $bullets ) > 7 ) {
|
||||||
|
$bullets = array_slice( $bullets, 0, 7 );
|
||||||
|
}
|
||||||
|
if ( count( $faq ) > 5 ) {
|
||||||
|
$faq = array_slice( $faq, 0, 5 );
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'summary' => $summary,
|
||||||
|
'bullets' => $bullets,
|
||||||
|
'faq' => $faq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveMeta( int $post_id, array $data ): void {
|
||||||
|
update_post_meta( $post_id, self::META_SUMMARY, sanitize_text_field( $data['summary'] ?? '' ) );
|
||||||
|
update_post_meta(
|
||||||
|
$post_id,
|
||||||
|
self::META_BULLETS,
|
||||||
|
wp_json_encode( array_map( 'sanitize_text_field', $data['bullets'] ?? array() ), JSON_UNESCAPED_UNICODE )
|
||||||
|
);
|
||||||
|
|
||||||
|
$faq_clean = array_map(
|
||||||
|
function ( $item ) {
|
||||||
|
return array(
|
||||||
|
'q' => sanitize_text_field( $item['q'] ?? '' ),
|
||||||
|
'a' => sanitize_text_field( $item['a'] ?? '' ),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
$data['faq'] ?? array()
|
||||||
|
);
|
||||||
|
update_post_meta( $post_id, self::META_FAQ, wp_json_encode( $faq_clean, JSON_UNESCAPED_UNICODE ) );
|
||||||
|
update_post_meta( $post_id, self::META_GENERATED, time() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getMeta( int $post_id ): array {
|
||||||
|
$summary = get_post_meta( $post_id, self::META_SUMMARY, true ) ?: '';
|
||||||
|
$bullets = json_decode( get_post_meta( $post_id, self::META_BULLETS, true ) ?: '[]', true );
|
||||||
|
$faq = json_decode( get_post_meta( $post_id, self::META_FAQ, true ) ?: '[]', true );
|
||||||
|
return array(
|
||||||
|
'summary' => is_string( $summary ) ? $summary : '',
|
||||||
|
'bullets' => is_array( $bullets ) ? $bullets : array(),
|
||||||
|
'faq' => is_array( $faq ) ? $faq : array(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
$settings = self::getSettings();
|
||||||
|
if ( empty( $settings['enabled'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $settings['output_style'] !== 'store_only_no_frontend' ) {
|
||||||
|
add_filter( 'the_content', array( $this, 'injectBlock' ) );
|
||||||
|
}
|
||||||
|
add_action( 'wp_enqueue_scripts', array( $this, 'enqueueCss' ) );
|
||||||
|
// Publish hook
|
||||||
|
add_action( 'transition_post_status', array( $this, 'onStatusTransition' ), 20, 3 );
|
||||||
|
// Update hook
|
||||||
|
if ( ! empty( $settings['regen_on_update'] ) ) {
|
||||||
|
add_action( 'save_post', array( $this, 'onSavePost' ), 20, 2 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueueCss(): void {
|
||||||
|
if ( ! is_singular() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'brezngeo-geo-frontend', BREZNGEO_URL . 'assets/geo-frontend.css', array(), BREZNGEO_VERSION );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function injectBlock( string $content ): string {
|
||||||
|
if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
if ( ! $post_id ) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-post enabled override: '' = follow global, '1' = on, '0' = off
|
||||||
|
$per_post = get_post_meta( $post_id, self::META_ENABLED, true );
|
||||||
|
if ( $per_post === '0' ) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = self::getMeta( $post_id );
|
||||||
|
if ( empty( $meta['summary'] ) && empty( $meta['bullets'] ) ) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
$block = $this->renderBlock( $meta );
|
||||||
|
$settings = self::getSettings();
|
||||||
|
|
||||||
|
switch ( $settings['position'] ) {
|
||||||
|
case 'top':
|
||||||
|
return $block . $content;
|
||||||
|
case 'bottom':
|
||||||
|
return $content . $block;
|
||||||
|
case 'after_first_p':
|
||||||
|
default:
|
||||||
|
$parts = preg_split( '/(<\/p>)/i', $content, 2, PREG_SPLIT_DELIM_CAPTURE );
|
||||||
|
if ( count( $parts ) >= 3 ) {
|
||||||
|
return $parts[0] . $parts[1] . $block . $parts[2];
|
||||||
|
}
|
||||||
|
return $block . $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderBlock( array $meta ): string {
|
||||||
|
$settings = self::getSettings();
|
||||||
|
$style = $settings['output_style'];
|
||||||
|
|
||||||
|
$title = esc_html( $settings['title'] );
|
||||||
|
$label_summary = esc_html( $settings['label_summary'] );
|
||||||
|
$label_bullets = esc_html( $settings['label_bullets'] );
|
||||||
|
$label_faq = esc_html( $settings['label_faq'] );
|
||||||
|
|
||||||
|
$inner = '';
|
||||||
|
|
||||||
|
if ( ! empty( $meta['summary'] ) ) {
|
||||||
|
$inner .= '<div class="brezngeo-geo__section brezngeo-geo__summary">'
|
||||||
|
. '<h3>' . $label_summary . '</h3>'
|
||||||
|
. '<p>' . esc_html( $meta['summary'] ) . '</p>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! empty( $meta['bullets'] ) ) {
|
||||||
|
$items = '';
|
||||||
|
foreach ( $meta['bullets'] as $bullet ) {
|
||||||
|
$items .= '<li>' . esc_html( $bullet ) . '</li>';
|
||||||
|
}
|
||||||
|
$inner .= '<div class="brezngeo-geo__section brezngeo-geo__bullets">'
|
||||||
|
. '<h3>' . $label_bullets . '</h3>'
|
||||||
|
. '<ul>' . $items . '</ul>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! empty( $meta['faq'] ) ) {
|
||||||
|
$pairs = '';
|
||||||
|
foreach ( $meta['faq'] as $item ) {
|
||||||
|
$pairs .= '<dt>' . esc_html( $item['q'] ) . '</dt>'
|
||||||
|
. '<dd>' . esc_html( $item['a'] ) . '</dd>';
|
||||||
|
}
|
||||||
|
$inner .= '<div class="brezngeo-geo__section brezngeo-geo__faq">'
|
||||||
|
. '<h3>' . $label_faq . '</h3>'
|
||||||
|
. '<dl>' . $pairs . '</dl>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$open_attr = ( $style === 'open_always' ) ? ' open' : '';
|
||||||
|
$theme = $settings['theme'] ?? 'light';
|
||||||
|
$theme_attr = ' data-brezngeo-theme="' . esc_attr( $theme ) . '"';
|
||||||
|
|
||||||
|
$accent = $settings['accent_color'] ?? '';
|
||||||
|
$style_attr = '';
|
||||||
|
if ( $accent && preg_match( '/^#[0-9a-fA-F]{3,6}$/', $accent ) ) {
|
||||||
|
$style_attr = ' style="--brezngeo-accent:' . esc_attr( $accent ) . ';"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<details class="brezngeo-geo" data-bre="geo"' . $open_attr . $theme_attr . $style_attr . '>'
|
||||||
|
. '<summary><span class="brezngeo-geo__title">' . $title . '</span></summary>'
|
||||||
|
. $inner
|
||||||
|
. '</details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onStatusTransition( string $new_status, string $old_status, \WP_Post $post ): void {
|
||||||
|
if ( $new_status !== 'publish' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = self::getSettings();
|
||||||
|
if ( ! in_array( $post->post_type, $settings['post_types'], true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$mode = $settings['mode'];
|
||||||
|
if ( $mode === 'manual_only' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $mode === 'hybrid' ) {
|
||||||
|
$meta = self::getMeta( $post->ID );
|
||||||
|
if ( ! empty( $meta['summary'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->generate( $post->ID );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onSavePost( int $post_id, \WP_Post $post ): void {
|
||||||
|
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $post->post_status !== 'publish' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = self::getSettings();
|
||||||
|
if ( ! in_array( $post->post_type, $settings['post_types'], true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->generate( $post_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
589
brezngeo/includes/Features/LinkSuggest.php
Normal file
589
brezngeo/includes/Features/LinkSuggest.php
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkSuggest — pure static matching helpers for internal link suggestions.
|
||||||
|
*
|
||||||
|
* All methods are side-effect-free and have no WordPress dependencies beyond
|
||||||
|
* wp_strip_all_tags(), which is stubbed in the test bootstrap.
|
||||||
|
*/
|
||||||
|
class LinkSuggest {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Stop-word lists
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @var array<string,string[]> */
|
||||||
|
private static array $stop_words = array(
|
||||||
|
'en' => array(
|
||||||
|
'a',
|
||||||
|
'an',
|
||||||
|
'the',
|
||||||
|
'and',
|
||||||
|
'or',
|
||||||
|
'but',
|
||||||
|
'in',
|
||||||
|
'on',
|
||||||
|
'at',
|
||||||
|
'to',
|
||||||
|
'for',
|
||||||
|
'of',
|
||||||
|
'with',
|
||||||
|
'by',
|
||||||
|
'from',
|
||||||
|
'is',
|
||||||
|
'are',
|
||||||
|
'was',
|
||||||
|
'were',
|
||||||
|
'be',
|
||||||
|
'been',
|
||||||
|
'has',
|
||||||
|
'have',
|
||||||
|
'had',
|
||||||
|
'do',
|
||||||
|
'does',
|
||||||
|
'did',
|
||||||
|
'will',
|
||||||
|
'would',
|
||||||
|
'could',
|
||||||
|
'should',
|
||||||
|
'may',
|
||||||
|
'might',
|
||||||
|
'that',
|
||||||
|
'this',
|
||||||
|
'these',
|
||||||
|
'those',
|
||||||
|
'it',
|
||||||
|
'its',
|
||||||
|
'as',
|
||||||
|
'up',
|
||||||
|
'out',
|
||||||
|
'over',
|
||||||
|
'so',
|
||||||
|
'if',
|
||||||
|
'about',
|
||||||
|
'into',
|
||||||
|
'than',
|
||||||
|
'then',
|
||||||
|
'when',
|
||||||
|
'where',
|
||||||
|
'which',
|
||||||
|
'who',
|
||||||
|
'not',
|
||||||
|
'no',
|
||||||
|
'can',
|
||||||
|
'he',
|
||||||
|
'she',
|
||||||
|
'we',
|
||||||
|
'you',
|
||||||
|
'they',
|
||||||
|
'their',
|
||||||
|
'our',
|
||||||
|
'your',
|
||||||
|
'his',
|
||||||
|
'her',
|
||||||
|
'my',
|
||||||
|
),
|
||||||
|
'de' => array(
|
||||||
|
'der',
|
||||||
|
'die',
|
||||||
|
'das',
|
||||||
|
'ein',
|
||||||
|
'eine',
|
||||||
|
'und',
|
||||||
|
'oder',
|
||||||
|
'aber',
|
||||||
|
'in',
|
||||||
|
'an',
|
||||||
|
'auf',
|
||||||
|
'zu',
|
||||||
|
'für',
|
||||||
|
'von',
|
||||||
|
'mit',
|
||||||
|
'bei',
|
||||||
|
'aus',
|
||||||
|
'nach',
|
||||||
|
'über',
|
||||||
|
'unter',
|
||||||
|
'vor',
|
||||||
|
'ist',
|
||||||
|
'sind',
|
||||||
|
'war',
|
||||||
|
'waren',
|
||||||
|
'sein',
|
||||||
|
'haben',
|
||||||
|
'hat',
|
||||||
|
'hatte',
|
||||||
|
'ich',
|
||||||
|
'du',
|
||||||
|
'er',
|
||||||
|
'sie',
|
||||||
|
'es',
|
||||||
|
'wir',
|
||||||
|
'ihr',
|
||||||
|
'den',
|
||||||
|
'dem',
|
||||||
|
'des',
|
||||||
|
'einer',
|
||||||
|
'einem',
|
||||||
|
'nicht',
|
||||||
|
'auch',
|
||||||
|
'noch',
|
||||||
|
'schon',
|
||||||
|
'so',
|
||||||
|
'wie',
|
||||||
|
'da',
|
||||||
|
'dann',
|
||||||
|
'wenn',
|
||||||
|
'als',
|
||||||
|
'um',
|
||||||
|
'durch',
|
||||||
|
'am',
|
||||||
|
'im',
|
||||||
|
'beim',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public static API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize $text into a filtered array of lowercase content words.
|
||||||
|
*
|
||||||
|
* @param string $text HTML or plain text to tokenize.
|
||||||
|
* @param string $lang Language code ('en' or 'de'). Defaults to 'en'.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function tokenize( string $text, string $lang = 'en' ): array {
|
||||||
|
// 1. Strip HTML (using the WP function, stubbed in tests).
|
||||||
|
$plain = wp_strip_all_tags( $text );
|
||||||
|
|
||||||
|
// 2. Lowercase.
|
||||||
|
$plain = mb_strtolower( $plain, 'UTF-8' );
|
||||||
|
|
||||||
|
// 3. Split on non-word characters (unicode-aware).
|
||||||
|
$words = preg_split( '/\W+/u', $plain, -1, PREG_SPLIT_NO_EMPTY );
|
||||||
|
if ( ! is_array( $words ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Filter: remove stop words and tokens with strlen ≤ 2.
|
||||||
|
$stop_words = self::$stop_words[ $lang ] ?? self::$stop_words['en'];
|
||||||
|
$stop_set = array_flip( $stop_words );
|
||||||
|
|
||||||
|
$tokens = array();
|
||||||
|
foreach ( $words as $word ) {
|
||||||
|
if ( mb_strlen( $word, 'UTF-8' ) <= 2 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( isset( $stop_set[ $word ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tokens[] = $word;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a candidate post against content tokens.
|
||||||
|
*
|
||||||
|
* @param string[] $content_tokens Tokens from the current page content.
|
||||||
|
* @param array{title_tokens: string[], tag_tokens: string[], cat_tokens: string[], excerpt_tokens: string[]} $candidate
|
||||||
|
* @return float
|
||||||
|
*/
|
||||||
|
public static function score_candidate( array $content_tokens, array $candidate ): float {
|
||||||
|
$title_overlap = self::overlap( $content_tokens, $candidate['title_tokens'] );
|
||||||
|
$tag_overlap = self::overlap( $content_tokens, $candidate['tag_tokens'] );
|
||||||
|
$excerpt_overlap = self::overlap( $content_tokens, $candidate['excerpt_tokens'] ?? array() );
|
||||||
|
$cat_overlap = self::overlap( $content_tokens, $candidate['cat_tokens'] );
|
||||||
|
|
||||||
|
return ( $title_overlap * 3.0 ) + ( $tag_overlap * 2.0 ) + ( $excerpt_overlap * 1.5 ) + ( $cat_overlap * 1.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiply a relevance score by a boost factor.
|
||||||
|
* A zero score stays zero (boost cannot manufacture relevance).
|
||||||
|
*
|
||||||
|
* @param float $score Base score.
|
||||||
|
* @param float $boost Multiplier.
|
||||||
|
* @return float
|
||||||
|
*/
|
||||||
|
public static function apply_boost( float $score, float $boost ): float {
|
||||||
|
return $score * $boost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best N-gram phrase in $raw_content that overlaps with $topic_tokens.
|
||||||
|
*
|
||||||
|
* Pass the combined tokens of the link target (title + tags + categories) so that
|
||||||
|
* the anchor phrase can be found even when the target title does not literally
|
||||||
|
* appear in the content. Example: a Donau article can produce "entlang der Donau"
|
||||||
|
* as an anchor for the Deggendorf article if "donau" is one of Deggendorf's tags.
|
||||||
|
*
|
||||||
|
* @param string $raw_content HTML content to search within.
|
||||||
|
* @param string[] $topic_tokens Lowercased tokens of the link target (title + tags + cats).
|
||||||
|
* @param int $min_len Minimum gram length (words). Default 2.
|
||||||
|
* @param int $max_len Maximum gram length (words). Default 6.
|
||||||
|
* @return string Original-case phrase, or '' if no suitable match is found.
|
||||||
|
*/
|
||||||
|
public static function find_best_phrase(
|
||||||
|
string $raw_content,
|
||||||
|
array $topic_tokens,
|
||||||
|
int $min_len = 2,
|
||||||
|
int $max_len = 6
|
||||||
|
): string {
|
||||||
|
if ( empty( $topic_tokens ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip existing <a>…</a> links from the search space so we do not
|
||||||
|
// return text that is already hyperlinked.
|
||||||
|
$stripped = preg_replace( '/<a\b[^>]*>.*?<\/a>/is', '', $raw_content );
|
||||||
|
|
||||||
|
// Strip remaining HTML tags.
|
||||||
|
$plain = wp_strip_all_tags( $stripped ?? '' );
|
||||||
|
|
||||||
|
// Extract words preserving original case.
|
||||||
|
if ( ! preg_match_all( '/\b[\wäöüÄÖÜß]+\b/u', $plain, $m ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$words = $m[0];
|
||||||
|
$total = count( $words );
|
||||||
|
|
||||||
|
if ( $total === 0 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$title_set = array_flip( $topic_tokens ); // O(1) lookup.
|
||||||
|
$best_score = -1.0;
|
||||||
|
$best_phrase = '';
|
||||||
|
|
||||||
|
// Generate all N-grams between $min_len and $max_len.
|
||||||
|
for ( $len = $min_len; $len <= $max_len; $len++ ) {
|
||||||
|
for ( $i = 0; $i <= $total - $len; $i++ ) {
|
||||||
|
$gram = array_slice( $words, $i, $len );
|
||||||
|
|
||||||
|
// Count how many lowercased gram words appear in title_tokens.
|
||||||
|
$shared = 0;
|
||||||
|
foreach ( $gram as $gram_word ) {
|
||||||
|
if ( isset( $title_set[ mb_strtolower( $gram_word, 'UTF-8' ) ] ) ) {
|
||||||
|
++$shared;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $shared === 0 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score: shared / len + len * 0.1 (rewards length + overlap).
|
||||||
|
$score = ( $shared / $len ) + ( $len * 0.1 );
|
||||||
|
|
||||||
|
if ( $score > $best_score ) {
|
||||||
|
$best_score = $score;
|
||||||
|
$best_phrase = implode( ' ', $gram );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the winning phrase exists outside existing <a> links (called once).
|
||||||
|
if ( $best_phrase !== '' && stripos( $plain, $best_phrase ) === false ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best_phrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove candidates whose post_id appears in $excluded_ids.
|
||||||
|
*
|
||||||
|
* @param array<int,array{post_id: int, ...}> $candidates
|
||||||
|
* @param int[] $excluded_ids
|
||||||
|
* @return array<int,array{post_id: int, ...}>
|
||||||
|
*/
|
||||||
|
public static function filter_excluded( array $candidates, array $excluded_ids ): array {
|
||||||
|
$excluded_set = array_flip( $excluded_ids );
|
||||||
|
|
||||||
|
$filtered = array_filter(
|
||||||
|
$candidates,
|
||||||
|
static fn( array $c ): bool => ! isset( $excluded_set[ $c['post_id'] ] )
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values( $filtered );
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Settings key
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public const OPTION_KEY = 'brezngeo_link_suggest_settings';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// WP-dependent public methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public static function get_settings(): array {
|
||||||
|
$defaults = array(
|
||||||
|
'trigger' => 'manual',
|
||||||
|
'interval_min' => 2,
|
||||||
|
'excluded_posts' => array(),
|
||||||
|
'boosted_posts' => array(),
|
||||||
|
'ai_candidates' => 20,
|
||||||
|
'ai_max_tokens' => 400,
|
||||||
|
);
|
||||||
|
$saved = get_option( self::OPTION_KEY, array() );
|
||||||
|
$saved = is_array( $saved ) ? $saved : array();
|
||||||
|
return array_merge( $defaults, $saved );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function build_boost_map( array $boosted_posts ): array {
|
||||||
|
$map = array();
|
||||||
|
foreach ( $boosted_posts as $entry ) {
|
||||||
|
$id = (int) ( $entry['id'] ?? 0 );
|
||||||
|
$boost = (float) ( $entry['boost'] ?? 1.0 );
|
||||||
|
if ( $id > 0 ) {
|
||||||
|
$map[ $id ] = max( 1.0, $boost );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'wp_ajax_brezngeo_link_suggestions', array( $this, 'ajax_suggest' ) );
|
||||||
|
add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||||
|
add_action( 'save_post', array( $this, 'invalidate_cache' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidate_cache(): void {
|
||||||
|
delete_transient( 'brezngeo_link_candidate_pool' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_meta_box(): void {
|
||||||
|
$post_types = \BreznGEO\Admin\SettingsPage::getSettings()['meta_post_types'] ?? array( 'post', 'page' );
|
||||||
|
foreach ( $post_types as $pt ) {
|
||||||
|
add_meta_box(
|
||||||
|
'brezngeo_link_suggest',
|
||||||
|
__( 'Internal Link Suggestions (BreznGEO)', 'brezngeo' ),
|
||||||
|
array( $this, 'render_meta_box' ),
|
||||||
|
$pt,
|
||||||
|
'normal',
|
||||||
|
'default'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_meta_box( \WP_Post $post ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||||
|
include BREZNGEO_DIR . 'includes/Admin/views/link-suggest-box.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_assets( string $hook ): void {
|
||||||
|
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = self::get_settings();
|
||||||
|
$lang = str_starts_with( get_locale(), 'de_' ) ? 'de' : 'en';
|
||||||
|
wp_enqueue_script( 'brezngeo-link-suggest', BREZNGEO_URL . 'assets/link-suggest.js', array( 'jquery' ), BREZNGEO_VERSION, true );
|
||||||
|
global $post;
|
||||||
|
wp_localize_script(
|
||||||
|
'brezngeo-link-suggest',
|
||||||
|
'bavrankLinkSuggest',
|
||||||
|
array(
|
||||||
|
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'restUrl' => get_rest_url( null, 'wp/v2/search' ),
|
||||||
|
'restNonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
|
'postId' => $post ? (int) $post->ID : 0,
|
||||||
|
'triggerMode' => $settings['trigger'],
|
||||||
|
'intervalMs' => max( 1, (int) $settings['interval_min'] ) * 60000,
|
||||||
|
'lang' => $lang,
|
||||||
|
'i18n' => array(
|
||||||
|
'title' => __( 'Internal Link Suggestions (BreznGEO)', 'brezngeo' ),
|
||||||
|
'analyse' => __( 'Analyse', 'brezngeo' ),
|
||||||
|
'loading' => __( 'Analysing…', 'brezngeo' ),
|
||||||
|
'noResults' => __( 'No suggestions found.', 'brezngeo' ),
|
||||||
|
/* translators: %d: number of links */
|
||||||
|
'applyBtn' => __( 'Apply (%d links)', 'brezngeo' ),
|
||||||
|
'selectAll' => __( 'All', 'brezngeo' ),
|
||||||
|
'selectNone' => __( 'None', 'brezngeo' ),
|
||||||
|
'preview' => __( 'Preview', 'brezngeo' ),
|
||||||
|
'confirm' => __( 'Confirm', 'brezngeo' ),
|
||||||
|
'cancel' => __( 'Cancel', 'brezngeo' ),
|
||||||
|
/* translators: %d: number of links */
|
||||||
|
'applied' => __( 'Applied — %d links set ✓', 'brezngeo' ),
|
||||||
|
'boosted' => __( 'Prioritised', 'brezngeo' ),
|
||||||
|
'openPost' => __( 'Open post', 'brezngeo' ),
|
||||||
|
'networkError' => __( 'Network error', 'brezngeo' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_suggest(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||||
|
wp_send_json_error( 'Insufficient permissions' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||||
|
$post_id = (int) ( wp_unslash( $_POST['post_id'] ?? 0 ) );
|
||||||
|
$content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
|
||||||
|
// phpcs:enable
|
||||||
|
|
||||||
|
if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) {
|
||||||
|
wp_send_json_error( 'Insufficient permissions' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $post_id || ! $content ) {
|
||||||
|
wp_send_json_success( array() );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = self::get_settings();
|
||||||
|
$lang = str_starts_with( get_locale(), 'de_' ) ? 'de' : 'en';
|
||||||
|
$content_toks = self::tokenize( $content, $lang );
|
||||||
|
|
||||||
|
if ( empty( $content_toks ) ) {
|
||||||
|
wp_send_json_success( array() );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pool = $this->get_candidate_pool( $post_id );
|
||||||
|
$excluded = array_map( 'intval', $settings['excluded_posts'] );
|
||||||
|
$pool = self::filter_excluded( $pool, $excluded );
|
||||||
|
$boost_map = self::build_boost_map( $settings['boosted_posts'] );
|
||||||
|
|
||||||
|
foreach ( $pool as &$candidate ) {
|
||||||
|
$score = self::score_candidate( $content_toks, $candidate );
|
||||||
|
$boost = $boost_map[ $candidate['post_id'] ] ?? 1.0;
|
||||||
|
$candidate['score'] = self::apply_boost( $score, $boost );
|
||||||
|
$candidate['boosted'] = isset( $boost_map[ $candidate['post_id'] ] );
|
||||||
|
}
|
||||||
|
unset( $candidate );
|
||||||
|
|
||||||
|
$pool = array_filter( $pool, fn( $c ) => $c['score'] > 0.0 );
|
||||||
|
usort( $pool, fn( $a, $b ) => $b['score'] <=> $a['score'] );
|
||||||
|
$pool = array_slice( $pool, 0, 20 );
|
||||||
|
|
||||||
|
$suggestions = array();
|
||||||
|
foreach ( $pool as $candidate ) {
|
||||||
|
// Combine title, tag and category tokens so the anchor phrase can be found
|
||||||
|
// even when the target title does not appear verbatim in the current content.
|
||||||
|
// A Donau article may anchor to Deggendorf via the shared "donau" tag token.
|
||||||
|
$topic_tokens = array_values(
|
||||||
|
array_unique(
|
||||||
|
array_merge(
|
||||||
|
$candidate['title_tokens'],
|
||||||
|
$candidate['tag_tokens'],
|
||||||
|
$candidate['excerpt_tokens'],
|
||||||
|
$candidate['cat_tokens']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$phrase = self::find_best_phrase( $content, $topic_tokens );
|
||||||
|
if ( $phrase === '' ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$suggestions[] = array(
|
||||||
|
'phrase' => $phrase,
|
||||||
|
'post_id' => $candidate['post_id'],
|
||||||
|
'post_title' => $candidate['post_title'],
|
||||||
|
'url' => $candidate['url'],
|
||||||
|
'score' => round( $candidate['score'], 3 ),
|
||||||
|
'boosted' => $candidate['boosted'],
|
||||||
|
);
|
||||||
|
if ( count( $suggestions ) >= 10 ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $suggestions );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_candidate_pool( int $exclude_post_id ): array {
|
||||||
|
$cached = get_transient( 'brezngeo_link_candidate_pool' );
|
||||||
|
if ( $cached !== false ) {
|
||||||
|
return array_values( array_filter( $cached, fn( $c ) => $c['post_id'] !== $exclude_post_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$lang = str_starts_with( get_locale(), 'de_' ) ? 'de' : 'en';
|
||||||
|
$posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
"SELECT ID, post_title, post_excerpt FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish'
|
||||||
|
AND post_type IN ('post','page')
|
||||||
|
ORDER BY post_date DESC
|
||||||
|
LIMIT 500"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! is_array( $posts ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload term cache for all post IDs in two queries (avoids N+1 problem).
|
||||||
|
$post_ids = array_map( fn( $p ) => (int) $p->ID, $posts );
|
||||||
|
update_object_term_cache( $post_ids, array( 'post_tag', 'category' ) );
|
||||||
|
|
||||||
|
$pool = array();
|
||||||
|
foreach ( $posts as $post ) {
|
||||||
|
$tags = wp_get_post_terms( (int) $post->ID, 'post_tag', array( 'fields' => 'names' ) );
|
||||||
|
$cats = wp_get_post_terms( (int) $post->ID, 'category', array( 'fields' => 'names' ) );
|
||||||
|
|
||||||
|
$tag_str = is_array( $tags ) ? implode( ' ', $tags ) : '';
|
||||||
|
$cat_str = is_array( $cats ) ? implode( ' ', $cats ) : '';
|
||||||
|
|
||||||
|
$pool[] = array(
|
||||||
|
'post_id' => (int) $post->ID,
|
||||||
|
'post_title' => $post->post_title,
|
||||||
|
'url' => get_permalink( (int) $post->ID ),
|
||||||
|
'title_tokens' => self::tokenize( $post->post_title, $lang ),
|
||||||
|
'tag_tokens' => self::tokenize( $tag_str, $lang ),
|
||||||
|
'excerpt_tokens' => self::tokenize( $post->post_excerpt ?? '', $lang ),
|
||||||
|
'cat_tokens' => self::tokenize( $cat_str, $lang ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient( 'brezngeo_link_candidate_pool', $pool, HOUR_IN_SECONDS );
|
||||||
|
return array_values( array_filter( $pool, fn( $c ) => $c['post_id'] !== $exclude_post_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the fraction of $candidate tokens that also appear in $content.
|
||||||
|
*
|
||||||
|
* @param string[] $content
|
||||||
|
* @param string[] $candidate
|
||||||
|
* @return float 0.0 if $candidate is empty.
|
||||||
|
*/
|
||||||
|
private static function overlap( array $content, array $candidate ): float {
|
||||||
|
if ( empty( $candidate ) ) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$shared = count( array_intersect( $candidate, $content ) );
|
||||||
|
return $shared / count( $candidate );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that $phrase appears (case-insensitively) in $html outside <a> tags.
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @param string $phrase
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function phrase_exists_outside_links( string $html, string $phrase ): bool {
|
||||||
|
// Remove all <a>…</a> blocks then strip remaining tags.
|
||||||
|
$stripped = preg_replace( '/<a\b[^>]*>.*?<\/a>/is', '', $html );
|
||||||
|
$plain = wp_strip_all_tags( $stripped ?? '' );
|
||||||
|
return stripos( $plain, $phrase ) !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
250
brezngeo/includes/Features/LlmsTxt.php
Normal file
250
brezngeo/includes/Features/LlmsTxt.php
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LlmsTxt {
|
||||||
|
private const OPTION_KEY = 'brezngeo_llms_settings';
|
||||||
|
private const CACHE_KEY = 'brezngeo_llms_cache';
|
||||||
|
|
||||||
|
private const NOTICE_META = 'brezngeo_dismissed_llms_rank_math';
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'parse_request', array( $this, 'maybe_serve' ), 1 );
|
||||||
|
add_action( 'init', array( $this, 'add_rewrite_rule' ) );
|
||||||
|
add_filter( 'query_vars', array( $this, 'add_query_var' ) );
|
||||||
|
add_action( 'admin_notices', array( $this, 'rank_math_notice' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_notice_script' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_dismiss_llms_notice', array( $this, 'ajax_dismiss_notice' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybe_serve(): void {
|
||||||
|
$uri = isset( $_SERVER['REQUEST_URI'] ) ? strtok( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '?' ) : '';
|
||||||
|
if ( $uri === '/llms.txt' ) {
|
||||||
|
$this->serve_page( 1 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( preg_match( '#^/llms-(\d+)\.txt$#', $uri, $m ) ) {
|
||||||
|
$this->serve_page( (int) $m[1] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybe_enqueue_notice_script(): void {
|
||||||
|
if ( ! defined( 'RANK_MATH_VERSION' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( get_user_meta( get_current_user_id(), self::NOTICE_META, true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = self::getSettings();
|
||||||
|
if ( empty( $settings['enabled'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$nonce = wp_create_nonce( 'brezngeo_dismiss_llms_notice' );
|
||||||
|
$js = "jQuery(document).on('click','#brezngeo-llms-rank-math-notice .notice-dismiss',function(){"
|
||||||
|
. "jQuery.post(window.ajaxurl,{action:'brezngeo_dismiss_llms_notice',nonce:'" . esc_js( $nonce ) . "'});"
|
||||||
|
. '});';
|
||||||
|
wp_add_inline_script( 'jquery', $js );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rank_math_notice(): void {
|
||||||
|
if ( ! defined( 'RANK_MATH_VERSION' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( get_user_meta( get_current_user_id(), self::NOTICE_META, true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$settings = self::getSettings();
|
||||||
|
if ( empty( $settings['enabled'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="notice notice-info is-dismissible" id="brezngeo-llms-rank-math-notice">
|
||||||
|
<p><?php esc_html_e( 'BreznGEO serves llms.txt with priority — no action needed in Rank Math.', 'brezngeo' ); ?></p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajax_dismiss_notice(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_dismiss_llms_notice', 'nonce' );
|
||||||
|
update_user_meta( get_current_user_id(), self::NOTICE_META, '1' );
|
||||||
|
wp_send_json_success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_rewrite_rule(): void {
|
||||||
|
add_rewrite_rule( '^llms\.txt$', 'index.php?brezngeo_llms=1', 'top' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_query_var( array $vars ): array {
|
||||||
|
$vars[] = 'brezngeo_llms';
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serve_page( int $page ): void {
|
||||||
|
$settings = self::getSettings();
|
||||||
|
if ( empty( $settings['enabled'] ) ) {
|
||||||
|
status_header( 404 );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache_key = self::CACHE_KEY . '_p' . $page;
|
||||||
|
$cached = get_transient( $cache_key );
|
||||||
|
|
||||||
|
if ( $cached === false ) {
|
||||||
|
$cached = $this->build( $settings, $page );
|
||||||
|
set_transient( $cache_key, $cached, 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$etag = '"' . md5( $cached ) . '"';
|
||||||
|
$last_modified = $this->get_last_modified();
|
||||||
|
|
||||||
|
header( 'Content-Type: text/plain; charset=utf-8' );
|
||||||
|
header( 'ETag: ' . $etag );
|
||||||
|
header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT' );
|
||||||
|
header( 'Cache-Control: public, max-age=3600' );
|
||||||
|
|
||||||
|
$if_none_match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : '';
|
||||||
|
if ( $if_none_match === $etag ) {
|
||||||
|
status_header( 304 );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
echo $cached;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_last_modified(): int {
|
||||||
|
global $wpdb;
|
||||||
|
if ( ! isset( $wpdb ) ) {
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
$latest = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
"SELECT UNIX_TIMESTAMP(MAX(post_modified_gmt)) FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish'"
|
||||||
|
);
|
||||||
|
return $latest ? (int) $latest : time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clear_cache(): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( ! isset( $wpdb ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_brezngeo_llms_cache%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_brezngeo_llms_cache%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
}
|
||||||
|
|
||||||
|
private function build( array $s, int $page = 1 ): string {
|
||||||
|
$max_links = max( 50, (int) ( $s['max_links'] ?? 500 ) );
|
||||||
|
$post_types = $s['post_types'] ?? array( 'post', 'page' );
|
||||||
|
$all_posts = $this->get_all_posts( $post_types );
|
||||||
|
$total = count( $all_posts );
|
||||||
|
$pages = $total > 0 ? (int) ceil( $total / $max_links ) : 1;
|
||||||
|
$offset = ( $page - 1 ) * $max_links;
|
||||||
|
$page_posts = array_slice( $all_posts, $offset, $max_links );
|
||||||
|
|
||||||
|
$out = '';
|
||||||
|
|
||||||
|
if ( $page === 1 ) {
|
||||||
|
if ( ! empty( $s['title'] ) ) {
|
||||||
|
$out .= '# ' . $s['title'] . "\n\n";
|
||||||
|
}
|
||||||
|
if ( ! empty( $s['description_before'] ) ) {
|
||||||
|
$out .= trim( $s['description_before'] ) . "\n\n";
|
||||||
|
}
|
||||||
|
if ( ! empty( $s['custom_links'] ) ) {
|
||||||
|
$out .= "## Featured Resources\n\n";
|
||||||
|
foreach ( explode( "\n", trim( $s['custom_links'] ) ) as $line ) {
|
||||||
|
$line = trim( $line );
|
||||||
|
if ( $line !== '' ) {
|
||||||
|
$out .= $line . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out .= "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! empty( $page_posts ) ) {
|
||||||
|
$out .= "## Content\n\n";
|
||||||
|
foreach ( $page_posts as $post ) {
|
||||||
|
$out .= sprintf(
|
||||||
|
'- [%s](%s) — %s',
|
||||||
|
$post->post_title,
|
||||||
|
get_permalink( $post ),
|
||||||
|
get_the_date( 'Y-m-d', $post )
|
||||||
|
) . "\n";
|
||||||
|
}
|
||||||
|
$out .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $pages > 1 ) {
|
||||||
|
$out .= "## More\n\n";
|
||||||
|
for ( $p = 1; $p <= $pages; $p++ ) {
|
||||||
|
if ( $p === $page ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filename = $p === 1 ? 'llms.txt' : "llms-{$p}.txt";
|
||||||
|
$url = home_url( '/' . $filename );
|
||||||
|
$out .= "- [{$filename}]({$url})\n";
|
||||||
|
}
|
||||||
|
$out .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $page === 1 ) {
|
||||||
|
if ( ! empty( $s['description_after'] ) ) {
|
||||||
|
$out .= "\n---\n" . trim( $s['description_after'] ) . "\n";
|
||||||
|
}
|
||||||
|
if ( ! empty( $s['description_footer'] ) ) {
|
||||||
|
$out .= "\n---\n" . trim( $s['description_footer'] ) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_all_posts( array $post_types ): array {
|
||||||
|
if ( empty( $post_types ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$query = new \WP_Query(
|
||||||
|
array(
|
||||||
|
'post_type' => $post_types,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'no_found_rows' => true,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$posts = $query->posts;
|
||||||
|
wp_reset_postdata();
|
||||||
|
return $posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush rewrite rules on activation.
|
||||||
|
* Call this from your activation hook.
|
||||||
|
*/
|
||||||
|
public function flush_rules(): void {
|
||||||
|
$this->add_rewrite_rule();
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSettings(): array {
|
||||||
|
$defaults = array(
|
||||||
|
'enabled' => false,
|
||||||
|
'title' => '',
|
||||||
|
'description_before' => '',
|
||||||
|
'description_after' => '',
|
||||||
|
'description_footer' => '',
|
||||||
|
'custom_links' => '',
|
||||||
|
'post_types' => array( 'post', 'page' ),
|
||||||
|
'max_links' => 500,
|
||||||
|
);
|
||||||
|
$saved = get_option( self::OPTION_KEY, array() );
|
||||||
|
return array_merge( $defaults, is_array( $saved ) ? $saved : array() );
|
||||||
|
}
|
||||||
|
}
|
||||||
395
brezngeo/includes/Features/MetaGenerator.php
Normal file
395
brezngeo/includes/Features/MetaGenerator.php
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Admin\SettingsPage;
|
||||||
|
use BreznGEO\ProviderRegistry;
|
||||||
|
use BreznGEO\Helpers\TokenEstimator;
|
||||||
|
use BreznGEO\Helpers\BulkQueue;
|
||||||
|
use BreznGEO\Helpers\FallbackMeta;
|
||||||
|
|
||||||
|
class MetaGenerator {
|
||||||
|
public function register(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
|
||||||
|
if ( ! empty( $settings['meta_auto_enabled'] ) ) {
|
||||||
|
add_action( 'publish_post', array( $this, 'onPublish' ), 20, 2 );
|
||||||
|
add_action( 'publish_page', array( $this, 'onPublish' ), 20, 2 );
|
||||||
|
|
||||||
|
foreach ( $settings['meta_post_types'] as $post_type ) {
|
||||||
|
if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) {
|
||||||
|
add_action( "publish_{$post_type}", array( $this, 'onPublish' ), 20, 2 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action( 'wp_ajax_brezngeo_bulk_generate', array( $this, 'ajaxBulkGenerate' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_bulk_stats', array( $this, 'ajaxBulkStats' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_bulk_release', array( $this, 'ajaxBulkRelease' ) );
|
||||||
|
add_action( 'wp_ajax_brezngeo_bulk_status', array( $this, 'ajaxBulkStatus' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onPublish( int $post_id, \WP_Post $post ): void {
|
||||||
|
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $this->hasExistingMeta( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
if ( ! in_array( $post->post_type, $settings['meta_post_types'], true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
|
||||||
|
$source = ! empty( $api_key ) ? 'ai' : 'fallback';
|
||||||
|
$description = $this->generate( $post, $settings );
|
||||||
|
if ( ! empty( $description ) ) {
|
||||||
|
$this->saveMeta( $post_id, $description, $source );
|
||||||
|
}
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||||
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||||
|
error_log( '[BreznGEO] Meta generation failed for post ' . $post_id . ': ' . $e->getMessage() );
|
||||||
|
}
|
||||||
|
// Try fallback
|
||||||
|
$fallback = FallbackMeta::extract( $post );
|
||||||
|
if ( $fallback !== '' ) {
|
||||||
|
$this->saveMeta( $post_id, $fallback, 'fallback' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate( \WP_Post $post, array $settings ): string {
|
||||||
|
$registry = ProviderRegistry::instance();
|
||||||
|
$provider = $registry->get( $settings['provider'] );
|
||||||
|
$api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
|
||||||
|
|
||||||
|
// No provider or no API key → use fallback immediately
|
||||||
|
if ( ! $provider || empty( $api_key ) ) {
|
||||||
|
return FallbackMeta::extract( $post );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $settings['ai_enabled'] ) ) {
|
||||||
|
return FallbackMeta::extract( $post );
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = $settings['models'][ $settings['provider'] ] ?? array_key_first( $provider->getModels() );
|
||||||
|
$content = $this->prepareContent( $post, $settings );
|
||||||
|
$prompt = $this->buildPrompt( $post, $content, $settings );
|
||||||
|
|
||||||
|
$result = $provider->generateText( $prompt, $api_key, $model, 300 );
|
||||||
|
$tokens_in = TokenEstimator::estimate( $prompt );
|
||||||
|
$tokens_out = TokenEstimator::estimate( $result );
|
||||||
|
self::record_usage( $tokens_in, $tokens_out );
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepareContent( \WP_Post $post, array $settings ): string {
|
||||||
|
$content = wp_strip_all_tags( $post->post_content );
|
||||||
|
if ( $settings['token_mode'] === 'limit' ) {
|
||||||
|
$content = TokenEstimator::truncate( $content, (int) $settings['token_limit'] );
|
||||||
|
}
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPrompt( \WP_Post $post, string $content, array $settings ): string {
|
||||||
|
$language = $this->detectLanguage( $post );
|
||||||
|
$prompt = $settings['prompt'];
|
||||||
|
|
||||||
|
$prompt = str_replace( '{title}', $post->post_title, $prompt );
|
||||||
|
$prompt = str_replace( '{content}', $content, $prompt );
|
||||||
|
$prompt = str_replace( '{excerpt}', $post->post_excerpt ?: '', $prompt );
|
||||||
|
$prompt = str_replace( '{language}', $language, $prompt );
|
||||||
|
|
||||||
|
return apply_filters( 'brezngeo_prompt', $prompt, $post );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectLanguage( \WP_Post $post ): string {
|
||||||
|
if ( function_exists( 'pll_get_post_language' ) ) {
|
||||||
|
$lang = pll_get_post_language( $post->ID, 'name' );
|
||||||
|
if ( $lang ) {
|
||||||
|
return $lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( defined( 'ICL_LANGUAGE_CODE' ) ) {
|
||||||
|
return ICL_LANGUAGE_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$locale_map = array(
|
||||||
|
'de_DE' => 'Deutsch',
|
||||||
|
'de_AT' => 'Deutsch',
|
||||||
|
'de_CH' => 'Deutsch',
|
||||||
|
'en_US' => 'English',
|
||||||
|
'en_GB' => 'English',
|
||||||
|
'fr_FR' => 'Français',
|
||||||
|
'es_ES' => 'Español',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $locale_map[ get_locale() ] ?? 'Deutsch';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasExistingMeta( int $post_id ): bool {
|
||||||
|
$fields = array(
|
||||||
|
'_brezngeo_meta_description',
|
||||||
|
'rank_math_description',
|
||||||
|
'_yoast_wpseo_metadesc',
|
||||||
|
'_aioseo_description',
|
||||||
|
'_seopress_titles_desc',
|
||||||
|
'_meta_description',
|
||||||
|
);
|
||||||
|
foreach ( $fields as $field ) {
|
||||||
|
if ( ! empty( get_post_meta( $post_id, $field, true ) ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveMeta( int $post_id, string $description, string $source = 'ai' ): void {
|
||||||
|
$clean = sanitize_text_field( $description );
|
||||||
|
update_post_meta( $post_id, '_brezngeo_meta_source', sanitize_key( $source ) );
|
||||||
|
update_post_meta( $post_id, '_brezngeo_meta_description', $clean );
|
||||||
|
|
||||||
|
if ( defined( 'RANK_MATH_VERSION' ) ) {
|
||||||
|
update_post_meta( $post_id, 'rank_math_description', $clean );
|
||||||
|
} elseif ( defined( 'WPSEO_VERSION' ) ) {
|
||||||
|
update_post_meta( $post_id, '_yoast_wpseo_metadesc', $clean );
|
||||||
|
} elseif ( defined( 'AIOSEO_VERSION' ) ) {
|
||||||
|
update_post_meta( $post_id, '_aioseo_description', $clean );
|
||||||
|
} elseif ( class_exists( 'SeoPress_Titles_Admin' ) ) {
|
||||||
|
update_post_meta( $post_id, '_seopress_titles_desc', $clean );
|
||||||
|
}
|
||||||
|
|
||||||
|
do_action( 'brezngeo_meta_saved', $post_id, $description );
|
||||||
|
delete_transient( 'brezngeo_meta_stats' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajaxBulkStats(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$stats = array();
|
||||||
|
|
||||||
|
foreach ( $settings['meta_post_types'] as $pt ) {
|
||||||
|
$stats[ $pt ] = $this->countPostsWithoutMeta( $pt );
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $stats );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajaxBulkRelease(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error();
|
||||||
|
}
|
||||||
|
BulkQueue::release();
|
||||||
|
wp_send_json_success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajaxBulkStatus(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error();
|
||||||
|
}
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'locked' => BulkQueue::isLocked(),
|
||||||
|
'lock_age' => BulkQueue::lockAge(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajaxBulkGenerate(): void {
|
||||||
|
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire lock on first batch
|
||||||
|
if ( ! empty( $_POST['is_first'] ) ) {
|
||||||
|
if ( ! BulkQueue::acquire() ) {
|
||||||
|
wp_send_json_error(
|
||||||
|
array(
|
||||||
|
'locked' => true,
|
||||||
|
'lock_age' => BulkQueue::lockAge(),
|
||||||
|
'message' => __( 'A bulk process is already running.', 'brezngeo' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_type = sanitize_key( wp_unslash( $_POST['post_type'] ?? 'post' ) );
|
||||||
|
$limit = min( 20, max( 1, absint( wp_unslash( $_POST['batch_size'] ?? 5 ) ) ) );
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
|
||||||
|
if ( ! empty( $_POST['provider'] ) ) {
|
||||||
|
$settings['provider'] = sanitize_key( wp_unslash( $_POST['provider'] ) );
|
||||||
|
}
|
||||||
|
if ( ! empty( $_POST['model'] ) ) {
|
||||||
|
$provider_obj = ProviderRegistry::instance()->get( $settings['provider'] );
|
||||||
|
$allowed_models = $provider_obj ? array_keys( $provider_obj->getModels() ) : array();
|
||||||
|
$requested_model = sanitize_text_field( wp_unslash( $_POST['model'] ) );
|
||||||
|
if ( in_array( $requested_model, $allowed_models, true ) ) {
|
||||||
|
$settings['models'][ $settings['provider'] ] = $requested_model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_ids = $this->getPostsWithoutMeta( $post_type, $limit );
|
||||||
|
$results = array();
|
||||||
|
$max_retries = 3;
|
||||||
|
|
||||||
|
foreach ( $post_ids as $post_id ) {
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
$success = false;
|
||||||
|
$last_error = '';
|
||||||
|
|
||||||
|
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
|
||||||
|
try {
|
||||||
|
$desc = $this->generate( $post, $settings );
|
||||||
|
$this->saveMeta( $post_id, $desc );
|
||||||
|
delete_post_meta( $post_id, '_brezngeo_bulk_failed' );
|
||||||
|
$results[] = array(
|
||||||
|
'id' => $post_id,
|
||||||
|
'title' => get_the_title( $post_id ),
|
||||||
|
'description' => $desc,
|
||||||
|
'success' => true,
|
||||||
|
'attempts' => $attempt,
|
||||||
|
);
|
||||||
|
$success = true;
|
||||||
|
break;
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
$last_error = $e->getMessage();
|
||||||
|
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||||
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||||
|
error_log( '[BreznGEO] Post ' . $post_id . ' attempt ' . $attempt . '/' . $max_retries . ': ' . $last_error );
|
||||||
|
}
|
||||||
|
if ( $attempt < $max_retries ) {
|
||||||
|
sleep( 1 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $success ) {
|
||||||
|
update_post_meta( $post_id, '_brezngeo_bulk_failed', $last_error );
|
||||||
|
$results[] = array(
|
||||||
|
'id' => $post_id,
|
||||||
|
'title' => get_the_title( $post_id ),
|
||||||
|
'error' => $last_error,
|
||||||
|
'success' => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release lock when JS signals last batch
|
||||||
|
if ( ! empty( $_POST['is_last'] ) ) {
|
||||||
|
BulkQueue::release();
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'results' => $results,
|
||||||
|
'processed' => count( $results ),
|
||||||
|
'remaining' => $this->countPostsWithoutMeta( $post_type ),
|
||||||
|
'locked' => BulkQueue::isLocked(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countPostsWithoutMeta( string $post_type ): int {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$meta_fields = array(
|
||||||
|
'_brezngeo_meta_description',
|
||||||
|
'rank_math_description',
|
||||||
|
'_yoast_wpseo_metadesc',
|
||||||
|
'_aioseo_description',
|
||||||
|
'_seopress_titles_desc',
|
||||||
|
'_meta_description',
|
||||||
|
);
|
||||||
|
|
||||||
|
$not_exists = '';
|
||||||
|
foreach ( $meta_fields as $field ) {
|
||||||
|
$not_exists .= $wpdb->prepare(
|
||||||
|
" AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM {$wpdb->postmeta} pm
|
||||||
|
WHERE pm.post_id = p.ID AND pm.meta_key = %s AND pm.meta_value != ''
|
||||||
|
)",
|
||||||
|
$field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$wpdb->posts} p
|
||||||
|
WHERE p.post_type = %s AND p.post_status = 'publish'" . $not_exists, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
$post_type
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPostsWithoutMeta( string $post_type, int $limit ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$meta_fields = array(
|
||||||
|
'_brezngeo_meta_description',
|
||||||
|
'rank_math_description',
|
||||||
|
'_yoast_wpseo_metadesc',
|
||||||
|
'_aioseo_description',
|
||||||
|
'_seopress_titles_desc',
|
||||||
|
'_meta_description',
|
||||||
|
);
|
||||||
|
|
||||||
|
$not_exists = '';
|
||||||
|
foreach ( $meta_fields as $field ) {
|
||||||
|
$not_exists .= $wpdb->prepare(
|
||||||
|
" AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM {$wpdb->postmeta} pm
|
||||||
|
WHERE pm.post_id = p.ID
|
||||||
|
AND pm.meta_key = %s
|
||||||
|
AND pm.meta_value != ''
|
||||||
|
)",
|
||||||
|
$field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
'intval',
|
||||||
|
$wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT p.ID FROM {$wpdb->posts} p
|
||||||
|
WHERE p.post_type = %s AND p.post_status = 'publish'"
|
||||||
|
. $not_exists . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
' ORDER BY p.ID DESC LIMIT %d',
|
||||||
|
$post_type,
|
||||||
|
$limit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function record_usage( int $tokens_in, int $tokens_out ): void {
|
||||||
|
$stats = get_option(
|
||||||
|
'brezngeo_usage_stats',
|
||||||
|
array(
|
||||||
|
'tokens_in' => 0,
|
||||||
|
'tokens_out' => 0,
|
||||||
|
'count' => 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$stats['tokens_in'] = (int) ( $stats['tokens_in'] ?? 0 ) + $tokens_in;
|
||||||
|
$stats['tokens_out'] = (int) ( $stats['tokens_out'] ?? 0 ) + $tokens_out;
|
||||||
|
$stats['count'] = (int) ( $stats['count'] ?? 0 ) + 1;
|
||||||
|
update_option( 'brezngeo_usage_stats', $stats, false );
|
||||||
|
}
|
||||||
|
}
|
||||||
51
brezngeo/includes/Features/RobotsTxt.php
Normal file
51
brezngeo/includes/Features/RobotsTxt.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RobotsTxt {
|
||||||
|
private const OPTION_KEY = 'brezngeo_robots_settings';
|
||||||
|
|
||||||
|
public const KNOWN_BOTS = array(
|
||||||
|
'GPTBot' => 'OpenAI GPTBot',
|
||||||
|
'ClaudeBot' => 'Anthropic ClaudeBot',
|
||||||
|
'Google-Extended' => 'Google Extended (Bard/Gemini Training)',
|
||||||
|
'PerplexityBot' => 'Perplexity AI',
|
||||||
|
'CCBot' => 'Common Crawl (CCBot)',
|
||||||
|
'Applebot-Extended' => 'Apple AI (Applebot-Extended)',
|
||||||
|
'Bytespider' => 'ByteDance Bytespider',
|
||||||
|
'DataForSeoBot' => 'DataForSEO Bot',
|
||||||
|
'ImagesiftBot' => 'Imagesift Bot',
|
||||||
|
'omgili' => 'Omgili Bot',
|
||||||
|
'Diffbot' => 'Diffbot',
|
||||||
|
'FacebookBot' => 'Meta FacebookBot',
|
||||||
|
'Amazonbot' => 'Amazon Amazonbot',
|
||||||
|
);
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_filter( 'robots_txt', array( $this, 'append_rules' ), 20, 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function append_rules( string $output, bool $public ): string {
|
||||||
|
$settings = self::getSettings();
|
||||||
|
$blocked = $settings['blocked_bots'] ?? array();
|
||||||
|
|
||||||
|
foreach ( $blocked as $bot ) {
|
||||||
|
if ( isset( self::KNOWN_BOTS[ $bot ] ) ) {
|
||||||
|
$output .= "\nUser-agent: {$bot}\nDisallow: /\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSettings(): array {
|
||||||
|
$saved = get_option( self::OPTION_KEY, array() );
|
||||||
|
return array_merge(
|
||||||
|
array( 'blocked_bots' => array() ),
|
||||||
|
is_array( $saved ) ? $saved : array()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
572
brezngeo/includes/Features/SchemaEnhancer.php
Normal file
572
brezngeo/includes/Features/SchemaEnhancer.php
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BreznGEO\Admin\SettingsPage;
|
||||||
|
use BreznGEO\Admin\SchemaMetaBox;
|
||||||
|
|
||||||
|
class SchemaEnhancer {
|
||||||
|
public function register(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$enabled = $settings['schema_enabled'] ?? array();
|
||||||
|
|
||||||
|
if ( empty( $enabled ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( in_array( 'ai_meta_tags', $enabled, true ) ) {
|
||||||
|
add_action( 'wp_head', array( $this, 'outputAiMetaTags' ), 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$json_ld_types = array_diff( $enabled, array( 'ai_meta_tags' ) );
|
||||||
|
if ( ! empty( $json_ld_types ) ) {
|
||||||
|
add_action( 'wp_head', array( $this, 'outputJsonLd' ), 5 );
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action( 'wp_head', array( $this, 'outputMetaDescription' ), 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputAiMetaTags(): void {
|
||||||
|
echo '<meta name="robots" content="max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
|
||||||
|
echo '<meta name="googlebot" content="max-snippet:-1, max-image-preview:large">' . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputMetaDescription(): void {
|
||||||
|
if ( defined( 'RANK_MATH_VERSION' ) || defined( 'WPSEO_VERSION' ) || defined( 'AIOSEO_VERSION' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! is_singular() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$desc = get_post_meta( get_the_ID(), '_brezngeo_meta_description', true );
|
||||||
|
if ( empty( $desc ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<meta name="description" content="' . esc_attr( $desc ) . '">' . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputJsonLd(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$enabled = $settings['schema_enabled'] ?? array();
|
||||||
|
$schemas = array();
|
||||||
|
|
||||||
|
if ( in_array( 'organization', $enabled, true ) ) {
|
||||||
|
$schemas[] = $this->buildOrganizationSchema( $settings );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( is_singular() ) {
|
||||||
|
if ( in_array( 'article_about', $enabled, true ) ) {
|
||||||
|
$schemas[] = $this->buildArticleSchema();
|
||||||
|
}
|
||||||
|
if ( in_array( 'author', $enabled, true ) ) {
|
||||||
|
$schemas[] = $this->buildAuthorSchema();
|
||||||
|
}
|
||||||
|
if ( in_array( 'speakable', $enabled, true ) ) {
|
||||||
|
$schemas[] = $this->buildSpeakableSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-types
|
||||||
|
if ( in_array( 'faq_schema', $enabled, true ) ) {
|
||||||
|
$faq = $this->buildFaqSchema();
|
||||||
|
if ( $faq ) {
|
||||||
|
$schemas[] = $faq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( in_array( 'blog_posting', $enabled, true ) ) {
|
||||||
|
$schemas[] = $this->buildBlogPosting();
|
||||||
|
}
|
||||||
|
if ( in_array( 'image_object', $enabled, true )
|
||||||
|
&& ! in_array( 'blog_posting', $enabled, true ) ) {
|
||||||
|
$img = $this->buildImageObject();
|
||||||
|
if ( $img ) {
|
||||||
|
$schemas[] = $img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( in_array( 'video_object', $enabled, true ) ) {
|
||||||
|
$vid = $this->buildVideoObject();
|
||||||
|
if ( $vid ) {
|
||||||
|
$schemas[] = $vid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metabox-types — only output if post's schema type matches
|
||||||
|
$schema_type = get_post_meta( get_the_ID(), SchemaMetaBox::META_TYPE, true );
|
||||||
|
if ( 'howto' === $schema_type && in_array( 'howto', $enabled, true ) ) {
|
||||||
|
$howto = $this->buildHowToSchema();
|
||||||
|
if ( $howto ) {
|
||||||
|
$schemas[] = $howto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( 'review' === $schema_type && in_array( 'review', $enabled, true ) ) {
|
||||||
|
$review = $this->buildReviewSchema();
|
||||||
|
if ( $review ) {
|
||||||
|
$schemas[] = $review;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( 'recipe' === $schema_type && in_array( 'recipe', $enabled, true ) ) {
|
||||||
|
$recipe = $this->buildRecipeSchema();
|
||||||
|
if ( $recipe ) {
|
||||||
|
$schemas[] = $recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( 'event' === $schema_type && in_array( 'event', $enabled, true ) ) {
|
||||||
|
$event = $this->buildEventSchema();
|
||||||
|
if ( $event ) {
|
||||||
|
$schemas[] = $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( in_array( 'breadcrumb', $enabled, true )
|
||||||
|
&& ! defined( 'RANK_MATH_VERSION' )
|
||||||
|
&& ! defined( 'WPSEO_VERSION' ) ) {
|
||||||
|
$breadcrumb = $this->buildBreadcrumbSchema();
|
||||||
|
if ( $breadcrumb ) {
|
||||||
|
$schemas[] = $breadcrumb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $schemas as $schema ) {
|
||||||
|
echo '<script type="application/ld+json">'
|
||||||
|
. wp_json_encode( $schema )
|
||||||
|
. '</script>' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildOrganizationSchema( array $settings ): array {
|
||||||
|
$same_as = array_values( array_filter( $settings['schema_same_as']['organization'] ?? array() ) );
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => get_bloginfo( 'name' ),
|
||||||
|
'url' => home_url( '/' ),
|
||||||
|
);
|
||||||
|
if ( ! empty( $same_as ) ) {
|
||||||
|
$schema['sameAs'] = $same_as;
|
||||||
|
}
|
||||||
|
$logo = get_site_icon_url( 192 );
|
||||||
|
if ( $logo ) {
|
||||||
|
$schema['logo'] = $logo;
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildArticleSchema(): array {
|
||||||
|
return array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Article',
|
||||||
|
'headline' => get_the_title(),
|
||||||
|
'url' => get_permalink(),
|
||||||
|
'datePublished' => get_the_date( 'c' ),
|
||||||
|
'dateModified' => get_the_modified_date( 'c' ),
|
||||||
|
'description' => get_post_meta( get_the_ID(), '_brezngeo_meta_description', true ) ?: get_the_excerpt(),
|
||||||
|
'publisher' => array(
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => get_bloginfo( 'name' ),
|
||||||
|
'url' => home_url( '/' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAuthorSchema(): array {
|
||||||
|
$author_id = (int) get_the_author_meta( 'ID' );
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Person',
|
||||||
|
'name' => get_the_author(),
|
||||||
|
'url' => get_author_posts_url( $author_id ),
|
||||||
|
);
|
||||||
|
$twitter = get_the_author_meta( 'twitter', $author_id );
|
||||||
|
if ( $twitter ) {
|
||||||
|
$schema['sameAs'] = array( 'https://twitter.com/' . ltrim( $twitter, '@' ) );
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSpeakableSchema(): array {
|
||||||
|
return array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'WebPage',
|
||||||
|
'url' => get_permalink(),
|
||||||
|
'speakable' => array(
|
||||||
|
'@type' => 'SpeakableSpecification',
|
||||||
|
'cssSelector' => array( 'h1', '.entry-content p:first-of-type', '.post-content p:first-of-type' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildBreadcrumbSchema(): ?array {
|
||||||
|
if ( ! is_singular() && ! is_category() ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = array(
|
||||||
|
array(
|
||||||
|
'@type' => 'ListItem',
|
||||||
|
'position' => 1,
|
||||||
|
'name' => get_bloginfo( 'name' ),
|
||||||
|
'item' => home_url( '/' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_singular() ) {
|
||||||
|
$items[] = array(
|
||||||
|
'@type' => 'ListItem',
|
||||||
|
'position' => 2,
|
||||||
|
'name' => get_the_title(),
|
||||||
|
'item' => get_permalink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'BreadcrumbList',
|
||||||
|
'itemListElement' => $items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper — converts GEO FAQ pairs to FAQPage schema.
|
||||||
|
* Returns null when the list is empty (skip empty schemas).
|
||||||
|
*
|
||||||
|
* @param array $faq Array of ['q' => string, 'a' => string] pairs.
|
||||||
|
*/
|
||||||
|
public static function faqPairsToSchema( array $faq ): ?array {
|
||||||
|
$entities = array();
|
||||||
|
foreach ( $faq as $item ) {
|
||||||
|
if ( empty( $item['q'] ) || empty( $item['a'] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entities[] = array(
|
||||||
|
'@type' => 'Question',
|
||||||
|
'name' => $item['q'],
|
||||||
|
'acceptedAnswer' => array(
|
||||||
|
'@type' => 'Answer',
|
||||||
|
'text' => $item['a'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( empty( $entities ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'FAQPage',
|
||||||
|
'mainEntity' => $entities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-dependent wrapper: reads from GeoBlock post meta.
|
||||||
|
*/
|
||||||
|
private function buildFaqSchema(): ?array {
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
if ( ! $post_id ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$meta = \BreznGEO\Features\GeoBlock::getMeta( $post_id );
|
||||||
|
return self::faqPairsToSchema( $meta['faq'] ?? array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts integer minutes to ISO 8601 duration string (e.g. 90 -> "PT90M").
|
||||||
|
*/
|
||||||
|
public static function minutesToIsoDuration( int $minutes ): string {
|
||||||
|
return 'PT' . $minutes . 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogPosting (or Article for non-post types) with embedded author + image.
|
||||||
|
*/
|
||||||
|
private function buildBlogPosting(): array {
|
||||||
|
$type = get_post_type() === 'post' ? 'BlogPosting' : 'Article';
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => $type,
|
||||||
|
'headline' => get_the_title(),
|
||||||
|
'url' => get_permalink(),
|
||||||
|
'datePublished' => get_the_date( 'c' ),
|
||||||
|
'dateModified' => get_the_modified_date( 'c' ),
|
||||||
|
'description' => get_post_meta( get_the_ID(), '_brezngeo_meta_description', true )
|
||||||
|
?: get_the_excerpt(),
|
||||||
|
'publisher' => array(
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => get_bloginfo( 'name' ),
|
||||||
|
'url' => home_url( '/' ),
|
||||||
|
),
|
||||||
|
'author' => array(
|
||||||
|
'@type' => 'Person',
|
||||||
|
'name' => get_the_author(),
|
||||||
|
'url' => get_author_posts_url( (int) get_the_author_meta( 'ID' ) ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
$img = $this->buildImageObject();
|
||||||
|
if ( $img ) {
|
||||||
|
$schema['image'] = $img;
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImageObject from featured image. Returns null when no thumbnail is set.
|
||||||
|
*/
|
||||||
|
private function buildImageObject(): ?array {
|
||||||
|
if ( ! has_post_thumbnail() ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$src = wp_get_attachment_image_src( get_post_thumbnail_id(), 'full' );
|
||||||
|
if ( ! $src ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'ImageObject',
|
||||||
|
'contentUrl' => $src[0],
|
||||||
|
);
|
||||||
|
if ( ! empty( $src[1] ) ) {
|
||||||
|
$schema['width'] = (int) $src[1];
|
||||||
|
}
|
||||||
|
if ( ! empty( $src[2] ) ) {
|
||||||
|
$schema['height'] = (int) $src[2];
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts first YouTube or Vimeo video from HTML content.
|
||||||
|
* Returns ['platform' => 'youtube'|'vimeo', 'videoId' => string] or null.
|
||||||
|
*/
|
||||||
|
public static function extractVideoFromContent( string $content ): ?array {
|
||||||
|
// YouTube embed or youtu.be
|
||||||
|
if ( preg_match(
|
||||||
|
'#(?:youtube\.com/embed/|youtu\.be/)([a-zA-Z0-9_\-]{11})#',
|
||||||
|
$content,
|
||||||
|
$m
|
||||||
|
) ) {
|
||||||
|
return array(
|
||||||
|
'platform' => 'youtube',
|
||||||
|
'videoId' => $m[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Vimeo
|
||||||
|
if ( preg_match( '#player\.vimeo\.com/video/(\d+)#', $content, $m ) ) {
|
||||||
|
return array(
|
||||||
|
'platform' => 'vimeo',
|
||||||
|
'videoId' => $m[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-dependent wrapper: builds VideoObject from first video found in post content.
|
||||||
|
*/
|
||||||
|
private function buildVideoObject(): ?array {
|
||||||
|
global $post;
|
||||||
|
$content = isset( $post->post_content ) ? $post->post_content : '';
|
||||||
|
$video = self::extractVideoFromContent( $content );
|
||||||
|
if ( ! $video ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( $video['platform'] === 'youtube' ) {
|
||||||
|
$embed_url = 'https://www.youtube.com/embed/' . $video['videoId'];
|
||||||
|
$thumbnail_url = 'https://i.ytimg.com/vi/' . $video['videoId'] . '/hqdefault.jpg';
|
||||||
|
} else {
|
||||||
|
$embed_url = 'https://player.vimeo.com/video/' . $video['videoId'];
|
||||||
|
$thumbnail_url = '';
|
||||||
|
}
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'VideoObject',
|
||||||
|
'name' => get_the_title(),
|
||||||
|
'description' => get_post_meta( get_the_ID(), '_brezngeo_meta_description', true ) ?: get_the_excerpt(),
|
||||||
|
'embedUrl' => $embed_url,
|
||||||
|
'uploadDate' => get_the_date( 'c' ),
|
||||||
|
);
|
||||||
|
if ( $thumbnail_url ) {
|
||||||
|
$schema['thumbnailUrl'] = $thumbnail_url;
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure builder for HowTo schema.
|
||||||
|
*
|
||||||
|
* @param string $name The how-to title.
|
||||||
|
* @param string[] $steps Each step as a string.
|
||||||
|
*/
|
||||||
|
public static function buildHowToFromData( string $name, array $steps ): array {
|
||||||
|
$how_to_steps = array();
|
||||||
|
foreach ( array_filter( array_map( 'trim', $steps ) ) as $step ) {
|
||||||
|
$how_to_steps[] = array(
|
||||||
|
'@type' => 'HowToStep',
|
||||||
|
'name' => $step,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'HowTo',
|
||||||
|
'name' => $name,
|
||||||
|
'step' => $how_to_steps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-dependent: builds HowTo from post meta.
|
||||||
|
*/
|
||||||
|
private function buildHowToSchema(): ?array {
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
$raw_data = get_post_meta( $post_id, SchemaMetaBox::META_DATA, true ) ?: '{}';
|
||||||
|
$data = json_decode( $raw_data, true );
|
||||||
|
$howto = isset( $data['howto'] ) && is_array( $data['howto'] ) ? $data['howto'] : array();
|
||||||
|
$name = $howto['name'] ?? '';
|
||||||
|
$steps = $howto['steps'] ?? array();
|
||||||
|
if ( empty( $name ) || empty( $steps ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::buildHowToFromData( $name, $steps );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure builder for Review schema.
|
||||||
|
*
|
||||||
|
* @param string $item Name of the reviewed item.
|
||||||
|
* @param int $rating Rating 1-5.
|
||||||
|
* @param string $author Reviewer name.
|
||||||
|
*/
|
||||||
|
public static function buildReviewFromData( string $item, int $rating, string $author ): array {
|
||||||
|
$rating = max( 1, min( 5, $rating ) );
|
||||||
|
return array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Review',
|
||||||
|
'itemReviewed' => array(
|
||||||
|
'@type' => 'Thing',
|
||||||
|
'name' => $item,
|
||||||
|
),
|
||||||
|
'reviewRating' => array(
|
||||||
|
'@type' => 'Rating',
|
||||||
|
'ratingValue' => $rating,
|
||||||
|
'bestRating' => 5,
|
||||||
|
'worstRating' => 1,
|
||||||
|
),
|
||||||
|
'author' => array(
|
||||||
|
'@type' => 'Person',
|
||||||
|
'name' => $author,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-dependent: builds Review from post meta.
|
||||||
|
*/
|
||||||
|
private function buildReviewSchema(): ?array {
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
$raw_data = get_post_meta( $post_id, SchemaMetaBox::META_DATA, true ) ?: '{}';
|
||||||
|
$data = json_decode( $raw_data, true );
|
||||||
|
$review = isset( $data['review'] ) && is_array( $data['review'] ) ? $data['review'] : array();
|
||||||
|
$item = $review['item'] ?? '';
|
||||||
|
$rating = (int) ( $review['rating'] ?? 0 );
|
||||||
|
if ( empty( $item ) || $rating < 1 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::buildReviewFromData( $item, $rating, get_the_author() );
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Pure builder for Recipe schema.
|
||||||
|
*
|
||||||
|
* @param array $d Keys: name, prep (int minutes), cook (int minutes),
|
||||||
|
* servings (string), ingredients (string[]), instructions (string[])
|
||||||
|
*/
|
||||||
|
public static function buildRecipeFromData( array $d ): array {
|
||||||
|
$steps = array();
|
||||||
|
foreach ( array_filter( array_map( 'trim', $d['instructions'] ?? array() ) ) as $step ) {
|
||||||
|
$steps[] = array(
|
||||||
|
'@type' => 'HowToStep',
|
||||||
|
'text' => $step,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Recipe',
|
||||||
|
'name' => $d['name'] ?? '',
|
||||||
|
'recipeIngredient' => array_values( array_filter( array_map( 'trim', $d['ingredients'] ?? array() ) ) ),
|
||||||
|
'recipeInstructions' => $steps,
|
||||||
|
);
|
||||||
|
if ( ! empty( $d['prep'] ) ) {
|
||||||
|
$schema['prepTime'] = self::minutesToIsoDuration( (int) $d['prep'] );
|
||||||
|
}
|
||||||
|
if ( ! empty( $d['cook'] ) ) {
|
||||||
|
$schema['cookTime'] = self::minutesToIsoDuration( (int) $d['cook'] );
|
||||||
|
}
|
||||||
|
if ( ! empty( $d['servings'] ) ) {
|
||||||
|
$schema['recipeYield'] = $d['servings'];
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-dependent: builds Recipe from post meta.
|
||||||
|
*/
|
||||||
|
private function buildRecipeSchema(): ?array {
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
$raw_data = get_post_meta( $post_id, SchemaMetaBox::META_DATA, true ) ?: '{}';
|
||||||
|
$data = json_decode( $raw_data, true );
|
||||||
|
$recipe = isset( $data['recipe'] ) && is_array( $data['recipe'] ) ? $data['recipe'] : array();
|
||||||
|
if ( empty( $recipe['name'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::buildRecipeFromData( $recipe );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure builder for Event schema.
|
||||||
|
*
|
||||||
|
* @param array $d Keys: name, start (date string), end (date string),
|
||||||
|
* location (string), online (bool)
|
||||||
|
*/
|
||||||
|
public static function buildEventFromData( array $d ): array {
|
||||||
|
$is_online = ! empty( $d['online'] );
|
||||||
|
$location_type = $is_online ? 'VirtualLocation' : 'Place';
|
||||||
|
$location = array(
|
||||||
|
'@type' => $location_type,
|
||||||
|
'name' => $d['location'] ?? '',
|
||||||
|
);
|
||||||
|
if ( $is_online && ! empty( $d['location'] ) ) {
|
||||||
|
$location['url'] = $d['location'];
|
||||||
|
}
|
||||||
|
$schema = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Event',
|
||||||
|
'name' => $d['name'] ?? '',
|
||||||
|
'startDate' => $d['start'] ?? '',
|
||||||
|
'location' => $location,
|
||||||
|
'eventStatus' => 'EventScheduled',
|
||||||
|
);
|
||||||
|
if ( ! empty( $d['end'] ) ) {
|
||||||
|
$schema['endDate'] = $d['end'];
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-dependent: builds Event from post meta.
|
||||||
|
*/
|
||||||
|
private function buildEventSchema(): ?array {
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
$raw_data = get_post_meta( $post_id, SchemaMetaBox::META_DATA, true ) ?: '{}';
|
||||||
|
$data = json_decode( $raw_data, true );
|
||||||
|
$event = isset( $data['event'] ) && is_array( $data['event'] ) ? $data['event'] : array();
|
||||||
|
if ( empty( $event['name'] ) || empty( $event['start'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::buildEventFromData( $event );
|
||||||
|
}
|
||||||
|
}
|
||||||
28
brezngeo/includes/Helpers/BulkQueue.php
Normal file
28
brezngeo/includes/Helpers/BulkQueue.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Helpers;
|
||||||
|
|
||||||
|
class BulkQueue {
|
||||||
|
private const LOCK_KEY = 'brezngeo_bulk_running';
|
||||||
|
private const LOCK_TTL = 900; // 15 minutes
|
||||||
|
|
||||||
|
public static function acquire(): bool {
|
||||||
|
if ( self::isLocked() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set_transient( self::LOCK_KEY, time(), self::LOCK_TTL );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function release(): void {
|
||||||
|
delete_transient( self::LOCK_KEY );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isLocked(): bool {
|
||||||
|
return get_transient( self::LOCK_KEY ) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lockAge(): int {
|
||||||
|
$started = get_transient( self::LOCK_KEY );
|
||||||
|
return $started !== false ? ( time() - (int) $started ) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
brezngeo/includes/Helpers/FallbackMeta.php
Normal file
52
brezngeo/includes/Helpers/FallbackMeta.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Helpers;
|
||||||
|
|
||||||
|
class FallbackMeta {
|
||||||
|
private const MIN = 150;
|
||||||
|
private const MAX = 160;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a clean 150–160 char meta description from post content.
|
||||||
|
* Ends on a complete sentence or word boundary. No HTML.
|
||||||
|
*
|
||||||
|
* @param object $post Object with post_content property (WP_Post compatible)
|
||||||
|
*/
|
||||||
|
public static function extract( object $post ): string {
|
||||||
|
$text = wp_strip_all_tags( $post->post_content ?? '' );
|
||||||
|
$text = preg_replace( '/\s+/', ' ', $text );
|
||||||
|
$text = trim( $text );
|
||||||
|
|
||||||
|
if ( $text === '' ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( mb_strlen( $text ) <= self::MAX ) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to end on a sentence boundary within MAX chars
|
||||||
|
$candidate = mb_substr( $text, 0, self::MAX );
|
||||||
|
$last_period = mb_strrpos( $candidate, '. ' );
|
||||||
|
$last_exclaim = mb_strrpos( $candidate, '! ' );
|
||||||
|
$last_question = mb_strrpos( $candidate, '? ' );
|
||||||
|
|
||||||
|
$last_sentence = max(
|
||||||
|
$last_period === false ? -1 : $last_period,
|
||||||
|
$last_exclaim === false ? -1 : $last_exclaim,
|
||||||
|
$last_question === false ? -1 : $last_question
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( $last_sentence >= 0 && $last_sentence >= self::MIN - 1 ) {
|
||||||
|
return mb_substr( $text, 0, $last_sentence + 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to last word boundary within MAX
|
||||||
|
$last_space = mb_strrpos( $candidate, ' ' );
|
||||||
|
if ( $last_space !== false && $last_space >= self::MIN - 20 ) {
|
||||||
|
return mb_substr( $text, 0, $last_space ) . '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: hard cut with ellipsis
|
||||||
|
return mb_substr( $text, 0, self::MAX - 1 ) . '…';
|
||||||
|
}
|
||||||
|
}
|
||||||
79
brezngeo/includes/Helpers/KeyVault.php
Normal file
79
brezngeo/includes/Helpers/KeyVault.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Helpers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obfuscates API keys for database storage using XOR with a derived WP-salt key.
|
||||||
|
*
|
||||||
|
* No OpenSSL or other PHP extensions required — only core string functions.
|
||||||
|
* Keys stored as "bre1:<base64(xor(plaintext, salt))>".
|
||||||
|
*
|
||||||
|
* Note: XOR with a static salt is obfuscation, not encryption. It prevents
|
||||||
|
* plain-text keys from appearing in database backups or export files, but
|
||||||
|
* does not protect against an attacker with access to both the database
|
||||||
|
* AND the wp-config.php salts. For stronger protection, users can define
|
||||||
|
* BREZNGEO_<PROVIDER>_KEY constants in wp-config.php and leave the DB field empty.
|
||||||
|
*/
|
||||||
|
class KeyVault {
|
||||||
|
private const PREFIX = 'bre1:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obfuscate a plain API key for database storage.
|
||||||
|
*/
|
||||||
|
public static function encrypt( string $key ): string {
|
||||||
|
if ( $key === '' ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return self::PREFIX . base64_encode( self::xor( $key, self::salt() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the plain API key from a stored obfuscated value.
|
||||||
|
* Returns empty string if the stored value is not in bre1: format (legacy/invalid).
|
||||||
|
*/
|
||||||
|
public static function decrypt( string $stored ): string {
|
||||||
|
if ( $stored === '' ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ( ! str_starts_with( $stored, self::PREFIX ) ) {
|
||||||
|
// Legacy OpenSSL-encrypted value or unknown format — return empty so user re-enters.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true );
|
||||||
|
if ( $raw === false ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return self::xor( $raw, self::salt() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns masked version for display: ••••••Ab3c9
|
||||||
|
*/
|
||||||
|
public static function mask( string $plain ): string {
|
||||||
|
if ( $plain === '' ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return str_repeat( '•', max( 0, mb_strlen( $plain ) - 5 ) ) . mb_substr( $plain, -5 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XOR each byte of $data with the corresponding byte of $key (wrapping).
|
||||||
|
*/
|
||||||
|
private static function xor( string $data, string $key ): string {
|
||||||
|
$out = '';
|
||||||
|
$keyLen = strlen( $key );
|
||||||
|
for ( $i = 0, $n = strlen( $data ); $i < $n; $i++ ) {
|
||||||
|
$out .= $data[ $i ] ^ $key[ $i % $keyLen ];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a 64-character hex salt from WP's AUTH_KEY and SECURE_AUTH_KEY.
|
||||||
|
* Falls back to known strings if the constants are not defined (local dev / unit tests).
|
||||||
|
*/
|
||||||
|
private static function salt(): string {
|
||||||
|
$a = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'brezngeo-fallback-a';
|
||||||
|
$b = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'brezngeo-fallback-b';
|
||||||
|
return hash( 'sha256', $a . $b ); // 64 hex chars, no extension needed
|
||||||
|
}
|
||||||
|
}
|
||||||
103
brezngeo/includes/Helpers/TokenEstimator.php
Normal file
103
brezngeo/includes/Helpers/TokenEstimator.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Helpers;
|
||||||
|
|
||||||
|
class TokenEstimator {
|
||||||
|
/**
|
||||||
|
* Pricing per 1k tokens [provider][model][input|output]
|
||||||
|
* Update these when provider pricing changes.
|
||||||
|
*/
|
||||||
|
private const PRICING = array(
|
||||||
|
'openai' => array(
|
||||||
|
'gpt-4.1' => array(
|
||||||
|
'input' => 0.002,
|
||||||
|
'output' => 0.008,
|
||||||
|
),
|
||||||
|
'gpt-4o' => array(
|
||||||
|
'input' => 0.0025,
|
||||||
|
'output' => 0.01,
|
||||||
|
),
|
||||||
|
'gpt-4o-mini' => array(
|
||||||
|
'input' => 0.00015,
|
||||||
|
'output' => 0.0006,
|
||||||
|
),
|
||||||
|
'gpt-3.5-turbo' => array(
|
||||||
|
'input' => 0.0005,
|
||||||
|
'output' => 0.0015,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'anthropic' => array(
|
||||||
|
'claude-sonnet-4-6' => array(
|
||||||
|
'input' => 0.003,
|
||||||
|
'output' => 0.015,
|
||||||
|
),
|
||||||
|
'claude-opus-4-6' => array(
|
||||||
|
'input' => 0.015,
|
||||||
|
'output' => 0.075,
|
||||||
|
),
|
||||||
|
'claude-haiku-4-5-20251001' => array(
|
||||||
|
'input' => 0.00025,
|
||||||
|
'output' => 0.00125,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'gemini' => array(
|
||||||
|
'gemini-2.0-flash' => array(
|
||||||
|
'input' => 0.00010,
|
||||||
|
'output' => 0.00040,
|
||||||
|
),
|
||||||
|
'gemini-2.0-flash-lite' => array(
|
||||||
|
'input' => 0.000038,
|
||||||
|
'output' => 0.00015,
|
||||||
|
),
|
||||||
|
'gemini-1.5-pro' => array(
|
||||||
|
'input' => 0.00125,
|
||||||
|
'output' => 0.005,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'grok' => array(
|
||||||
|
'grok-3' => array(
|
||||||
|
'input' => 0.003,
|
||||||
|
'output' => 0.015,
|
||||||
|
),
|
||||||
|
'grok-3-mini' => array(
|
||||||
|
'input' => 0.0003,
|
||||||
|
'output' => 0.0005,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Estimate token count (~4 chars per token) */
|
||||||
|
public static function estimate( string $text ): int {
|
||||||
|
return (int) ceil( mb_strlen( $text ) / 4 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate text to approximately $max_tokens */
|
||||||
|
public static function truncate( string $text, int $max_tokens ): string {
|
||||||
|
$max_chars = $max_tokens * 4;
|
||||||
|
if ( mb_strlen( $text ) <= $max_chars ) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
return mb_substr( $text, 0, $max_chars );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate cost in USD.
|
||||||
|
*
|
||||||
|
* @param int $tokens Number of tokens
|
||||||
|
* @param string $provider Provider ID
|
||||||
|
* @param string $model Model ID
|
||||||
|
* @param string $type 'input' or 'output'
|
||||||
|
*/
|
||||||
|
public static function estimateCost( int $tokens, string $provider, string $model, string $type = 'input' ): float {
|
||||||
|
$price_per_1k = self::PRICING[ $provider ][ $model ][ $type ] ?? 0.002;
|
||||||
|
return round( ( $tokens / 1000 ) * $price_per_1k, 6 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable cost string e.g. "~0,05 €" */
|
||||||
|
public static function formatCost( float $usd ): string {
|
||||||
|
$eur = $usd * 0.92;
|
||||||
|
if ( $eur < 0.01 ) {
|
||||||
|
return '< 0,01 €';
|
||||||
|
}
|
||||||
|
return '~' . number_format( $eur, 2, ',', '.' ) . ' €';
|
||||||
|
}
|
||||||
|
}
|
||||||
74
brezngeo/includes/Providers/AnthropicProvider.php
Normal file
74
brezngeo/includes/Providers/AnthropicProvider.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Providers;
|
||||||
|
|
||||||
|
class AnthropicProvider implements ProviderInterface {
|
||||||
|
private const API_URL = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
|
public function getId(): string {
|
||||||
|
return 'anthropic'; }
|
||||||
|
public function getName(): string {
|
||||||
|
return 'Anthropic (Claude)'; }
|
||||||
|
|
||||||
|
public function getModels(): array {
|
||||||
|
return array(
|
||||||
|
'claude-sonnet-4-6' => 'Claude Sonnet 4.6 (' . __( 'Recommended', 'brezngeo' ) . ')',
|
||||||
|
'claude-opus-4-6' => 'Claude Opus 4.6 (' . __( 'Powerful', 'brezngeo' ) . ')',
|
||||||
|
'claude-haiku-4-5-20251001' => 'Claude Haiku 4.5 (' . __( 'Fast & cheap', 'brezngeo' ) . ')',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection( string $api_key ): array {
|
||||||
|
try {
|
||||||
|
$this->generateText( 'Say "ok"', $api_key, 'claude-haiku-4-5-20251001', 5 );
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'Connection successful', 'brezngeo' ),
|
||||||
|
);
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
|
||||||
|
$response = wp_remote_post(
|
||||||
|
self::API_URL,
|
||||||
|
array(
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => array(
|
||||||
|
'x-api-key' => $api_key,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
),
|
||||||
|
'body' => wp_json_encode(
|
||||||
|
array(
|
||||||
|
'model' => $model,
|
||||||
|
'max_tokens' => $max_tokens,
|
||||||
|
'messages' => array(
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $prompt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
|
if ( $code !== 200 ) {
|
||||||
|
$msg = $body['error']['message'] ?? "HTTP $code";
|
||||||
|
throw new \RuntimeException( esc_html( $msg ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim( $body['content'][0]['text'] ?? '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
68
brezngeo/includes/Providers/GeminiProvider.php
Normal file
68
brezngeo/includes/Providers/GeminiProvider.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Providers;
|
||||||
|
|
||||||
|
class GeminiProvider implements ProviderInterface {
|
||||||
|
private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/';
|
||||||
|
|
||||||
|
public function getId(): string {
|
||||||
|
return 'gemini'; }
|
||||||
|
public function getName(): string {
|
||||||
|
return 'Google Gemini'; }
|
||||||
|
|
||||||
|
public function getModels(): array {
|
||||||
|
return array(
|
||||||
|
'gemini-2.0-flash' => 'Gemini 2.0 Flash (' . __( 'Recommended', 'brezngeo' ) . ')',
|
||||||
|
'gemini-2.0-flash-lite' => 'Gemini 2.0 Flash Lite (' . __( 'Cheap', 'brezngeo' ) . ')',
|
||||||
|
'gemini-1.5-pro' => 'Gemini 1.5 Pro',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection( string $api_key ): array {
|
||||||
|
try {
|
||||||
|
$this->generateText( 'Say "ok"', $api_key, 'gemini-2.0-flash-lite', 5 );
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'Connection successful', 'brezngeo' ),
|
||||||
|
);
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
|
||||||
|
$url = self::API_BASE . $model . ':generateContent';
|
||||||
|
$response = wp_remote_post(
|
||||||
|
$url,
|
||||||
|
array(
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => array(
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'x-goog-api-key' => $api_key,
|
||||||
|
),
|
||||||
|
'body' => wp_json_encode(
|
||||||
|
array(
|
||||||
|
'contents' => array( array( 'parts' => array( array( 'text' => $prompt ) ) ) ),
|
||||||
|
'generationConfig' => array( 'maxOutputTokens' => $max_tokens ),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
|
if ( $code !== 200 ) {
|
||||||
|
$msg = $body['error']['message'] ?? "HTTP $code";
|
||||||
|
throw new \RuntimeException( esc_html( $msg ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim( $body['candidates'][0]['content']['parts'][0]['text'] ?? '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
72
brezngeo/includes/Providers/GrokProvider.php
Normal file
72
brezngeo/includes/Providers/GrokProvider.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Providers;
|
||||||
|
|
||||||
|
class GrokProvider implements ProviderInterface {
|
||||||
|
private const API_URL = 'https://api.x.ai/v1/chat/completions';
|
||||||
|
|
||||||
|
public function getId(): string {
|
||||||
|
return 'grok'; }
|
||||||
|
public function getName(): string {
|
||||||
|
return 'xAI Grok'; }
|
||||||
|
|
||||||
|
public function getModels(): array {
|
||||||
|
return array(
|
||||||
|
'grok-3' => 'Grok 3 (' . __( 'Recommended', 'brezngeo' ) . ')',
|
||||||
|
'grok-3-mini' => 'Grok 3 Mini (' . __( 'Cheap', 'brezngeo' ) . ')',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection( string $api_key ): array {
|
||||||
|
try {
|
||||||
|
$this->generateText( 'Say "ok"', $api_key, 'grok-3-mini', 5 );
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'Connection successful', 'brezngeo' ),
|
||||||
|
);
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
|
||||||
|
$response = wp_remote_post(
|
||||||
|
self::API_URL,
|
||||||
|
array(
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => array(
|
||||||
|
'Authorization' => 'Bearer ' . $api_key,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
),
|
||||||
|
'body' => wp_json_encode(
|
||||||
|
array(
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => array(
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $prompt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'max_tokens' => $max_tokens,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
|
if ( $code !== 200 ) {
|
||||||
|
$msg = $body['error']['message'] ?? "HTTP $code";
|
||||||
|
throw new \RuntimeException( esc_html( $msg ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim( $body['choices'][0]['message']['content'] ?? '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
78
brezngeo/includes/Providers/OpenAIProvider.php
Normal file
78
brezngeo/includes/Providers/OpenAIProvider.php
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Providers;
|
||||||
|
|
||||||
|
class OpenAIProvider implements ProviderInterface {
|
||||||
|
private const API_URL = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
|
public function getId(): string {
|
||||||
|
return 'openai'; }
|
||||||
|
public function getName(): string {
|
||||||
|
return 'OpenAI'; }
|
||||||
|
|
||||||
|
public function getModels(): array {
|
||||||
|
return array(
|
||||||
|
'gpt-4.1' => 'GPT-4.1 (' . __( 'Recommended', 'brezngeo' ) . ')',
|
||||||
|
'gpt-4o' => 'GPT-4o',
|
||||||
|
'gpt-4o-mini' => 'GPT-4o Mini (' . __( 'Cheap', 'brezngeo' ) . ')',
|
||||||
|
'gpt-3.5-turbo' => 'GPT-3.5 Turbo (' . __( 'Very cheap', 'brezngeo' ) . ')',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection( string $api_key ): array {
|
||||||
|
try {
|
||||||
|
$this->generateText( 'Say "ok"', $api_key, 'gpt-4o-mini', 5 );
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'Connection successful', 'brezngeo' ),
|
||||||
|
);
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
|
||||||
|
$response = wp_remote_post(
|
||||||
|
self::API_URL,
|
||||||
|
array(
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => array(
|
||||||
|
'Authorization' => 'Bearer ' . $api_key,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
),
|
||||||
|
'body' => wp_json_encode(
|
||||||
|
array(
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => array(
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $prompt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'max_tokens' => $max_tokens,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->parseResponse( $response );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseResponse( $response ): string {
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
|
if ( $code !== 200 ) {
|
||||||
|
$msg = $body['error']['message'] ?? "HTTP $code";
|
||||||
|
throw new \RuntimeException( esc_html( $msg ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim( $body['choices'][0]['message']['content'] ?? '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
34
brezngeo/includes/Providers/ProviderInterface.php
Normal file
34
brezngeo/includes/Providers/ProviderInterface.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO\Providers;
|
||||||
|
|
||||||
|
interface ProviderInterface {
|
||||||
|
/** Unique machine-readable ID, e.g. 'openai' */
|
||||||
|
public function getId(): string;
|
||||||
|
|
||||||
|
/** Human-readable label for dropdowns */
|
||||||
|
public function getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available models as ['model-id' => 'Human Label']
|
||||||
|
* Ordered from most capable to least expensive
|
||||||
|
*/
|
||||||
|
public function getModels(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test API connectivity with minimal cost.
|
||||||
|
* Returns ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
public function testConnection( string $api_key ): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate text from a prompt.
|
||||||
|
*
|
||||||
|
* @param string $prompt The full prompt to send
|
||||||
|
* @param string $api_key Provider API key
|
||||||
|
* @param string $model Model ID from getModels()
|
||||||
|
* @param int $max_tokens Maximum tokens in response (0 = provider default)
|
||||||
|
* @return string Generated text or empty string on failure
|
||||||
|
* @throws \RuntimeException on API error
|
||||||
|
*/
|
||||||
|
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string;
|
||||||
|
}
|
||||||
45
brezngeo/includes/Providers/ProviderRegistry.php
Normal file
45
brezngeo/includes/Providers/ProviderRegistry.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
namespace BreznGEO;
|
||||||
|
|
||||||
|
use BreznGEO\Providers\ProviderInterface;
|
||||||
|
|
||||||
|
class ProviderRegistry {
|
||||||
|
private static ?ProviderRegistry $instance = null;
|
||||||
|
private array $providers = array();
|
||||||
|
|
||||||
|
private function __construct() {}
|
||||||
|
|
||||||
|
public static function instance(): self {
|
||||||
|
if ( null === self::$instance ) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register( ProviderInterface $provider ): void {
|
||||||
|
$this->providers[ $provider->getId() ] = $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get( string $id ): ?ProviderInterface {
|
||||||
|
return $this->providers[ $id ] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return ProviderInterface[] */
|
||||||
|
public function all(): array {
|
||||||
|
return $this->providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset singleton — for use in tests only */
|
||||||
|
public static function reset(): void {
|
||||||
|
self::$instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns ['id' => 'Name'] for dropdowns */
|
||||||
|
public function getSelectOptions(): array {
|
||||||
|
$options = array();
|
||||||
|
foreach ( $this->providers as $id => $provider ) {
|
||||||
|
$options[ $id ] = $provider->getName();
|
||||||
|
}
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
brezngeo/languages/brezngeo-de_DE.mo
Normal file
BIN
brezngeo/languages/brezngeo-de_DE.mo
Normal file
Binary file not shown.
1091
brezngeo/languages/brezngeo-de_DE.po
Normal file
1091
brezngeo/languages/brezngeo-de_DE.po
Normal file
File diff suppressed because it is too large
Load diff
BIN
brezngeo/languages/brezngeo-en_US.mo
Normal file
BIN
brezngeo/languages/brezngeo-en_US.mo
Normal file
Binary file not shown.
653
brezngeo/languages/brezngeo-en_US.po
Normal file
653
brezngeo/languages/brezngeo-en_US.po
Normal file
|
|
@ -0,0 +1,653 @@
|
||||||
|
# English (en_US) translation for Bavarian Rank Engine
|
||||||
|
# Copyright (C) 2025 Donau2Space
|
||||||
|
# This file is distributed under the GPL-2.0-or-later license.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Bavarian Rank Engine 1.0.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: https://donau2space.de\n"
|
||||||
|
"POT-Creation-Date: 2026-02-21T00:00:00+00:00\n"
|
||||||
|
"PO-Revision-Date: 2026-02-21T00:00:00+00:00\n"
|
||||||
|
"Last-Translator: Donau2Space <info@donau2space.de>\n"
|
||||||
|
"Language-Team: English <en@li.org>\n"
|
||||||
|
"Language: en_US\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
"X-Generator: Manual\n"
|
||||||
|
"X-Domain: bavarian-rank-engine\n"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:11
|
||||||
|
msgid "Bavarian Rank Engine"
|
||||||
|
msgstr "Bavarian Rank Engine"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:12
|
||||||
|
msgid "Bavarian Rank"
|
||||||
|
msgstr "Bavarian Rank"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:23
|
||||||
|
#: includes/Admin/AdminMenu.php:24
|
||||||
|
msgid "Dashboard"
|
||||||
|
msgstr "Dashboard"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:31
|
||||||
|
#: includes/Admin/AdminMenu.php:32
|
||||||
|
#: includes/Admin/views/provider.php:3
|
||||||
|
#: includes/Admin/views/provider.php:10
|
||||||
|
msgid "AI Provider"
|
||||||
|
msgstr "AI Provider"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:41
|
||||||
|
#: includes/Admin/AdminMenu.php:42
|
||||||
|
#: includes/Admin/views/meta.php:3
|
||||||
|
#: includes/Admin/views/meta.php:10
|
||||||
|
msgid "Meta Generator"
|
||||||
|
msgstr "Meta Generator"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:59
|
||||||
|
#: includes/Admin/AdminMenu.php:60
|
||||||
|
#: includes/Admin/views/bulk.php:3
|
||||||
|
msgid "Bulk Generator"
|
||||||
|
msgstr "Bulk Generator"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php
|
||||||
|
msgid "TXT Files"
|
||||||
|
msgstr "TXT Files"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:3
|
||||||
|
msgid "Bavarian Rank Engine \xe2\x80\x94 Dashboard"
|
||||||
|
msgstr "Bavarian Rank Engine \xe2\x80\x94 Dashboard"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:8
|
||||||
|
msgid "Meta Coverage"
|
||||||
|
msgstr "Meta Coverage"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:11
|
||||||
|
msgid "No post types configured."
|
||||||
|
msgstr "No post types configured."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:15
|
||||||
|
msgid "Post Type"
|
||||||
|
msgstr "Post Type"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:16
|
||||||
|
msgid "Published"
|
||||||
|
msgstr "Published"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:17
|
||||||
|
msgid "With Meta"
|
||||||
|
msgstr "With Meta"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:18
|
||||||
|
msgid "Coverage"
|
||||||
|
msgstr "Coverage"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:36
|
||||||
|
msgid "Quick Links"
|
||||||
|
msgstr "Quick Links"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:39
|
||||||
|
msgid "AI Provider Settings"
|
||||||
|
msgstr "AI Provider Settings"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:40
|
||||||
|
msgid "Meta Generator Settings"
|
||||||
|
msgstr "Meta Generator Settings"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:42
|
||||||
|
#: includes/Admin/views/dashboard.php:48
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "Status"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:50
|
||||||
|
msgid "Version:"
|
||||||
|
msgstr "Version:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:51
|
||||||
|
msgid "Active Provider:"
|
||||||
|
msgstr "Active Provider:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:56
|
||||||
|
msgid "Interne Link-Analyse"
|
||||||
|
msgstr "Internal Link Analysis"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:58
|
||||||
|
msgid "Wird geladen\xe2\x80\xa6"
|
||||||
|
msgstr "Loading\xe2\x80\xa6"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:63
|
||||||
|
msgid "AI Crawler \xe2\x80\x94 letzte 30 Tage"
|
||||||
|
msgstr "AI Crawler \xe2\x80\x94 last 30 days"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:67
|
||||||
|
msgid "Noch keine AI-Crawls aufgezeichnet."
|
||||||
|
msgstr "No AI crawls recorded yet."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:71
|
||||||
|
msgid "Bot"
|
||||||
|
msgstr "Bot"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:72
|
||||||
|
msgid "Besuche"
|
||||||
|
msgstr "Visits"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:73
|
||||||
|
msgid "Zuletzt"
|
||||||
|
msgstr "Last seen"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:13
|
||||||
|
#: includes/Admin/views/bulk.php:15
|
||||||
|
msgid "Active Provider"
|
||||||
|
msgstr "Active Provider"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:27
|
||||||
|
msgid "API Key"
|
||||||
|
msgstr "API Key"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:31
|
||||||
|
msgid "Saved:"
|
||||||
|
msgstr "Saved:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:37
|
||||||
|
msgid "Enter new key to overwrite"
|
||||||
|
msgstr "Enter new key to overwrite"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:37
|
||||||
|
msgid "Enter API key"
|
||||||
|
msgstr "Enter API key"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:41
|
||||||
|
msgid "Test connection"
|
||||||
|
msgstr "Test connection"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:45
|
||||||
|
#: includes/Admin/views/bulk.php:28
|
||||||
|
msgid "Model:"
|
||||||
|
msgstr "Model:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:63
|
||||||
|
msgid "Aktuelle Preise ansehen \xe2\x86\x92"
|
||||||
|
msgstr "View current prices \xe2\x86\x92"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:67
|
||||||
|
msgid "Kosten pro 1 Million Token (f\xc3\xbcr Kostens\xc3\xbcbersicht im Bulk):"
|
||||||
|
msgstr "Cost per 1 million tokens (for cost overview in bulk):"
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:88
|
||||||
|
#: includes/Admin/views/meta.php:101
|
||||||
|
msgid "Save Settings"
|
||||||
|
msgstr "Save Settings"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
#: includes/Admin/views/bulk.php
|
||||||
|
#: includes/Admin/views/provider.php
|
||||||
|
#: includes/Admin/views/meta.php
|
||||||
|
#: includes/Admin/views/geo.php
|
||||||
|
#: includes/Admin/views/schema.php
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
#: includes/Admin/views/link-suggest-settings.php
|
||||||
|
msgid "developed by"
|
||||||
|
msgstr "developed by"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
#: includes/Admin/views/bulk.php
|
||||||
|
#: includes/Admin/views/provider.php
|
||||||
|
#: includes/Admin/views/meta.php
|
||||||
|
#: includes/Admin/views/geo.php
|
||||||
|
#: includes/Admin/views/schema.php
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
#: includes/Admin/views/link-suggest-settings.php
|
||||||
|
msgid "for"
|
||||||
|
msgstr "for"
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:7
|
||||||
|
msgid "Generates meta descriptions for posts without an existing meta description."
|
||||||
|
msgstr "Generates meta descriptions for posts without an existing meta description."
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:10
|
||||||
|
msgid "Loading statistics\xe2\x80\xa6"
|
||||||
|
msgstr "Loading statistics\xe2\x80\xa6"
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:46
|
||||||
|
msgid "Max. posts this run"
|
||||||
|
msgstr "Max. posts this run"
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:55
|
||||||
|
msgid "Start Bulk Run"
|
||||||
|
msgstr "Start Bulk Run"
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:56
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "Cancel"
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:64
|
||||||
|
msgid "0 / 0 processed"
|
||||||
|
msgstr "0 / 0 processed"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:3
|
||||||
|
msgid "llms.txt Configuration"
|
||||||
|
msgstr "llms.txt Configuration"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:7
|
||||||
|
msgid "llms.txt Cache leeren"
|
||||||
|
msgstr "Clear llms.txt cache"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:28
|
||||||
|
msgid "Your llms.txt will be served at:"
|
||||||
|
msgstr "Your llms.txt will be served at:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:33
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Active"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:35
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr "Inactive"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:44
|
||||||
|
msgid "Enable llms.txt"
|
||||||
|
msgstr "Enable llms.txt"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:49
|
||||||
|
msgid "Serve llms.txt at"
|
||||||
|
msgstr "Serve llms.txt at"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:56
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "Title"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:63
|
||||||
|
msgid "Appears as the # heading in llms.txt"
|
||||||
|
msgstr "Appears as the # heading in llms.txt"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:68
|
||||||
|
msgid "Description (before links)"
|
||||||
|
msgstr "Description (before links)"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:72
|
||||||
|
msgid "Text shown after the title, before featured links."
|
||||||
|
msgstr "Text shown after the title, before featured links."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:77
|
||||||
|
msgid "Featured Links"
|
||||||
|
msgstr "Featured Links"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:82
|
||||||
|
msgid "Important links to highlight for AI models. One per line."
|
||||||
|
msgstr "Important links to highlight for AI models. One per line."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:83
|
||||||
|
msgid "Markdown format recommended:"
|
||||||
|
msgstr "Markdown format recommended:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:90
|
||||||
|
#: includes/Admin/views/meta.php:25
|
||||||
|
msgid "Post Types"
|
||||||
|
msgstr "Post Types"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:101
|
||||||
|
msgid "Which post types to include in the content list."
|
||||||
|
msgstr "Which post types to include in the content list."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:106
|
||||||
|
msgid "Max. Links pro Seite"
|
||||||
|
msgstr "Max. links per page"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:112
|
||||||
|
msgid "Bei mehr Posts werden automatisch llms-2.txt, llms-3.txt etc. erstellt und verlinkt."
|
||||||
|
msgstr "For more posts, llms-2.txt, llms-3.txt etc. are automatically created and linked."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:118
|
||||||
|
msgid "Description (after content)"
|
||||||
|
msgstr "Description (after content)"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:126
|
||||||
|
msgid "Footer Description"
|
||||||
|
msgstr "Footer Description"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:130
|
||||||
|
msgid "Appears at the end of llms.txt after a --- separator."
|
||||||
|
msgstr "Appears at the end of llms.txt after a --- separator."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:135
|
||||||
|
msgid "Save llms.txt Settings"
|
||||||
|
msgstr "Save llms.txt Settings"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:139
|
||||||
|
msgid "Preview"
|
||||||
|
msgstr "Preview"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:141
|
||||||
|
msgid "After saving, visit your llms.txt to verify:"
|
||||||
|
msgstr "After saving, visit your llms.txt to verify:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php:147
|
||||||
|
msgid "Note: If the URL shows a 404, go to Settings \xe2\x86\x92 Permalinks and click Save to flush rewrite rules."
|
||||||
|
msgstr "Note: If the URL shows a 404, go to Settings \xe2\x86\x92 Permalinks and click Save to flush rewrite rules."
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:13
|
||||||
|
msgid "Auto Mode"
|
||||||
|
msgstr "Auto Mode"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:20
|
||||||
|
msgid "Automatically generate meta description on publish"
|
||||||
|
msgstr "Automatically generate meta description on publish"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:39
|
||||||
|
msgid "Token Mode"
|
||||||
|
msgstr "Token Mode"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:44
|
||||||
|
msgid "Send full article"
|
||||||
|
msgstr "Send full article"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:50
|
||||||
|
msgid "Limit to"
|
||||||
|
msgstr "Limit to"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:55
|
||||||
|
msgid "tokens"
|
||||||
|
msgstr "tokens"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:60
|
||||||
|
msgid "Prompt"
|
||||||
|
msgstr "Prompt"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:66
|
||||||
|
msgid "Variables:"
|
||||||
|
msgstr "Variables:"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:68
|
||||||
|
msgid "Reset prompt"
|
||||||
|
msgstr "Reset prompt"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:74
|
||||||
|
msgid "Schema.org Enhancer (GEO)"
|
||||||
|
msgstr "Schema.org Enhancer (GEO)"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:77
|
||||||
|
msgid "Enabled Schema Types"
|
||||||
|
msgstr "Enabled Schema Types"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:91
|
||||||
|
msgid "Organization sameAs URLs"
|
||||||
|
msgstr "Organization sameAs URLs"
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:93
|
||||||
|
msgid "One URL per line (Twitter, LinkedIn, GitHub, Facebook\xe2\x80\xa6)"
|
||||||
|
msgstr "One URL per line (Twitter, LinkedIn, GitHub, Facebook\xe2\x80\xa6)"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "Block known AI bots for this site."
|
||||||
|
msgstr "Block known AI bots for this site."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "Note: Bots are not required to comply."
|
||||||
|
msgstr "Note: Bots are not required to comply."
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "User-Agent"
|
||||||
|
msgstr "User-Agent"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Description"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "Block"
|
||||||
|
msgstr "Block"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "View current robots.txt \xe2\x86\x92"
|
||||||
|
msgstr "View current robots.txt \xe2\x86\x92"
|
||||||
|
|
||||||
|
#: includes/Admin/views/txt.php
|
||||||
|
msgid "Save robots.txt Settings"
|
||||||
|
msgstr "Save robots.txt Settings"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:20
|
||||||
|
msgid "Meta Description (BRE)"
|
||||||
|
msgstr "Meta Description (BRE)"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:34
|
||||||
|
msgid "KI generiert"
|
||||||
|
msgstr "AI generated"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:35
|
||||||
|
msgid "Fallback (erster Absatz)"
|
||||||
|
msgstr "Fallback (first paragraph)"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:36
|
||||||
|
msgid "Manuell bearbeitet"
|
||||||
|
msgstr "Manually edited"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:37
|
||||||
|
msgid "Noch nicht generiert"
|
||||||
|
msgstr "Not yet generated"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:67
|
||||||
|
msgid "Mit KI neu generieren"
|
||||||
|
msgstr "Regenerate with AI"
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:16
|
||||||
|
msgid "SEO Analyse (BRE)"
|
||||||
|
msgstr "SEO Analysis (BRE)"
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:31
|
||||||
|
msgid "Titel:"
|
||||||
|
msgstr "Title:"
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:37
|
||||||
|
msgid "W\xc3\xb6rter:"
|
||||||
|
msgstr "Words:"
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:41
|
||||||
|
msgid "Lesezeit:"
|
||||||
|
msgstr "Reading time:"
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:46
|
||||||
|
msgid "\xc3\x9cberschriften"
|
||||||
|
msgstr "Headings"
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:49
|
||||||
|
msgid "Links"
|
||||||
|
msgstr "Links"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:66
|
||||||
|
msgid "Organization (sameAs Social Profiles)"
|
||||||
|
msgstr "Organization (sameAs Social Profiles)"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:67
|
||||||
|
msgid "Author (sameAs Profile Links)"
|
||||||
|
msgstr "Author (sameAs Profile Links)"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:68
|
||||||
|
msgid "Speakable (for AI assistants)"
|
||||||
|
msgstr "Speakable (for AI assistants)"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:69
|
||||||
|
msgid "Article about/mentions"
|
||||||
|
msgstr "Article about/mentions"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:70
|
||||||
|
msgid "BreadcrumbList"
|
||||||
|
msgstr "BreadcrumbList"
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:71
|
||||||
|
msgid "AI-optimized Meta Tags (max-snippet etc.)"
|
||||||
|
msgstr "AI-optimized Meta Tags (max-snippet etc.)"
|
||||||
|
|
||||||
|
#: includes/Admin/ProviderPage.php:83
|
||||||
|
#: includes/Features/MetaGenerator.php:149
|
||||||
|
#: includes/Features/MetaGenerator.php:181
|
||||||
|
msgid "Insufficient permissions."
|
||||||
|
msgstr "Insufficient permissions."
|
||||||
|
|
||||||
|
#: includes/Admin/ProviderPage.php:89
|
||||||
|
msgid "No API key saved. Please save first."
|
||||||
|
msgstr "No API key saved. Please save first."
|
||||||
|
|
||||||
|
#: includes/Admin/ProviderPage.php:93
|
||||||
|
msgid "Unknown provider."
|
||||||
|
msgstr "Unknown provider."
|
||||||
|
|
||||||
|
#: includes/Admin/LlmsPage.php:52
|
||||||
|
msgid "Cache geleert."
|
||||||
|
msgstr "Cache cleared."
|
||||||
|
|
||||||
|
#: includes/Features/MetaGenerator.php:191
|
||||||
|
msgid "Ein Bulk-Prozess l\xc3\xa4uft bereits."
|
||||||
|
msgstr "A bulk process is already running."
|
||||||
|
|
||||||
|
#: includes/Features/LlmsTxt.php:31
|
||||||
|
msgid "Bavarian Rank Engine bedient llms.txt mit Priorit\xc3\xa4t \xe2\x80\x94 kein Handlungsbedarf bei Rank Math."
|
||||||
|
msgstr "Bavarian Rank Engine serves llms.txt with priority \xe2\x80\x94 no action needed for Rank Math."
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php
|
||||||
|
msgid "Link Suggestions"
|
||||||
|
msgstr "Link Suggestions"
|
||||||
|
|
||||||
|
#: includes/Admin/views/link-suggest-settings.php
|
||||||
|
msgid "Analysis Trigger"
|
||||||
|
msgstr "Analysis Trigger"
|
||||||
|
|
||||||
|
msgid "When to analyse"
|
||||||
|
msgstr "When to analyse"
|
||||||
|
|
||||||
|
msgid "Manual only (button)"
|
||||||
|
msgstr "Manual only (button)"
|
||||||
|
|
||||||
|
msgid "On post save"
|
||||||
|
msgstr "On post save"
|
||||||
|
|
||||||
|
msgid "Every"
|
||||||
|
msgstr "Every"
|
||||||
|
|
||||||
|
msgid "minutes"
|
||||||
|
msgstr "minutes"
|
||||||
|
|
||||||
|
msgid "Exclude Posts / Pages"
|
||||||
|
msgstr "Exclude Posts / Pages"
|
||||||
|
|
||||||
|
msgid "These posts will never appear as link suggestions (e.g. Imprint, Contact, Terms)."
|
||||||
|
msgstr "These posts will never appear as link suggestions (e.g. Imprint, Contact, Terms)."
|
||||||
|
|
||||||
|
msgid "Excluded"
|
||||||
|
msgstr "Excluded"
|
||||||
|
|
||||||
|
msgid "Search posts…"
|
||||||
|
msgstr "Search posts…"
|
||||||
|
|
||||||
|
msgid "Remove"
|
||||||
|
msgstr "Remove"
|
||||||
|
|
||||||
|
msgid "Prioritise Posts / Pages"
|
||||||
|
msgstr "Prioritise Posts / Pages"
|
||||||
|
|
||||||
|
msgid "Boosted posts rank higher when thematically relevant. A boost of 1.0 = no change."
|
||||||
|
msgstr "Boosted posts rank higher when thematically relevant. A boost of 1.0 = no change."
|
||||||
|
|
||||||
|
msgid "Boosted"
|
||||||
|
msgstr "Boosted"
|
||||||
|
|
||||||
|
msgid "Boost:"
|
||||||
|
msgstr "Boost:"
|
||||||
|
|
||||||
|
msgid "AI Options (optional)"
|
||||||
|
msgstr "AI Options (optional)"
|
||||||
|
|
||||||
|
msgid "AI is connected — these settings control how many candidates are sent for semantic analysis."
|
||||||
|
msgstr "AI is connected — these settings control how many candidates are sent for semantic analysis."
|
||||||
|
|
||||||
|
msgid "Candidates to AI"
|
||||||
|
msgstr "Candidates to AI"
|
||||||
|
|
||||||
|
msgid "How many pre-scored candidates are passed to the AI (max 50)."
|
||||||
|
msgstr "How many pre-scored candidates are passed to the AI (max 50)."
|
||||||
|
|
||||||
|
msgid "Max output tokens"
|
||||||
|
msgstr "Max output tokens"
|
||||||
|
|
||||||
|
#: includes/Admin/views/link-suggest-box.php
|
||||||
|
msgid "Click Analyse to find internal link opportunities."
|
||||||
|
msgstr "Click Analyse to find internal link opportunities."
|
||||||
|
|
||||||
|
msgid "Analyse"
|
||||||
|
msgstr "Analyse"
|
||||||
|
|
||||||
|
msgid "All"
|
||||||
|
msgstr "All"
|
||||||
|
|
||||||
|
msgid "None"
|
||||||
|
msgstr "None"
|
||||||
|
|
||||||
|
msgid "Apply (0 links)"
|
||||||
|
msgstr "Apply (0 links)"
|
||||||
|
|
||||||
|
#: includes/Features/LinkSuggest.php
|
||||||
|
msgid "Internal Link Suggestions (BRE)"
|
||||||
|
msgstr "Internal Link Suggestions (BRE)"
|
||||||
|
|
||||||
|
msgid "Analysing…"
|
||||||
|
msgstr "Analysing…"
|
||||||
|
|
||||||
|
msgid "No suggestions found."
|
||||||
|
msgstr "No suggestions found."
|
||||||
|
|
||||||
|
msgid "Apply (%d links)"
|
||||||
|
msgstr "Apply (%d links)"
|
||||||
|
|
||||||
|
msgid "Confirm"
|
||||||
|
msgstr "Confirm"
|
||||||
|
|
||||||
|
msgid "Applied — %d links set ✓"
|
||||||
|
msgstr "Applied — %d links set ✓"
|
||||||
|
|
||||||
|
msgid "Prioritised"
|
||||||
|
msgstr "Prioritised"
|
||||||
|
|
||||||
|
msgid "Open post"
|
||||||
|
msgstr "Open post"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "AI Features"
|
||||||
|
msgstr "AI Features"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Settings saved."
|
||||||
|
msgstr "Settings saved."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Choose which features may use your connected AI provider. All options are opt-in and disabled by default."
|
||||||
|
msgstr "Choose which features may use your connected AI provider. All options are opt-in and disabled by default."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Meta Descriptions"
|
||||||
|
msgstr "Meta Descriptions"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Generate meta descriptions with AI when editing or using the Bulk Generator."
|
||||||
|
msgstr "Generate meta descriptions with AI when editing or using the Bulk Generator."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Internal Link Suggestions"
|
||||||
|
msgstr "Internal Link Suggestions"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Let AI pick the most natural anchor phrases and rank candidates semantically."
|
||||||
|
msgstr "Let AI pick the most natural anchor phrases and rank candidates semantically."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "GEO Block"
|
||||||
|
msgstr "GEO Block"
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Use AI to generate GEO-optimised content blocks for LLM visibility."
|
||||||
|
msgstr "Use AI to generate GEO-optimised content blocks for LLM visibility."
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php
|
||||||
|
msgid "Save"
|
||||||
|
msgstr "Save"
|
||||||
|
|
||||||
|
msgid "Network error"
|
||||||
|
msgstr "Network error"
|
||||||
613
brezngeo/languages/brezngeo.pot
Normal file
613
brezngeo/languages/brezngeo.pot
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
# Copyright (C) 2025 Donau2Space
|
||||||
|
# This file is distributed under the GPL-2.0-or-later license.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Bavarian Rank Engine 1.0.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: https://donau2space.de\n"
|
||||||
|
"POT-Creation-Date: 2026-02-21T00:00:00+00:00\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"PO-Revision-Date: 2026-MO-DA HO:MI+ZONE\n"
|
||||||
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
|
"X-Generator: Manual\n"
|
||||||
|
"X-Domain: bavarian-rank-engine\n"
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:11
|
||||||
|
msgid "Bavarian Rank Engine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:12
|
||||||
|
msgid "Bavarian Rank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:23
|
||||||
|
#: includes/Admin/AdminMenu.php:24
|
||||||
|
msgid "Dashboard"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:31
|
||||||
|
#: includes/Admin/AdminMenu.php:32
|
||||||
|
#: includes/Admin/views/provider.php:3
|
||||||
|
#: includes/Admin/views/provider.php:10
|
||||||
|
msgid "AI Provider"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:41
|
||||||
|
#: includes/Admin/AdminMenu.php:42
|
||||||
|
#: includes/Admin/views/meta.php:3
|
||||||
|
#: includes/Admin/views/meta.php:10
|
||||||
|
msgid "Meta Generator"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:59
|
||||||
|
#: includes/Admin/AdminMenu.php:60
|
||||||
|
#: includes/Admin/views/bulk.php:3
|
||||||
|
msgid "Bulk Generator"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:67
|
||||||
|
msgid "robots.txt / AI Bots"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php:69
|
||||||
|
msgid "robots.txt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:3
|
||||||
|
msgid "Bavarian Rank Engine \xe2\x80\x94 Dashboard"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:8
|
||||||
|
msgid "Meta Coverage"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:11
|
||||||
|
msgid "No post types configured."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:15
|
||||||
|
msgid "Post Type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:16
|
||||||
|
msgid "Published"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:17
|
||||||
|
msgid "With Meta"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:18
|
||||||
|
msgid "Coverage"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:36
|
||||||
|
msgid "Quick Links"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:39
|
||||||
|
msgid "AI Provider Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:40
|
||||||
|
msgid "Meta Generator Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:42
|
||||||
|
msgid "Bulk Generator"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:48
|
||||||
|
msgid "Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:50
|
||||||
|
msgid "Version:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:51
|
||||||
|
msgid "Active Provider:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:56
|
||||||
|
msgid "Interne Link-Analyse"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:58
|
||||||
|
msgid "Wird geladen\xe2\x80\xa6"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:63
|
||||||
|
msgid "AI Crawler \xe2\x80\x94 letzte 30 Tage"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:67
|
||||||
|
msgid "Noch keine AI-Crawls aufgezeichnet."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:71
|
||||||
|
msgid "Bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:72
|
||||||
|
msgid "Besuche"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/dashboard.php:73
|
||||||
|
msgid "Zuletzt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:13
|
||||||
|
#: includes/Admin/views/bulk.php:15
|
||||||
|
msgid "Active Provider"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:27
|
||||||
|
msgid "API Key"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:31
|
||||||
|
msgid "Saved:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:37
|
||||||
|
msgid "Enter new key to overwrite"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:37
|
||||||
|
msgid "Enter API key"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:41
|
||||||
|
msgid "Test connection"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:45
|
||||||
|
#: includes/Admin/views/bulk.php:28
|
||||||
|
msgid "Model:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:63
|
||||||
|
msgid "Aktuelle Preise ansehen \xe2\x86\x92"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:67
|
||||||
|
msgid "Kosten pro 1 Million Token (f\xc3\xbcr Kostens\xc3\xbcbersicht im Bulk):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:88
|
||||||
|
#: includes/Admin/views/meta.php:101
|
||||||
|
msgid "Save Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/provider.php:94
|
||||||
|
#: includes/Admin/views/meta.php:107
|
||||||
|
msgid "developed with"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:7
|
||||||
|
msgid "Generates meta descriptions for posts without an existing meta description."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:10
|
||||||
|
msgid "Loading statistics\xe2\x80\xa6"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:46
|
||||||
|
msgid "Max. posts this run"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:55
|
||||||
|
msgid "Start Bulk Run"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:56
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/bulk.php:64
|
||||||
|
msgid "0 / 0 processed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:3
|
||||||
|
msgid "llms.txt Configuration"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:7
|
||||||
|
msgid "llms.txt Cache leeren"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:28
|
||||||
|
msgid "Your llms.txt will be served at:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:33
|
||||||
|
msgid "Active"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:35
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:44
|
||||||
|
msgid "Enable llms.txt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:49
|
||||||
|
msgid "Serve llms.txt at"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:56
|
||||||
|
msgid "Title"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:63
|
||||||
|
msgid "Appears as the # heading in llms.txt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:68
|
||||||
|
msgid "Description (before links)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:72
|
||||||
|
msgid "Text shown after the title, before featured links."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:77
|
||||||
|
msgid "Featured Links"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:82
|
||||||
|
msgid "Important links to highlight for AI models. One per line."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:83
|
||||||
|
msgid "Markdown format recommended:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:90
|
||||||
|
#: includes/Admin/views/meta.php:25
|
||||||
|
msgid "Post Types"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:101
|
||||||
|
msgid "Which post types to include in the content list."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:106
|
||||||
|
msgid "Max. Links pro Seite"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:112
|
||||||
|
msgid "Bei mehr Posts werden automatisch llms-2.txt, llms-3.txt etc. erstellt und verlinkt."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:118
|
||||||
|
msgid "Description (after content)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:126
|
||||||
|
msgid "Footer Description"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:130
|
||||||
|
msgid "Appears at the end of llms.txt after a --- separator."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:135
|
||||||
|
msgid "Save llms.txt Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:139
|
||||||
|
msgid "Preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:141
|
||||||
|
msgid "After saving, visit your llms.txt to verify:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/llms.php:147
|
||||||
|
msgid "Note: If the URL shows a 404, go to Settings \xe2\x86\x92 Permalinks and click Save to flush rewrite rules."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:13
|
||||||
|
msgid "Auto Mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:20
|
||||||
|
msgid "Automatically generate meta description on publish"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:39
|
||||||
|
msgid "Token Mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:44
|
||||||
|
msgid "Send full article"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:50
|
||||||
|
msgid "Limit to"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:55
|
||||||
|
msgid "tokens"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:60
|
||||||
|
msgid "Prompt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:66
|
||||||
|
msgid "Variables:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:68
|
||||||
|
msgid "Reset prompt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:74
|
||||||
|
msgid "Schema.org Enhancer (GEO)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:77
|
||||||
|
msgid "Enabled Schema Types"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:91
|
||||||
|
msgid "Organization sameAs URLs"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/meta.php:93
|
||||||
|
msgid "One URL per line (Twitter, LinkedIn, GitHub, Facebook\xe2\x80\xa6)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:3
|
||||||
|
msgid "robots.txt \xe2\x80\x94 AI Bots"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:5
|
||||||
|
msgid "Bekannte AI-Bots f\xc3\xbcr diese Website blockieren."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:6
|
||||||
|
msgid "Hinweis: Bots m\xc3\xbcssen sich nicht daran halten."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:15
|
||||||
|
msgid "User-Agent"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:16
|
||||||
|
msgid "Beschreibung"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:17
|
||||||
|
msgid "Blockieren"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:36
|
||||||
|
msgid "Einstellungen speichern"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/robots.php:41
|
||||||
|
msgid "Aktuelle robots.txt ansehen \xe2\x86\x92"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:20
|
||||||
|
msgid "Meta Description (BRE)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:34
|
||||||
|
msgid "KI generiert"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:35
|
||||||
|
msgid "Fallback (erster Absatz)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:36
|
||||||
|
msgid "Manuell bearbeitet"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:37
|
||||||
|
msgid "Noch nicht generiert"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaEditorBox.php:67
|
||||||
|
msgid "Mit KI neu generieren"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:16
|
||||||
|
msgid "SEO Analyse (BRE)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:31
|
||||||
|
msgid "Titel:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:37
|
||||||
|
msgid "W\xc3\xb6rter:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:41
|
||||||
|
msgid "Lesezeit:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:46
|
||||||
|
msgid "\xc3\x9cberschriften"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/SeoWidget.php:49
|
||||||
|
msgid "Links"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:66
|
||||||
|
msgid "Organization (sameAs Social Profiles)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:67
|
||||||
|
msgid "Author (sameAs Profile Links)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:68
|
||||||
|
msgid "Speakable (for AI assistants)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:69
|
||||||
|
msgid "Article about/mentions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:70
|
||||||
|
msgid "BreadcrumbList"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/MetaPage.php:71
|
||||||
|
msgid "AI-optimized Meta Tags (max-snippet etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/ProviderPage.php:83
|
||||||
|
#: includes/Features/MetaGenerator.php:149
|
||||||
|
#: includes/Features/MetaGenerator.php:181
|
||||||
|
msgid "Insufficient permissions."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/ProviderPage.php:89
|
||||||
|
msgid "No API key saved. Please save first."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/ProviderPage.php:93
|
||||||
|
msgid "Unknown provider."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/LlmsPage.php:52
|
||||||
|
msgid "Cache geleert."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Features/MetaGenerator.php:191
|
||||||
|
msgid "Ein Bulk-Prozess l\xc3\xa4uft bereits."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Features/LlmsTxt.php:31
|
||||||
|
msgid "Bavarian Rank Engine bedient llms.txt mit Priorit\xc3\xa4t \xe2\x80\x94 kein Handlungsbedarf bei Rank Math."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/AdminMenu.php
|
||||||
|
msgid "Link Suggestions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/link-suggest-settings.php
|
||||||
|
msgid "Analysis Trigger"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "When to analyse"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Manual only (button)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "On post save"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Every"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "minutes"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Exclude Posts / Pages"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "These posts will never appear as link suggestions (e.g. Imprint, Contact, Terms)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Excluded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search posts…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Remove"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prioritise Posts / Pages"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Boosted posts rank higher when thematically relevant. A boost of 1.0 = no change."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Boosted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Boost:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "AI Options (optional)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "AI is connected — these settings control how many candidates are sent for semantic analysis."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Candidates to AI"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "How many pre-scored candidates are passed to the AI (max 50)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Max output tokens"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Save Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Admin/views/link-suggest-box.php
|
||||||
|
msgid "Click Analyse to find internal link opportunities."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Analyse"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "All"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "None"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Apply (0 links)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: includes/Features/LinkSuggest.php
|
||||||
|
msgid "Internal Link Suggestions (BRE)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Analysing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No suggestions found."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Apply (%d links)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Confirm"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Applied — %d links set ✓"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prioritised"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Open post"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Network error"
|
||||||
|
msgstr ""
|
||||||
323
brezngeo/readme.txt
Normal file
323
brezngeo/readme.txt
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
=== Bavarian Rank Engine ===
|
||||||
|
Contributors: mifupadev
|
||||||
|
Tags: seo, ai, meta description, schema, llms.txt
|
||||||
|
Requires at least: 6.0
|
||||||
|
Tested up to: 6.9
|
||||||
|
Stable tag: 1.3.5
|
||||||
|
Requires PHP: 8.0
|
||||||
|
License: GPL-2.0-or-later
|
||||||
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
|
AI meta descriptions, GEO blocks, internal link suggestions, Schema.org structured data, and llms.txt for WordPress. No subscription.
|
||||||
|
|
||||||
|
== Description ==
|
||||||
|
|
||||||
|
Bavarian Rank Engine is a WordPress SEO plugin that automates AI meta descriptions, adds Schema.org structured data, and helps your content get discovered by AI-driven search. It covers GEO — Generative Engine Optimization — preparing your pages for AI overviews, answer engines, and retrieval-augmented search alongside traditional search engine optimization. No subscription required.
|
||||||
|
|
||||||
|
The plugin was originally built for Donau2Space.de and has run on the developer's own production sites since version 1.0. Stability is the priority: when something breaks, it affects the developer first. There are no upsells, no subscription tiers, and no features added just to expand the feature list. It works as a focused complement to your existing SEO setup — not a replacement.
|
||||||
|
|
||||||
|
All AI features are optional. Without an API key, the plugin falls back to local logic and keeps running normally — meta descriptions are extracted from post content, internal link suggestions use text-based matching, and all Schema.org, llms.txt, and robots.txt features work without any external service.
|
||||||
|
|
||||||
|
= Learn more =
|
||||||
|
|
||||||
|
* Website: https://bavarianrankengine.com/
|
||||||
|
* FAQ: https://bavarianrankengine.com/faq.html
|
||||||
|
* Live demo: https://bavarianrankengine.com/demo.html
|
||||||
|
|
||||||
|
= At a glance =
|
||||||
|
|
||||||
|
* Generates AI meta descriptions automatically on publish — falls back to clean local extraction without any API key
|
||||||
|
* Adds a GEO Quick Overview block to each post: AI-generated summary, key bullet points, optional FAQ
|
||||||
|
* Suggests internal links while writing — text-based matching works without AI; optional AI upgrade for semantic ranking
|
||||||
|
* Bulk-generates descriptions for all existing posts that have none
|
||||||
|
* Adds Schema.org JSON-LD structured data for search engines and AI retrieval systems
|
||||||
|
* Serves `/llms.txt` — a machine-readable content index for AI discovery tools
|
||||||
|
* Manages AI crawler access per-bot via the robots.txt manager, directly from the admin
|
||||||
|
* Logs AI bot visits with hashed IPs — no plain text stored
|
||||||
|
* Free. No subscription. API costs go directly to your provider.
|
||||||
|
|
||||||
|
= What makes Bavarian Rank Engine different =
|
||||||
|
|
||||||
|
* **AI is optional.** No API key means no AI and no costs. All non-AI features — Schema.org, llms.txt, internal link suggestions, and fallback meta extraction — continue to work normally.
|
||||||
|
* **No subscription.** The plugin is free. If you use AI generation, costs go directly to your chosen provider. There is no service fee or middle layer.
|
||||||
|
* **Works alongside existing SEO plugins.** When another SEO plugin is active, generated descriptions are written into that plugin's own meta field — no duplication, no conflicts.
|
||||||
|
* **Built for real sites.** It has been running on the developer's own production sites since version 1.0 — shipped only after being tested under real conditions.
|
||||||
|
* **No vendor lock-in.** Switch AI providers at any time without losing settings. The plugin works independently of any specific AI provider.
|
||||||
|
|
||||||
|
= AI Meta Generator =
|
||||||
|
|
||||||
|
Generates a 150–160 character meta description the moment a post is published. The prompt is fully customizable using `{title}`, `{content}`, `{excerpt}`, and `{language}` placeholders. Language is detected automatically from Polylang, WPML, or the WordPress site locale.
|
||||||
|
|
||||||
|
If no API key is configured or the AI request fails, a clean fallback excerpt is extracted from the post content — no description is left empty.
|
||||||
|
|
||||||
|
= GEO Block =
|
||||||
|
|
||||||
|
Adds an AI-generated Quick Overview block to each post: a short summary, key bullet points, and an optional FAQ. Rendered as a native `<details>` element — configurable as collapsible (default), always open, or stored without frontend output.
|
||||||
|
|
||||||
|
Supports three generation modes: automatic on publish, hybrid (auto only when fields are empty), or manual. Insertion position is configurable: after the first paragraph (default), top, or bottom. A quality gate suppresses FAQ generation on posts below a configurable word-count threshold. The post editor meta box includes live generate and clear buttons, a per-post enable toggle, and an optional prompt add-on field for author-level customization. Four built-in themes: Light, Dark, Minimal, Bavarian.
|
||||||
|
|
||||||
|
= Internal Link Suggestions =
|
||||||
|
|
||||||
|
An editor meta box that surfaces relevant internal links while you write. Each suggestion is a phrase–target pair: a phrase found in your content, paired with an existing post that covers the same topic.
|
||||||
|
|
||||||
|
Text-based matching (title, tag, category, and excerpt overlap) works without AI. An optional AI upgrade sends the top 20 candidates to your connected provider for semantic ranking. Trigger options: manual button, on post save, or a timed interval. A settings page lets you exclude posts (such as legal pages) and boost specific pillar pages. Supported in both Gutenberg and Classic Editor.
|
||||||
|
|
||||||
|
= Bulk Generator =
|
||||||
|
|
||||||
|
Finds all published posts without a meta description (including descriptions set by Rank Math, Yoast, AIOSEO, or SEOPress) and generates them in configurable batches with rate-limiting between batches. A live progress log and per-batch cost estimate are shown during the run.
|
||||||
|
|
||||||
|
= Multi-Provider AI Support =
|
||||||
|
|
||||||
|
Choose from four AI providers and switch at any time without losing your settings:
|
||||||
|
|
||||||
|
* OpenAI (GPT-4.1, GPT-4o, GPT-4o mini, and more)
|
||||||
|
* Anthropic Claude (Claude 3.5 Sonnet, Claude 3 Haiku, and more)
|
||||||
|
* Google Gemini (Gemini 2.0 Flash, Gemini 1.5 Pro, and more)
|
||||||
|
* xAI Grok (Grok 3, Grok 3 mini, and more)
|
||||||
|
|
||||||
|
= Schema.org Enhancer (GEO) =
|
||||||
|
|
||||||
|
Injects JSON-LD structured data for search engines and AI retrieval systems:
|
||||||
|
|
||||||
|
* Organization — site name, URL, logo, and social `sameAs` links
|
||||||
|
* Article — headline, dates, description, and publisher
|
||||||
|
* Author — person name, author URL, Twitter link
|
||||||
|
* Speakable — marks up your H1 and first paragraph for voice and AI assistants
|
||||||
|
* BreadcrumbList — skipped automatically when Rank Math or Yoast is active
|
||||||
|
* AI Meta Tags — `max-snippet:-1, max-image-preview:large, max-video-preview:-1` directives
|
||||||
|
|
||||||
|
= llms.txt =
|
||||||
|
|
||||||
|
Serves a machine-readable index of your published content at `/llms.txt`, following the emerging llms.txt convention increasingly supported by AI indexing tools. Supports custom title, description sections, featured resource links, pagination for large sites, and HTTP ETag / Last-Modified caching.
|
||||||
|
|
||||||
|
= robots.txt Manager =
|
||||||
|
|
||||||
|
Block individual AI training and data-harvesting bots directly from the WordPress admin — no manual file editing. Supports 13 known bots: GPTBot, ClaudeBot, Google-Extended, PerplexityBot, CCBot, Applebot-Extended, Bytespider, DataForSeoBot, ImagesiftBot, Omgili, Diffbot, FacebookBot, and Amazonbot.
|
||||||
|
|
||||||
|
= Crawler Log =
|
||||||
|
|
||||||
|
Automatically logs visits from known AI bots. Stores the bot name, a SHA-256-hashed IP address, and the requested URL. Entries older than 90 days are purged automatically. A 30-day summary is shown on the plugin dashboard.
|
||||||
|
|
||||||
|
= Post Editor Integration =
|
||||||
|
|
||||||
|
A "Meta Description" meta box in the post and page editor shows the current description, its source (AI / Fallback / Manual), a live character counter, and a "Regenerate with AI" button. A sidebar SEO widget displays word count, reading time, heading structure, and link counts with live warnings.
|
||||||
|
|
||||||
|
= Link Analysis Dashboard =
|
||||||
|
|
||||||
|
Identifies posts without internal links, posts with an unusually high external-link count, and your top pillar pages by inbound internal link count — loaded on demand with a one-hour cache.
|
||||||
|
|
||||||
|
= Secure API Key Storage =
|
||||||
|
|
||||||
|
API keys are obfuscated using XOR with a key derived from your WordPress authentication salts before being written to the database. Keys never appear in plain text in database dumps or export files. No OpenSSL extension required.
|
||||||
|
|
||||||
|
= Compatibility =
|
||||||
|
|
||||||
|
Works standalone or alongside any major SEO plugin. When Rank Math, Yoast SEO, AIOSEO, or SEOPress is active, generated descriptions are written to that plugin's own meta field. Existing descriptions set by those plugins are always respected and never overwritten.
|
||||||
|
|
||||||
|
== Installation ==
|
||||||
|
|
||||||
|
1. Download the plugin zip and go to **Plugins → Add New → Upload Plugin** in your WordPress admin.
|
||||||
|
2. Upload the zip file and click **Install Now**, then **Activate**.
|
||||||
|
3. Go to **Bavarian Rank → AI Provider**.
|
||||||
|
4. Select your preferred AI provider, paste your API key, and click **Test connection**.
|
||||||
|
5. Choose a model and optionally enter token costs for cost estimation.
|
||||||
|
6. Go to **Bavarian Rank → Meta Generator** to select post types and configure Schema.org types.
|
||||||
|
7. To serve a content index, go to **Bavarian Rank → llms.txt**, enable it, and save.
|
||||||
|
8. To manage AI crawler access, go to **Bavarian Rank → robots.txt** and select the bots to block.
|
||||||
|
|
||||||
|
The plugin works without an API key — fallback meta extraction runs automatically on publish.
|
||||||
|
|
||||||
|
== Frequently Asked Questions ==
|
||||||
|
|
||||||
|
= Do I need an API key? =
|
||||||
|
|
||||||
|
An API key is required for AI-generated meta descriptions. Without one, the plugin automatically falls back to extracting a clean 150–160 character excerpt from the post content. All other features (Schema.org, llms.txt, robots.txt manager, crawler log) work without an API key.
|
||||||
|
|
||||||
|
= How much does it cost to generate meta descriptions? =
|
||||||
|
|
||||||
|
Cost depends on the AI provider and model you choose. A single meta description typically uses fewer than 1,500 tokens (input + output combined). As a rough reference, 1,000 descriptions with GPT-4o mini has cost around $0.50–$1.00 at recent rates — but AI provider pricing changes over time. The AI Provider settings page links directly to the current pricing page for each supported provider.
|
||||||
|
|
||||||
|
= Are my API keys stored securely? =
|
||||||
|
|
||||||
|
Keys are obfuscated using XOR encryption with a key derived from your WordPress authentication salts before being written to the database. They do not appear in plain text in database dumps or export files. For the highest level of protection, define your API keys as constants in `wp-config.php` — the plugin will use them automatically and nothing is stored in the database.
|
||||||
|
|
||||||
|
= What is llms.txt? =
|
||||||
|
|
||||||
|
`llms.txt` is an emerging convention (similar in spirit to `robots.txt` or `sitemap.xml`) that gives AI language models and retrieval-augmented generation (RAG) tools a structured, machine-readable index of a site's content. Support varies by tool and is still evolving. The plugin serves it at `yourdomain.com/llms.txt` with proper HTTP caching headers.
|
||||||
|
|
||||||
|
= Is this compatible with Rank Math / Yoast SEO / AIOSEO / SEOPress? =
|
||||||
|
|
||||||
|
Yes. When one of these plugins is active, Bavarian Rank Engine writes generated descriptions directly into that plugin's meta field. It also checks for existing descriptions from all four plugins before generating, and skips posts that already have one. Breadcrumb and standalone meta description output is suppressed automatically to avoid conflicts.
|
||||||
|
|
||||||
|
= Does it work with Polylang or WPML? =
|
||||||
|
|
||||||
|
Yes. The meta generator detects the post language from Polylang (`pll_get_post_language()`), WPML (`ICL_LANGUAGE_CODE`), or the WordPress site locale, and includes it in the prompt so the AI writes in the correct language.
|
||||||
|
|
||||||
|
= How does the Bulk Generator handle rate limits? =
|
||||||
|
|
||||||
|
Posts are processed in configurable batches (1–20 per batch) with a 6-second pause between batches. Each post is retried up to three times with a 1-second delay between attempts. A transient-based lock prevents simultaneous runs. The lock expires automatically after 15 minutes and can also be released manually from the Bulk Generator page.
|
||||||
|
|
||||||
|
= Does the Crawler Log store personal data? =
|
||||||
|
|
||||||
|
No. IP addresses are hashed with SHA-256 before storage — the original IP is never saved. Entries are purged automatically after 90 days.
|
||||||
|
|
||||||
|
= Will it slow down my site? =
|
||||||
|
|
||||||
|
No front-end overhead beyond the inline JSON-LD and optional meta tags in `wp_head`. The llms.txt response is cached via WordPress transients and served with HTTP 304 when the ETag matches. No external HTTP requests are made during normal page loads — AI API calls only happen on post publish or when explicitly triggered from the admin.
|
||||||
|
|
||||||
|
= Can I add a custom AI provider? =
|
||||||
|
|
||||||
|
Yes. Implement the `BreznGEO\Providers\ProviderInterface` interface (five methods: `getId`, `getName`, `getModels`, `testConnection`, `generateText`), place the class in `includes/Providers/`, and register it in `Core::register_hooks()`. It will appear in all admin dropdowns automatically.
|
||||||
|
|
||||||
|
== Screenshots ==
|
||||||
|
|
||||||
|
1. Dashboard — provider status, meta coverage stats, crawler log summary.
|
||||||
|
2. AI Provider settings — provider selector, API key entry, connection test, model picker, cost configuration.
|
||||||
|
3. Meta Generator settings — post type selection, token limit, prompt editor, Schema.org toggles.
|
||||||
|
4. Bulk Generator — batch controls, live progress log, cost estimate.
|
||||||
|
5. llms.txt configuration — enable toggle, custom sections, post types, pagination settings.
|
||||||
|
6. robots.txt / AI Bots — per-bot block checkboxes.
|
||||||
|
7. Post editor — Meta Description meta box with source badge and AI regeneration button.
|
||||||
|
8. Post editor — SEO Analysis sidebar widget with live stats and warnings.
|
||||||
|
|
||||||
|
== External Services ==
|
||||||
|
|
||||||
|
This plugin can optionally connect to external AI services. All AI features are opt-in and disabled by default. No data is transmitted unless the user has explicitly enabled AI generation and configured an API key.
|
||||||
|
|
||||||
|
The following features may send data to the selected AI provider:
|
||||||
|
|
||||||
|
* **Meta Descriptions** — post title and content excerpt are sent to generate a meta description. Triggered on publish, on update, or via the Bulk Generator.
|
||||||
|
* **GEO Block** — post title and content are sent to generate a Quick Overview block (summary, key points, optional FAQ). Triggered on publish/update or manually from the post editor.
|
||||||
|
* **Internal Link Suggestions (AI upgrade)** — up to 20 pre-scored candidate link pairs (post titles and URLs) are sent for semantic ranking. Triggered manually, on save, or on a timed interval — all configurable by the user.
|
||||||
|
|
||||||
|
No data is transmitted during normal page loads or to visitors.
|
||||||
|
|
||||||
|
= OpenAI =
|
||||||
|
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||||
|
* API endpoint: `https://api.openai.com/v1/`
|
||||||
|
* Privacy policy: https://openai.com/policies/privacy-policy/
|
||||||
|
* Terms of use: https://openai.com/policies/terms-of-use/
|
||||||
|
|
||||||
|
= Anthropic Claude =
|
||||||
|
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||||
|
* API endpoint: `https://api.anthropic.com/`
|
||||||
|
* Privacy policy: https://www.anthropic.com/privacy
|
||||||
|
* Terms of use: https://www.anthropic.com/legal/consumer-terms
|
||||||
|
|
||||||
|
= Google Gemini =
|
||||||
|
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||||
|
* API endpoint: `https://generativelanguage.googleapis.com/`
|
||||||
|
* Privacy policy: https://policies.google.com/privacy
|
||||||
|
* Terms of use: https://ai.google.dev/gemini-api/terms
|
||||||
|
|
||||||
|
= xAI Grok =
|
||||||
|
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||||
|
* API endpoint: `https://api.x.ai/`
|
||||||
|
* Privacy policy: https://x.ai/privacy-policy
|
||||||
|
* Terms of use: https://x.ai/legal/terms-of-service
|
||||||
|
|
||||||
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.3.5 =
|
||||||
|
* Changed: Admin footer updated across all plugin pages — "developed by 🍺 noschmarrn.dev for Donau2Space.de" with links to both sites
|
||||||
|
* Fix: German translations added for the AI Features dashboard section — heading, intro text, feature descriptions, and submit button were displayed in English on German WordPress installations
|
||||||
|
* Docs: readme.txt restructured for WordPress.org — paragraph order corrected, "At a glance" promoted to proper section heading, tag list trimmed to five
|
||||||
|
|
||||||
|
= 1.3.4 =
|
||||||
|
* New: Four built-in GEO block themes — Light, Dark, Minimal, Bavarian
|
||||||
|
* Changed: Replaced Color Scheme / Load Minimal CSS controls with a single Theme selector
|
||||||
|
* Removed: Custom CSS field (use your theme's stylesheet for custom styling)
|
||||||
|
* Removed: Auto OS dark/light detection (select Dark theme explicitly for dark-mode sites)
|
||||||
|
|
||||||
|
= 1.3.3 =
|
||||||
|
* Security: GeoBlock custom CSS is now sanitised through a dedicated parser — strips comments, blocks at-rules (@import, @media etc.), expression(), javascript: and url() to prevent CSS injection
|
||||||
|
* Fix: GeoBlock inline style previously used esc_attr() on CSS content (corrupts quotes/ampersands); replaced with wp_strip_all_tags()-based sanitiser
|
||||||
|
* Fix: Uniform brezngeo_ prefix applied throughout — JS localized objects, test bootstrap constants, and a KeyVault doc comment updated
|
||||||
|
* Fix: SchemaEnhancer JSON-LD output no longer passes JSON_UNESCAPED_SLASHES to wp_json_encode()
|
||||||
|
* Fix: GeoBlock settings help text now explicitly describes the CSS input policy (declarations only, url() blocked)
|
||||||
|
|
||||||
|
= 1.3.2 =
|
||||||
|
* Fix: Schema.org structured data now correctly reads the AI-generated meta description — it was silently falling back to the post excerpt due to a renamed post meta key
|
||||||
|
* Fix: Dashboard usage-stats transient key typo corrected (no functional impact for most users)
|
||||||
|
* Internal: All plugin identifiers (option keys, AJAX actions, post meta, transients) renamed to brezngeo_ prefix for WordPress.org compliance
|
||||||
|
|
||||||
|
= 1.3.1 =
|
||||||
|
* Improved: Internal Link Suggestions now match by topic (title + tags + categories + excerpt) — anchor phrases are found even when the target post title does not appear verbatim in the content
|
||||||
|
* New: Post excerpt included in candidate scoring (weight ×1.5) for better semantic relevance
|
||||||
|
* New: Dashboard "AI Features" card — opt-in AI toggle per feature (Meta Descriptions, Link Suggestions, GEO Block); only visible when an AI provider is connected; all options disabled by default
|
||||||
|
* Fix: Post search in Link Suggestions settings (Exclude / Boost) was broken — wrong REST API URL and missing script on settings page
|
||||||
|
* Fix: Plugin Check — translators comment position in bulk.php, NonPrefixedVariable warnings in link-suggest-settings.php and txt.php
|
||||||
|
|
||||||
|
= 1.3.0 =
|
||||||
|
* New: Internal Link Suggestions — editor meta box suggests "phrase → target post" links while writing; manual review + multi-select apply with preview modal
|
||||||
|
* New: Suggestions use text-based matching (title/tag/category overlap) — works without AI
|
||||||
|
* New: Optional AI upgrade: top-20 candidates sent to connected AI provider for semantic analysis
|
||||||
|
* New: Configurable trigger: manual button, on-save, or timed interval
|
||||||
|
* New: Link Suggestions settings page: exclude posts (Impressum, Kontakt, AGB), boost/prioritise specific posts
|
||||||
|
* New: Gutenberg and Classic Editor both supported for content reading and link insertion
|
||||||
|
* New: Full localization (de_DE, en_US)
|
||||||
|
|
||||||
|
= 1.2.4 =
|
||||||
|
* Fix: AI generation is now disabled by default — users must explicitly enable it on the AI Provider page
|
||||||
|
* Fix: Dashboard "Active Provider" now correctly shows "AI disabled" or "Not configured" when no API key is set
|
||||||
|
* Fix: German strings removed from Schema.org admin page, Schema Metabox in Post Editor, and SEO Widget
|
||||||
|
* New: Meta Generator and GEO Block pages show an info notice when no AI provider is active
|
||||||
|
* New: Meta Generator prompt is now locale-aware — German WordPress installs get the German prompt, all others get English
|
||||||
|
* New: "Theme outputs post title as H1" setting in Meta Generator — suppresses false H1 warning in the SEO Widget
|
||||||
|
* Improved: SEO Widget strings (headings, warnings, links) are now fully translatable via WordPress i18n
|
||||||
|
|
||||||
|
= 1.2.3 =
|
||||||
|
* Improved: llms.txt and robots.txt admin pages merged into a single "TXT Files" page with native WordPress tab navigation
|
||||||
|
|
||||||
|
= 1.2.2 =
|
||||||
|
* New: Dismissible welcome notice with 24 h auto-expiry and Bavarian flavour
|
||||||
|
* New: AI enable toggle with cost warning on AI Provider page
|
||||||
|
* New: Estimated token usage and cost in Status widget
|
||||||
|
* Improved: Dashboard UI — progress bars for meta coverage, styled quick links, crawler dot indicators
|
||||||
|
* Fix: Plugin Check warnings (variable definitions in template moved to controller)
|
||||||
|
* Fix: Hardcoded German strings in admin.js replaced with localized equivalents
|
||||||
|
* Perf: 5-minute transient caching for dashboard DB queries
|
||||||
|
|
||||||
|
= 1.2.1 =
|
||||||
|
* New: Dedicated "Schema.org" admin menu item under Bavarian Rank — schema settings moved out of Meta Generator into their own page with a separate option key
|
||||||
|
|
||||||
|
= 1.2.0 =
|
||||||
|
* New: Schema Suite v2 — FAQPage (auto-generated from GEO Quick Overview data), BlogPosting/Article (with embedded author and featured image), ImageObject, and VideoObject (YouTube/Vimeo auto-detected from post content)
|
||||||
|
* New: Post editor meta box for HowTo, Review (star rating 1–5), Recipe, and Event schema types — per-post data entry, saved as post meta, output as JSON-LD automatically
|
||||||
|
|
||||||
|
= 1.1.0 =
|
||||||
|
* New: GEO Schnellüberblick block — AI-generated per-post summary with short summary, key bullet points, and optional FAQ.
|
||||||
|
* New: Rendered as a native `<details>` element; configurable as collapsible (default), always open, or store-only (no frontend output).
|
||||||
|
* New: Three generation modes — auto on publish, hybrid (auto only when fields are empty), manual only.
|
||||||
|
* New: Configurable insertion position: after first paragraph (default), top, or bottom of content.
|
||||||
|
* New: Quality gate suppresses FAQ generation on posts below a configurable word-count threshold (default: 350).
|
||||||
|
* New: Post editor meta box with live AJAX generate/clear buttons, per-post enable toggle, and auto-lock on manual edit.
|
||||||
|
* New: Optional per-post prompt add-on field for author-level customization.
|
||||||
|
* New: Dedicated admin settings page under Bavarian Rank → GEO Block.
|
||||||
|
* New: Bundled minimal CSS scoped to `.bre-geo`; custom CSS field for theme-level overrides.
|
||||||
|
|
||||||
|
= 1.0.0 =
|
||||||
|
* Initial release.
|
||||||
|
* AI Meta Generator with auto-publish trigger, customizable prompt, and Polylang/WPML language detection.
|
||||||
|
* Fallback meta extraction (sentence-boundary-aware, 150–160 characters) for use without an API key or on API failure.
|
||||||
|
* Bulk Generator with batched AJAX processing, rate limiting, transient lock, per-post retry logic, and cost estimation.
|
||||||
|
* Schema.org Enhancer: Organization, Article, Author, Speakable, BreadcrumbList JSON-LD; AI indexing meta tags.
|
||||||
|
* Standalone meta description output with automatic suppression when Rank Math, Yoast, or AIOSEO is active.
|
||||||
|
* Native field write-through for Rank Math, Yoast SEO, AIOSEO, and SEOPress.
|
||||||
|
* llms.txt with pagination, ETag/Last-Modified HTTP caching, custom sections, and manual cache clear.
|
||||||
|
* robots.txt manager for 13 known AI and data-harvesting crawlers.
|
||||||
|
* Crawler Log database table with SHA-256 IP hashing and weekly auto-purge.
|
||||||
|
* Meta Description meta box with source badge, character counter, and inline AI regeneration.
|
||||||
|
* SEO Analysis sidebar widget with live content statistics and warnings.
|
||||||
|
* Link Analysis dashboard panel: no-internal-links report, external-link outliers, pillar page ranking.
|
||||||
|
* KeyVault API key obfuscation using XOR with WP salts (no OpenSSL dependency).
|
||||||
|
* Multi-provider support: OpenAI, Anthropic, Google Gemini, xAI Grok.
|
||||||
|
* `bre_prompt` filter and `bre_meta_saved` action hooks for developers.
|
||||||
|
|
||||||
|
== Upgrade Notice ==
|
||||||
|
|
||||||
|
= 1.1.0 =
|
||||||
|
No database changes. Deactivate and reactivate the plugin after updating to register the new GEO Block rewrite rules.
|
||||||
|
|
||||||
|
= 1.0.0 =
|
||||||
|
Initial release. No upgrade steps required.
|
||||||
6
brezngeo/uninstall.php
Normal file
6
brezngeo/uninstall.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
delete_option( 'brezngeo_settings' );
|
||||||
|
delete_post_meta_by_key( '_brezngeo_meta_description' );
|
||||||
Loading…
Reference in a new issue