Compare commits
80 Commits
e82754c523
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ac693fe8 | |||
| c091ed1371 | |||
| 164df2f770 | |||
| 232802b0ce | |||
| 4dae370deb | |||
| a5c44e106e | |||
| 70a9f30336 | |||
| 1b6df4a9e4 | |||
| 47a8bfa50a | |||
| e1102d9f06 | |||
| af0ca29131 | |||
| c203fe6397 | |||
| 487e96b588 | |||
| 47889c7d4c | |||
| a5b33c1b8d | |||
| e6d6ad3c7b | |||
| 80e3aa95d8 | |||
| 43fc752535 | |||
| 7a4ce8609c | |||
| 16e80f81c0 | |||
| 5ce4c1daef | |||
| 30b85b2257 | |||
| e306226ac8 | |||
| 4eaec5c7b1 | |||
| 912f9dcac6 | |||
| 351485efb1 | |||
| e1d24c6627 | |||
| 56e51b1c3c | |||
| d3e087ad6e | |||
| 8aa022feff | |||
| 95a0e94b4a | |||
| dd74499339 | |||
| 8a97197f3e | |||
| 34ff7f1a7f | |||
| cf1fcd9e96 | |||
| 7c9e289740 | |||
| 77fcae3450 | |||
| c1ae169335 | |||
| 2d9e5ff8d0 | |||
| c20b482616 | |||
| c11d9bdb8c | |||
| 23a50ce4f6 | |||
| 331e392fc9 | |||
| 239fcc2cb2 | |||
| 73d0796e8d | |||
| 600198ec9a | |||
| a704640ebc | |||
| 6328523624 | |||
| 15cfa01114 | |||
| 0948f50d76 | |||
| 16a07701ee | |||
| c5f4bcc1c5 | |||
| 6a396ce511 | |||
| f86dce6ec3 | |||
| 681eadaed0 | |||
| 259fd297d3 | |||
| f83248d605 | |||
| f8d5dafc62 | |||
| 2e1e5feba9 | |||
| a835f76f90 | |||
| 41580fa010 | |||
| 23403bcc4f | |||
| dbc9de37e0 | |||
| 30bb9e3bbd | |||
| 68f678c2df | |||
| 43b1cc2efd | |||
| 9664dbb256 | |||
| 490a483df1 | |||
| 0ad43bcf9c | |||
| e59970e0b8 | |||
| b64d4d91ff | |||
| c442c0df1e | |||
| 9a067eca16 | |||
| fe8d0bbffb | |||
| a3558c470e | |||
|
|
83e348f31e | ||
|
|
23c781f855 | ||
|
|
9ad5912307 | ||
|
|
c470c4266e | ||
|
|
202f571cbe |
26
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Development instructions
|
||||||
|
- This is a personal project that will only ever be used by me. No public distribution is planned.
|
||||||
|
- The project is written in PHP and targets Dokuwiki.
|
||||||
|
- Follow general PHP best practices.
|
||||||
|
- Follow Dokuwiki coding conventions: https://www.dokuwiki.org/devel
|
||||||
|
- Do not use `phpunit` There are missing dependencies that make it fail.
|
||||||
|
- Use `php -l <file>` to check for syntax errors.
|
||||||
|
- Consider The official documentation for writing dokuwiki plugins: https://www.dokuwiki.org/devel:plugins
|
||||||
|
- Whenever necessary, update the README file to reflect new features or changes.
|
||||||
|
|
||||||
|
|
||||||
|
# General instructions
|
||||||
|
- This plugin is inteded to be used by me for many years to come
|
||||||
|
- That means maintainability is more important than cutting edge technologies
|
||||||
|
- Write code that is easy to understand and modify
|
||||||
|
- Favor stability over performance unless performance is a clear requirement
|
||||||
|
- Favor simplicity over cleverness
|
||||||
|
- Favor explicitness over implicitness
|
||||||
|
- Favor well-known solutions over new or exotic solutions
|
||||||
|
- Favor documented solutions over undocumented solutions
|
||||||
|
- Favor built-in solutions over external dependencies
|
||||||
|
|
||||||
|
# Conduct and user interactions
|
||||||
|
- The user is a professional software developer, but unfamiliar with Dokuwiki internals, PHP and JavaScript best practices
|
||||||
|
- When the user gives specific instructions regarding implementation details, check wether those details fit PHP and Dokuwiki best practices.
|
||||||
|
- If the user instructions conflict with best practices, point out the conflict and suggest alternatives
|
||||||
11
.github/workflows/dokuwiki.yml
vendored
@@ -1,11 +0,0 @@
|
|||||||
name: DokuWiki Default Tasks
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
schedule:
|
|
||||||
- cron: '58 2 25 * *'
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
all:
|
|
||||||
uses: dokuwiki/github-action/.github/workflows/all.yml@main
|
|
||||||
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
_agent-data/
|
||||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "_dokuwiki"]
|
||||||
|
path = _dokuwiki
|
||||||
|
url = https://github.com/dokuwiki/dokuwiki.git
|
||||||
26
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
// Developer quality-of-life:
|
||||||
|
// If you add a DokuWiki checkout as ./_dokuwiki (see README), Intelephense will
|
||||||
|
// index it and resolve DokuWiki base classes (ActionPlugin, SyntaxPlugin, etc.).
|
||||||
|
"intelephense.environment.includePaths": [
|
||||||
|
"./_dokuwiki",
|
||||||
|
"./_dokuwiki/inc",
|
||||||
|
"./_dokuwiki/lib",
|
||||||
|
"./_dokuwiki/vendor"
|
||||||
|
],
|
||||||
|
|
||||||
|
// DokuWiki replaces @ini_* placeholders server-side.
|
||||||
|
// VS Code's CSS validator doesn't understand those tokens, but LESS does.
|
||||||
|
"files.associations": {
|
||||||
|
"style.css": "less",
|
||||||
|
"temp-input-colors.css": "less"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Keep the file explorer tidy when the optional DokuWiki checkout exists.
|
||||||
|
"files.exclude": {
|
||||||
|
"**/_dokuwiki/.git": true,
|
||||||
|
"**/_dokuwiki/data": true,
|
||||||
|
"**/_dokuwiki/conf": true,
|
||||||
|
"**/_dokuwiki/cache": true
|
||||||
|
}
|
||||||
|
}
|
||||||
340
COPYING
@@ -1,340 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 2, June 1991
|
|
||||||
|
|
||||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
|
||||||
59 Temple Place, Suite 330, Boston, MA 02111-1307 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 Library 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 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 Library General
|
|
||||||
Public License instead of this License.
|
|
||||||
|
Before Width: | Height: | Size: 29 KiB |
283
Output.php
@@ -1,283 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace dokuwiki\plugin\filelist;
|
|
||||||
|
|
||||||
class Output
|
|
||||||
{
|
|
||||||
/** @var \Doku_Renderer */
|
|
||||||
protected $renderer;
|
|
||||||
|
|
||||||
/** @var string */
|
|
||||||
protected $basedir;
|
|
||||||
|
|
||||||
/** @var string */
|
|
||||||
protected $webdir;
|
|
||||||
|
|
||||||
/** @var array */
|
|
||||||
protected $files;
|
|
||||||
|
|
||||||
|
|
||||||
public function __construct(\Doku_Renderer $renderer, $basedir, $webdir, $files)
|
|
||||||
{
|
|
||||||
$this->renderer = $renderer;
|
|
||||||
$this->basedir = $basedir;
|
|
||||||
$this->webdir = $webdir;
|
|
||||||
$this->files = $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function renderAsList($params)
|
|
||||||
{
|
|
||||||
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
|
|
||||||
$this->renderer->doc .= '<div class="filelist-plugin">';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->renderListItems($this->files, $params);
|
|
||||||
|
|
||||||
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
|
|
||||||
$this->renderer->doc .= '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the files as a table, including details if configured that way.
|
|
||||||
*
|
|
||||||
* @param array $params the parameters of the filelist call
|
|
||||||
*/
|
|
||||||
public function renderAsTable($params)
|
|
||||||
{
|
|
||||||
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
|
|
||||||
$this->renderer->doc .= '<div class="filelist-plugin">';
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = $this->flattenResultTree($this->files);
|
|
||||||
$this->renderTableItems($items, $params);
|
|
||||||
|
|
||||||
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
|
|
||||||
$this->renderer->doc .= '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the files as a table, including details if configured that way.
|
|
||||||
*
|
|
||||||
* @param array $params the parameters of the filelist call
|
|
||||||
*/
|
|
||||||
protected function renderTableItems($items, $params)
|
|
||||||
{
|
|
||||||
|
|
||||||
$renderer = $this->renderer;
|
|
||||||
|
|
||||||
|
|
||||||
// count the columns
|
|
||||||
$columns = 1;
|
|
||||||
if ($params['showsize']) {
|
|
||||||
$columns++;
|
|
||||||
}
|
|
||||||
if ($params['showdate']) {
|
|
||||||
$columns++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$renderer->table_open($columns);
|
|
||||||
|
|
||||||
if ($params['tableheader']) {
|
|
||||||
$renderer->tablethead_open();
|
|
||||||
$renderer->tablerow_open();
|
|
||||||
|
|
||||||
$renderer->tableheader_open();
|
|
||||||
$renderer->cdata($this->getLang('filename'));
|
|
||||||
$renderer->tableheader_close();
|
|
||||||
|
|
||||||
if ($params['showsize']) {
|
|
||||||
$renderer->tableheader_open();
|
|
||||||
$renderer->cdata($this->getLang('filesize'));
|
|
||||||
$renderer->tableheader_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($params['showdate']) {
|
|
||||||
$renderer->tableheader_open();
|
|
||||||
$renderer->cdata($this->getLang('lastmodified'));
|
|
||||||
$renderer->tableheader_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
$renderer->tablerow_close();
|
|
||||||
$renderer->tablethead_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
$renderer->tabletbody_open();
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$renderer->tablerow_open();
|
|
||||||
$renderer->tablecell_open();
|
|
||||||
$this->renderItemLink($item, $params['randlinks']);
|
|
||||||
$renderer->tablecell_close();
|
|
||||||
|
|
||||||
if ($params['showsize']) {
|
|
||||||
$renderer->tablecell_open(1, 'right');
|
|
||||||
$renderer->cdata(filesize_h($item['size']));
|
|
||||||
$renderer->tablecell_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($params['showdate']) {
|
|
||||||
$renderer->tablecell_open();
|
|
||||||
$renderer->cdata(dformat($item['mtime']));
|
|
||||||
$renderer->tablecell_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
$renderer->tablerow_close();
|
|
||||||
}
|
|
||||||
$renderer->tabletbody_close();
|
|
||||||
$renderer->table_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively renders a tree of files as list items.
|
|
||||||
*
|
|
||||||
* @param array $items the files to render
|
|
||||||
* @param array $params the parameters of the filelist call
|
|
||||||
* @param int $level the level to render
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
protected function renderListItems($items, $params, $level = 1)
|
|
||||||
{
|
|
||||||
if ($params['style'] == 'olist') {
|
|
||||||
$this->renderer->listo_open();
|
|
||||||
} else {
|
|
||||||
$this->renderer->listu_open();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($items as $file) {
|
|
||||||
if ($file['children'] === false && $file['treesize'] === 0) continue; // empty directory
|
|
||||||
|
|
||||||
$this->renderer->listitem_open($level);
|
|
||||||
$this->renderer->listcontent_open();
|
|
||||||
|
|
||||||
if ($file['children'] !== false && $file['treesize'] > 0) {
|
|
||||||
// render the directory and its subtree
|
|
||||||
$this->renderer->cdata($file['name']);
|
|
||||||
$this->renderListItems($file['children'], $params, $level + 1);
|
|
||||||
} elseif ($file['children'] === false) {
|
|
||||||
// render the file link
|
|
||||||
$this->renderItemLink($file, $params['randlinks']);
|
|
||||||
|
|
||||||
// render filesize
|
|
||||||
if ($params['showsize']) {
|
|
||||||
$this->renderer->cdata($params['listsep'] . filesize_h($file['size']));
|
|
||||||
}
|
|
||||||
// render lastmodified
|
|
||||||
if ($params['showdate']) {
|
|
||||||
$this->renderer->cdata($params['listsep'] . dformat($file['mtime']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->renderer->listcontent_close();
|
|
||||||
$this->renderer->listitem_close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($params['style'] == 'olist') {
|
|
||||||
$this->renderer->listo_close();
|
|
||||||
} else {
|
|
||||||
$this->renderer->listu_close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function renderItemLink($item, $cachebuster = false)
|
|
||||||
{
|
|
||||||
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
|
|
||||||
$this->renderItemLinkXHTML($item, $cachebuster);
|
|
||||||
} else {
|
|
||||||
$this->renderItemLinkAny($item, $cachebuster);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a file link on the XHTML renderer
|
|
||||||
*/
|
|
||||||
protected function renderItemLinkXHTML($item, $cachebuster = false)
|
|
||||||
{
|
|
||||||
global $conf;
|
|
||||||
/** @var \Doku_Renderer_xhtml $renderer */
|
|
||||||
$renderer = $this->renderer;
|
|
||||||
|
|
||||||
//prepare for formating
|
|
||||||
$link['target'] = $conf['target']['extern'];
|
|
||||||
$link['style'] = '';
|
|
||||||
$link['pre'] = '';
|
|
||||||
$link['suf'] = '';
|
|
||||||
$link['more'] = '';
|
|
||||||
$link['url'] = $this->itemWebUrl($item, $cachebuster);
|
|
||||||
$link['name'] = $item['name'];
|
|
||||||
$link['title'] = $renderer->_xmlEntities($link['url']);
|
|
||||||
if ($conf['relnofollow']) $link['more'] .= ' rel="nofollow"';
|
|
||||||
[$ext,] = mimetype(basename($item['local']));
|
|
||||||
$link['class'] = 'media mediafile mf_' . $ext;
|
|
||||||
$renderer->doc .= $renderer->_formatLink($link);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a file link on any Renderer
|
|
||||||
* @param array $item
|
|
||||||
* @param bool $cachebuster
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
protected function renderItemLinkAny($item, $cachebuster = false)
|
|
||||||
{
|
|
||||||
$this->renderer->externalmedialink($this->itemWebUrl($item, $cachebuster), $item['name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct the Web URL for a given item
|
|
||||||
*
|
|
||||||
* @param array $item The item data as returned by the Crawler
|
|
||||||
* @param bool $cachbuster add a cachebuster to the URL?
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
protected function itemWebUrl($item, $cachbuster = false)
|
|
||||||
{
|
|
||||||
if (str_ends_with($this->webdir, '=')) {
|
|
||||||
$url = $this->webdir . rawurlencode($item['local']);
|
|
||||||
} else {
|
|
||||||
$url = $this->webdir . $item['local'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($cachbuster) {
|
|
||||||
if (strpos($url, '?') === false) {
|
|
||||||
$url .= '?t=' . $item['mtime'];
|
|
||||||
} else {
|
|
||||||
$url .= '&t=' . $item['mtime'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flattens the filelist by recursively walking through all subtrees and
|
|
||||||
* merging them with a prefix attached to the filenames.
|
|
||||||
*
|
|
||||||
* @param array $items the tree to flatten
|
|
||||||
* @param string $prefix the prefix to attach to all processed nodes
|
|
||||||
* @return array a flattened representation of the tree
|
|
||||||
*/
|
|
||||||
protected function flattenResultTree($items, $prefix = '')
|
|
||||||
{
|
|
||||||
$result = [];
|
|
||||||
foreach ($items as $file) {
|
|
||||||
if ($file['children'] !== false) {
|
|
||||||
$result = array_merge(
|
|
||||||
$result,
|
|
||||||
$this->flattenResultTree($file['children'], $prefix . $file['name'] . '/')
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$file['name'] = $prefix . $file['name'];
|
|
||||||
$result[] = $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getLang($key)
|
|
||||||
{
|
|
||||||
$syntax = plugin_load('syntax', 'filelist');
|
|
||||||
return $syntax->getLang($key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
README
@@ -1,27 +0,0 @@
|
|||||||
filelist plugin for DokuWiki
|
|
||||||
|
|
||||||
Lists files matching a given glob pattern.
|
|
||||||
|
|
||||||
All documentation for this plugin can be found at
|
|
||||||
https://www.dokuwiki.org/plugin:filelist
|
|
||||||
|
|
||||||
If you install this plugin manually, make sure it is installed in
|
|
||||||
lib/plugins/filelist/ - if the folder is called different it
|
|
||||||
will not work!
|
|
||||||
|
|
||||||
Please refer to http://www.dokuwiki.org/extensions for additional info
|
|
||||||
on how to install extensions in DokuWiki.
|
|
||||||
|
|
||||||
----
|
|
||||||
Copyright (C) Gina Häußge, Dokufreaks <gina@foosel.net, freaks@dokuwiki.org>
|
|
||||||
|
|
||||||
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; version 2 of the License
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
See the LICENSING file for details
|
|
||||||
494
README.md
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
# luxtools (DokuWiki plugin)
|
||||||
|
|
||||||
|
luxtools is a suite of tools designed to integrate the host file system with
|
||||||
|
DokuWiki, intentionally sidestepping the built-in MediaManager for specific
|
||||||
|
workflows.
|
||||||
|
|
||||||
|
This is a personal project that models my specific workflows and preferences.
|
||||||
|
It is likely unsuited for wider adoption without modification.
|
||||||
|
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
|
- luxtools-client: https://git.luxick.de/luxick/luxtools-client
|
||||||
|
A client application for direct integration with the client machine
|
||||||
|
(e.g. opening folders/paths from within the browser).
|
||||||
|
- luxtools-template: https://git.luxick.de/luxick/luxtools-template
|
||||||
|
A DokuWiki template designed to complement the features of this plugin.
|
||||||
|
|
||||||
|
|
||||||
|
## What this plugin does
|
||||||
|
|
||||||
|
luxtools provides DokuWiki syntax that:
|
||||||
|
|
||||||
|
- Lists a directory's direct children (files + folders) or files matching a glob pattern
|
||||||
|
- Renders an image thumbnail gallery (with lightbox)
|
||||||
|
- Groups multiple `{{image>...}}` blocks in compact grid/flex layouts
|
||||||
|
- Provides "open this folder/path" links for local workflows
|
||||||
|
- Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions)
|
||||||
|
- Links a page to a media folder via a UUID (.pagelink), enabling a `blobs/` alias
|
||||||
|
- Adds a Page ID download link in the page info area to fetch a `.pagelink` file
|
||||||
|
- Renders a basic calendar widget with clickable day links to chronological pages
|
||||||
|
|
||||||
|
It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used
|
||||||
|
to deliver files and generate cached thumbnails.
|
||||||
|
|
||||||
|
|
||||||
|
## Note on security
|
||||||
|
|
||||||
|
The file-serving endpoint (`lib/plugins/luxtools/file.php`) runs inside DokuWiki
|
||||||
|
and enforces access via per-page ACL: the requester must have at least read
|
||||||
|
access to the wiki page that rendered the link.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install like any other DokuWiki plugin.
|
||||||
|
|
||||||
|
If you install this plugin manually, make sure it is installed in:
|
||||||
|
|
||||||
|
`lib/plugins/luxtools/`
|
||||||
|
|
||||||
|
If the folder is called differently, DokuWiki will not load it.
|
||||||
|
|
||||||
|
This plugin uses Composer dependencies shipped inside `vendor/`.
|
||||||
|
If dependencies are missing in your local checkout, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php composer.phar install
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Project structure (developer notes)
|
||||||
|
|
||||||
|
This repository follows DokuWiki's plugin conventions at the top level (e.g. `syntax.php`, `conf/`, `lang/`, endpoints like `file.php`).
|
||||||
|
|
||||||
|
Reusable PHP code lives in `src/` and is loaded via `autoload.php`.
|
||||||
|
When adding new internal classes under the `dokuwiki\plugin\luxtools\` namespace, place them in `src/<ClassName>.php`.
|
||||||
|
|
||||||
|
JavaScript is split into small modules under `js/` and registered via `action.php` so DokuWiki loads them in order.
|
||||||
|
|
||||||
|
|
||||||
|
## IDE support (developer notes)
|
||||||
|
|
||||||
|
This plugin extends and uses DokuWiki core classes (for example `dokuwiki\Extension\ActionPlugin`, `dokuwiki\Extension\SyntaxPlugin`, renderers, handlers). If you only open the plugin folder in your IDE, those types may show as “unknown”.
|
||||||
|
|
||||||
|
DokuWiki does not currently ship an official PHP “SDK”/stub package for IDEs. The most reliable way to get full type navigation and autocomplete is to have the DokuWiki sources available in your workspace.
|
||||||
|
|
||||||
|
Two recommended setups:
|
||||||
|
|
||||||
|
### Option A: Add DokuWiki as a git submodule (recommended for a single-folder workspace)
|
||||||
|
|
||||||
|
From the plugin root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule add https://github.com/dokuwiki/dokuwiki.git _dokuwiki
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
The repository includes a VS Code config in `.vscode/settings.json` that points Intelephense at `./_dokuwiki/*` so the classes resolve.
|
||||||
|
|
||||||
|
`deploy.sh` excludes `_dokuwiki/` to avoid deploying the dev-only checkout.
|
||||||
|
|
||||||
|
### Option B: Use a separate DokuWiki checkout next to the plugin (recommended if you don’t want submodules)
|
||||||
|
|
||||||
|
- Clone DokuWiki into a sibling folder (outside this repo)
|
||||||
|
- Open a multi-root VS Code workspace with both folders
|
||||||
|
|
||||||
|
This avoids changing the git state of the plugin repo, but still gives the IDE access to DokuWiki’s class definitions.
|
||||||
|
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
luxtools is configured via its dedicated admin page:
|
||||||
|
|
||||||
|
`Admin -> Additional Plugins -> luxtools`
|
||||||
|
|
||||||
|
Key settings:
|
||||||
|
|
||||||
|
- **paths**
|
||||||
|
Allowed base filesystem roots (one per line). Each root can be followed by:
|
||||||
|
- `A> Alias` (optional) alias used in wiki syntax and open links
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
/srv/share/Datascape/
|
||||||
|
A> Scape
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Wiki syntax accepts aliases in path form (for example `Scape/sub/folder`).
|
||||||
|
- Open links sent to the local client service are emitted as `Alias>relative/path`
|
||||||
|
(for example `Scape>sub/folder`) so each client can resolve its own local root.
|
||||||
|
|
||||||
|
luxtools links use the plugin endpoint:
|
||||||
|
|
||||||
|
`lib/plugins/luxtools/file.php?root=...&file=...`
|
||||||
|
|
||||||
|
The generated URLs also include the current wiki page id (`id=...`) so
|
||||||
|
`file.php` can enforce ACLs for the host page.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- **scratchpad_paths**
|
||||||
|
Scratchpad file map (one file path per line, followed by an `A>` alias line).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
/var/lib/dokuwiki-scratchpads/startpad.txt
|
||||||
|
A> start
|
||||||
|
```
|
||||||
|
|
||||||
|
- **defaults**
|
||||||
|
Default inline options appended to each listing call.
|
||||||
|
|
||||||
|
- **extensions**
|
||||||
|
Comma-separated list of file extensions allowed in listings.
|
||||||
|
Leave empty to allow all.
|
||||||
|
|
||||||
|
- **thumb_placeholder**
|
||||||
|
MediaManager ID used as a placeholder image in the gallery (optional).
|
||||||
|
|
||||||
|
- **gallery_thumb_scale**
|
||||||
|
Multiplier for generated thumbnails (1 = 150x150, 2 = 300x300), while still
|
||||||
|
displaying them as 150x150. Useful for HiDPI screens.
|
||||||
|
|
||||||
|
- **open_service_url**
|
||||||
|
URL of a local client service used by `{{open>...}}` and directory links.
|
||||||
|
See luxtools-client.
|
||||||
|
|
||||||
|
- **image_base_path**
|
||||||
|
Base filesystem path used for chronological photo integration.
|
||||||
|
On canonical day pages (`chronological:YYYY:MM:DD`), files that start with
|
||||||
|
`YYYY-MM-DD` are listed automatically.
|
||||||
|
If a yearly subfolder exists (for example `.../2026/`), it is preferred.
|
||||||
|
|
||||||
|
- **calendar_ics_files**
|
||||||
|
Local calendar `.ics` files (one absolute file path per line).
|
||||||
|
Events are parsed by `sabre/vobject` and shown on matching chronological day pages.
|
||||||
|
Recurrence and exclusions from the ICS are respected. For timed entries, the
|
||||||
|
page stores the original timestamp and renders the visible time in the
|
||||||
|
browser's local timezone.
|
||||||
|
Multi-day events appear on each overlapping day.
|
||||||
|
|
||||||
|
- **pagelink_search_depth**
|
||||||
|
Maximum directory depth for `.pagelink` discovery under each configured root.
|
||||||
|
`0` means only the root directory itself is checked.
|
||||||
|
|
||||||
|
### Template style settings
|
||||||
|
|
||||||
|
The `{{open>...}}` links and directory “open” links use a dedicated color
|
||||||
|
placeholder so they can be customized in **Template Style Settings**.
|
||||||
|
|
||||||
|
- **Location Links** (`__luxtools_locationlink__`)
|
||||||
|
Default: `#b57d35`
|
||||||
|
|
||||||
|
To be able to customize the color via the UI add the following to your local template
|
||||||
|
style file at `conf/tpl/<your-template>/style.ini` under the
|
||||||
|
`[replacements]` section:
|
||||||
|
|
||||||
|
```
|
||||||
|
__luxtools_locationlink__ = "#b57d35" ; @ini_luxtools_locationlink
|
||||||
|
```
|
||||||
|
|
||||||
|
### Temporary global input styling
|
||||||
|
|
||||||
|
Because the target template is not ready yet, the plugin currently ships a
|
||||||
|
temporary stylesheet that applies `@ini_text`, `@ini_background`, and
|
||||||
|
`@ini_border` to all `input`, `textarea`, and `select` elements site-wide.
|
||||||
|
This file is explicitly marked as a temporary fix and should be removed once
|
||||||
|
the template provides proper form control styles.
|
||||||
|
|
||||||
|
Temporary file: [temp-input-colors.css](temp-input-colors.css)
|
||||||
|
|
||||||
|
Developer note: DokuWiki serves a combined stylesheet via `lib/exe/css.php` and caches it.
|
||||||
|
Cache invalidation is based on the mtimes of the source CSS/LESS files.
|
||||||
|
If you deploy into a mounted/remote filesystem with a different clock, preserving mtimes can prevent
|
||||||
|
automatic invalidation (making it look like your CSS changes don't load until you purge cache).
|
||||||
|
|
||||||
|
`deploy.sh` avoids preserving mtimes by default to make CSS iteration smoother. If you explicitly want
|
||||||
|
to preserve mtimes, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh --preserve-times
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Features and usage
|
||||||
|
|
||||||
|
### 0) Editor toolbar: Code block button
|
||||||
|
|
||||||
|
The plugin adds a custom button to the DokuWiki editor toolbar for quickly inserting `<code>` blocks.
|
||||||
|
|
||||||
|
When editing a page, click the code block button (angle brackets icon `<>`) in the toolbar to wrap selected text in `<code></code>` tags, or to insert an empty code block at the cursor position.
|
||||||
|
|
||||||
|
This complements DokuWiki's built-in monospace formatting (`''`) by providing quick access to HTML code blocks.
|
||||||
|
|
||||||
|
### 0.1) Editor toolbar: Date Fix
|
||||||
|
|
||||||
|
The plugin adds two toolbar buttons for normalizing timestamps while editing:
|
||||||
|
|
||||||
|
- **Date Fix**: Converts the selected timestamp to `YYYY-MM-DD` (or `YYYY-MM-DD HH:MM:SS` if time is included).
|
||||||
|
- **Date Fix (All)**: Scans the whole page and normalizes any recognizable timestamps.
|
||||||
|
|
||||||
|
Supported input examples include:
|
||||||
|
|
||||||
|
- `2026-01-30`
|
||||||
|
- `30.01.2026`
|
||||||
|
- `30 Jan 2026`
|
||||||
|
- `Jan 30, 2026`
|
||||||
|
- `2026-01-30 13:45`
|
||||||
|
- `2026-01-30T13:45:00`
|
||||||
|
|
||||||
|
### 0.2) Page Link: link a page to a folder
|
||||||
|
|
||||||
|
Page linking uses a page-scoped UUID stored in page metadata. This UUID is used
|
||||||
|
to link the page to a folder that contains a `.pagelink` file with the same UUID.
|
||||||
|
|
||||||
|
The Page Link workflow is driven by the **Page ID link** in the page info area
|
||||||
|
(page footer, `.docInfo`):
|
||||||
|
|
||||||
|
1. **Link Page** (page has no UUID yet)
|
||||||
|
Creates the UUID and downloads a `.pagelink` file.
|
||||||
|
2. **Download Link File** (page has UUID, but no linked folder found)
|
||||||
|
Downloads the `.pagelink` file.
|
||||||
|
3. **Unlink Page** (page is linked)
|
||||||
|
Prompts for confirmation, removes the `.pagelink` file from the linked folder
|
||||||
|
(if found), removes the UUID from the page, and refreshes the page.
|
||||||
|
|
||||||
|
After downloading the `.pagelink` file, place it into the folder you want to
|
||||||
|
link (within your configured `paths` roots). Once DokuWiki can discover it,
|
||||||
|
the page becomes “linked”.
|
||||||
|
|
||||||
|
Once linked, you can use `blobs/` as an alias in luxtools syntax on that page,
|
||||||
|
for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{images>blobs/*.png}}
|
||||||
|
{{directory>blobs/&recursive=1}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.3) Calendar widget
|
||||||
|
|
||||||
|
Render a basic monthly calendar that links each day to canonical chronological pages:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{calendar>}}
|
||||||
|
{{calendar>2024-10}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `{{calendar>}}` renders the current month.
|
||||||
|
- `{{calendar>YYYY-MM}}` renders a specific month.
|
||||||
|
- Day links target `chronological:YYYY:MM:DD`.
|
||||||
|
- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`.
|
||||||
|
- Prev/next month buttons update the widget in-place without a full page reload.
|
||||||
|
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
|
||||||
|
|
||||||
|
### 0.4) Virtual chronological day pages
|
||||||
|
|
||||||
|
When a canonical day page (for example `chronological:2026:02:13`) does not yet
|
||||||
|
exist, luxtools renders a virtual page in normal show mode instead of the
|
||||||
|
default "page does not exist" output.
|
||||||
|
|
||||||
|
The virtual page includes:
|
||||||
|
|
||||||
|
- a German-formatted heading (for example `Freitag, 13. Februar 2026`)
|
||||||
|
- matching local calendar events from configured `.ics` files (when available)
|
||||||
|
- matching day photos (via existing `{{images>...}}` rendering) when available
|
||||||
|
|
||||||
|
The page is only created once you edit and save actual content.
|
||||||
|
|
||||||
|
### 1) List files by glob pattern
|
||||||
|
|
||||||
|
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{directory>/Scape/projects/*}}
|
||||||
|
{{directory>/Scape/projects/*&tableheader=1&showsize=1&showdate=1}}
|
||||||
|
{{directory>/Scape/projects/*&recursive=1&sort=mtime&order=desc}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using the legacy `files` keyword (same behavior):
|
||||||
|
```
|
||||||
|
{{files>/Scape/projects/*}}
|
||||||
|
{{files>/Scape/projects/*&tableheader=1&showsize=1&showdate=1}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pattern matching is performed per-directory (safe glob via fnmatch).
|
||||||
|
- Always renders as a table.
|
||||||
|
- A directory can have a title file (default: `_title.txt`) to override the displayed folder name.
|
||||||
|
|
||||||
|
### 2) List a directory (folders + files) as a table
|
||||||
|
|
||||||
|
```
|
||||||
|
{{directory>/Scape/projects/&tableheader=1&foldersfirst=1&sort=name}}
|
||||||
|
```
|
||||||
|
|
||||||
|
This always renders as a table. It includes an "Open Location" link above the table when rendered as XHTML.
|
||||||
|
|
||||||
|
### 3) Image gallery with thumbnails + lightbox
|
||||||
|
|
||||||
|
```
|
||||||
|
{{images>/Scape/photos/2025/*}}
|
||||||
|
{{images>/Scape/photos/2025/*&recursive=1}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking a thumbnail opens a lightbox viewer. Thumbnails are generated and cached via the plugin endpoint.
|
||||||
|
|
||||||
|
### 4) Single image with caption (imagebox)
|
||||||
|
|
||||||
|
```
|
||||||
|
{{image>/Scape/photos/picture.jpg|This is the caption}}
|
||||||
|
{{image>/Scape/photos/picture.jpg|Caption|400}}
|
||||||
|
{{image>/Scape/photos/picture.jpg|Caption|400x300}}
|
||||||
|
{{image>/Scape/photos/picture.jpg|Caption|left}}
|
||||||
|
{{image>/Scape/photos/picture.jpg|Caption|400¢er}}
|
||||||
|
{{image>https://example.com/images/picture.jpg|Remote image caption}}
|
||||||
|
{{image>https://example.com/images/picture.jpg|Remote caption|400x300&left}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a Wikipedia-style image box with optional caption. The syntax uses pipe-separated parts:
|
||||||
|
|
||||||
|
- `{{image>path|caption}}` – Image with caption (uses defaults)
|
||||||
|
- `{{image>path|caption|options}}` – Image with caption and options
|
||||||
|
|
||||||
|
Options (in the third part, separated by `&`):
|
||||||
|
|
||||||
|
- Size: `200` (width) or `200x150` (width × height)
|
||||||
|
- Alignment: `left`, `right` (default), or `center`
|
||||||
|
- Combined: `400&left` or `400x300¢er`
|
||||||
|
|
||||||
|
The image links to the full-size version when clicked.
|
||||||
|
|
||||||
|
Remote images (HTTP/HTTPS URLs) are linked directly without proxying or thumbnailing.
|
||||||
|
|
||||||
|
### 5) Group multiple image boxes compactly
|
||||||
|
|
||||||
|
Use `<grouping> ... </grouping>` to arrange multiple `{{image>...}}` entries in less vertical space.
|
||||||
|
|
||||||
|
```text
|
||||||
|
<grouping>
|
||||||
|
{{image>/Scape/photos/1.jpg|One|300}}
|
||||||
|
{{image>/Scape/photos/2.jpg|Two|300}}
|
||||||
|
{{image>/Scape/photos/3.jpg|Three|300}}
|
||||||
|
{{image>/Scape/photos/4.jpg|Four|300}}
|
||||||
|
</grouping>
|
||||||
|
|
||||||
|
<grouping layout="flex" gap="0" justify="start" align="start">
|
||||||
|
{{image>/Scape/photos/1.jpg|One|220}}
|
||||||
|
{{image>/Scape/photos/2.jpg|Two|220}}
|
||||||
|
{{image>/Scape/photos/3.jpg|Three|220}}
|
||||||
|
</grouping>
|
||||||
|
|
||||||
|
<grouping layout="grid" cols="3" gap="0.4rem">
|
||||||
|
{{image>/Scape/photos/1.jpg|One|260}}
|
||||||
|
{{image>/Scape/photos/2.jpg|Two|260}}
|
||||||
|
{{image>/Scape/photos/3.jpg|Three|260}}
|
||||||
|
</grouping>
|
||||||
|
|
||||||
|
<grouping layout="flex" gap="0.5rem" justify="space-between" align="center">
|
||||||
|
{{image>/Scape/photos/1.jpg|One|220}}
|
||||||
|
{{image>/Scape/photos/2.jpg|Two|220}}
|
||||||
|
{{image>/Scape/photos/3.jpg|Three|220}}
|
||||||
|
</grouping>
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported attributes on the opening tag:
|
||||||
|
|
||||||
|
- `layout`: `flex` (default) or `grid`
|
||||||
|
- `cols`: integer >= 1 (default `2`, used by `grid`)
|
||||||
|
- `gap`: CSS length token such as `0`, `0.6rem`, `8px` (default `0`)
|
||||||
|
- `justify`: `start`, `center`, `end`, `space-between`, `space-around`, `space-evenly` (default `start`)
|
||||||
|
- `align`: `start`, `center`, `end`, `stretch`, `baseline` (default `start`)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The wrapper only controls layout. It adds no own border/background/frame.
|
||||||
|
- Invalid values silently fall back to defaults.
|
||||||
|
- Unknown attributes render a small warning string, e.g. `[grouping: unknown option(s): gpa]`.
|
||||||
|
- Existing standalone `{{image>...}}` behavior is unchanged outside `<grouping>`.
|
||||||
|
|
||||||
|
### 6) Open a local path/folder (best-effort)
|
||||||
|
|
||||||
|
```
|
||||||
|
{{open>/Scape/projects|Open projects folder}}
|
||||||
|
{{open>/home/me/notes|Open local folder}}
|
||||||
|
{{open>file:///home/me/notes|Open via file://}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
- Prefer calling the configured local client service (open_service_url).
|
||||||
|
- Fall back to opening a file:// URL in a new tab (often blocked by browsers).
|
||||||
|
|
||||||
|
### 7) Scratchpads (shared, file-backed, no page revisions)
|
||||||
|
|
||||||
|
```
|
||||||
|
{{scratchpad>start}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Scratchpads render the referenced file as wikitext and (when you have edit rights on the host page) provide an inline editor that saves directly to the backing file.
|
||||||
|
|
||||||
|
### 8) Link Favicons (automatic)
|
||||||
|
|
||||||
|
External links automatically display the favicon of the linked website. This feature:
|
||||||
|
|
||||||
|
- Uses DuckDuckGo's favicon service (`icons.duckduckgo.com`)
|
||||||
|
- Works on all external links (class `urlextern`)
|
||||||
|
- Shows grayscale icons that become colored on hover
|
||||||
|
- Browser handles caching; no server-side storage needed
|
||||||
|
|
||||||
|
No configuration required. The feature is enabled by default for all external links.
|
||||||
|
|
||||||
|
Based on the [linkfavicon plugin](https://github.com/shaoyanmin/linkfavicon) by Shao Yanmin.
|
||||||
|
|
||||||
|
|
||||||
|
## Inline options reference (directory/images)
|
||||||
|
|
||||||
|
The listing syntaxes accept options appended with &key=value:
|
||||||
|
|
||||||
|
| Option | Values | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| recursive | 0\|1 | Recurse into subdirectories. |
|
||||||
|
| sort | name\|iname\|ctime\|mtime\|size | Sort key. |
|
||||||
|
| order | asc\|desc | Sort order. |
|
||||||
|
| foldersfirst | 0\|1 | Group folders before files (useful for tables). |
|
||||||
|
| titlefile | _title.txt | Directory title override file name. |
|
||||||
|
| cache | 0\|1 | 0 disables page caching (default). |
|
||||||
|
| randlinks | 0\|1 | Adds a cache-busting query parameter based on mtime. |
|
||||||
|
| showsize | 0\|1 | Show file size (where supported). |
|
||||||
|
| showdate | 0\|1 | Show last modified date (where supported). |
|
||||||
|
| maxheight | 500 | Container max-height in pixels; -1 disables scroll container. |
|
||||||
|
| tableheader | 0\|1 | Render table header row. |
|
||||||
|
|
||||||
|
|
||||||
|
## Admin settings
|
||||||
|
|
||||||
|
The admin settings page includes a **default_tablecolumns** option that lets you specify which columns are displayed by default in table-style listings. This is a comma-separated list of column names:
|
||||||
|
|
||||||
|
- `name` – File/folder name (always shown)
|
||||||
|
- `size` – File size
|
||||||
|
- `date` – Last modified date
|
||||||
|
|
||||||
|
Example: `name,size,date` shows all columns by default.
|
||||||
|
|
||||||
|
|
||||||
|
## Credits / upstream
|
||||||
|
|
||||||
|
luxtools is a fork of the [DokuWiki Filelist plugin](https://www.dokuwiki.org/plugin:filelist).
|
||||||
|
|
||||||
|
Upstream authors and contributors include Gina Häußge and the DokuWiki
|
||||||
|
community (Dokufreaks).
|
||||||
|
|
||||||
|
This fork keeps the original license (GPL-2) and retains the relevant copyright
|
||||||
|
notices in the source.
|
||||||
|
|
||||||
|
|
||||||
|
## Development helpers
|
||||||
|
|
||||||
|
- Linux/macOS: ./deploy.sh
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-2. See COPYING / LICENSE.
|
||||||
1
_dokuwiki
Submodule
138
_test/ChronoIDTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\ChronoID;
|
||||||
|
use DokuWikiTest;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chronological ID helper tests.
|
||||||
|
*
|
||||||
|
* @group plugin_luxtools
|
||||||
|
* @group plugins
|
||||||
|
*/
|
||||||
|
class ChronoIDTest extends DokuWikiTest
|
||||||
|
{
|
||||||
|
protected function assertBool(bool $expected, bool $actual, string $message): void
|
||||||
|
{
|
||||||
|
if ($expected !== $actual) {
|
||||||
|
throw new \Exception($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertStringOrNull(?string $expected, ?string $actual, string $message): void
|
||||||
|
{
|
||||||
|
if ($expected !== $actual) {
|
||||||
|
throw new \Exception($message . ' expected=' . var_export($expected, true) . ' actual=' . var_export($actual, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsIsoDateValidCases(): void
|
||||||
|
{
|
||||||
|
$valid = [
|
||||||
|
'2024-10-24',
|
||||||
|
'2024-02-29',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($valid as $date) {
|
||||||
|
$this->assertBool(true, ChronoID::isIsoDate($date), 'Expected valid ISO date: ' . $date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsIsoDateInvalidCases(): void
|
||||||
|
{
|
||||||
|
$invalid = [
|
||||||
|
'2023-02-29',
|
||||||
|
'2024-13-01',
|
||||||
|
'2024-00-10',
|
||||||
|
'24-10-2024',
|
||||||
|
'2024/10/24',
|
||||||
|
'2024-10-24 12:00:00',
|
||||||
|
'2024-10-24T12:00:00',
|
||||||
|
'0000-01-01',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($invalid as $date) {
|
||||||
|
$this->assertBool(false, ChronoID::isIsoDate($date), 'Expected invalid ISO date: ' . $date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDateToDayId(): void
|
||||||
|
{
|
||||||
|
$this->assertStringOrNull('chronological:2024:10:24', ChronoID::dateToDayId('2024-10-24'), 'dateToDayId failed');
|
||||||
|
$this->assertStringOrNull('journal:chrono:2024:10:24', ChronoID::dateToDayId('2024-10-24', 'journal:chrono'), 'dateToDayId with custom namespace failed');
|
||||||
|
|
||||||
|
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24T12:00:00'), 'datetime should be rejected');
|
||||||
|
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-13-01'), 'invalid month should be rejected');
|
||||||
|
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24', ''), 'empty namespace should be rejected');
|
||||||
|
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24', 'bad namespace!'), 'invalid namespace should be rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanonicalIdChecks(): void
|
||||||
|
{
|
||||||
|
$this->assertBool(true, ChronoID::isDayId('chronological:2024:10:24'), 'valid day ID should be accepted');
|
||||||
|
$this->assertBool(true, ChronoID::isMonthId('chronological:2024:10'), 'valid month ID should be accepted');
|
||||||
|
$this->assertBool(true, ChronoID::isYearId('chronological:2024'), 'valid year ID should be accepted');
|
||||||
|
|
||||||
|
$this->assertBool(false, ChronoID::isDayId('2024:10:24'), 'missing namespace should be rejected as day ID');
|
||||||
|
$this->assertBool(false, ChronoID::isDayId('chronological:2024-10-24'), 'hyphen date in ID should be rejected as day ID');
|
||||||
|
$this->assertBool(false, ChronoID::isDayId('chronological:2023:02:29'), 'invalid Gregorian day should be rejected');
|
||||||
|
|
||||||
|
$this->assertBool(false, ChronoID::isMonthId('chronological:2024:13'), 'invalid month 13 should be rejected');
|
||||||
|
$this->assertBool(false, ChronoID::isMonthId('chronological:2024:00'), 'invalid month 00 should be rejected');
|
||||||
|
$this->assertBool(false, ChronoID::isMonthId('chronological:2024-10'), 'invalid month format should be rejected');
|
||||||
|
|
||||||
|
$this->assertBool(false, ChronoID::isYearId('chronological:0000'), 'year 0000 should be rejected');
|
||||||
|
$this->assertBool(false, ChronoID::isYearId('chronological:24'), 'short year should be rejected');
|
||||||
|
$this->assertBool(false, ChronoID::isYearId('chronological:2024:10'), 'month ID should not pass as year ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConversions(): void
|
||||||
|
{
|
||||||
|
$this->assertStringOrNull('chronological:2024:10', ChronoID::dayIdToMonthId('chronological:2024:10:24'), 'dayIdToMonthId failed');
|
||||||
|
$this->assertStringOrNull('chronological:2024', ChronoID::monthIdToYearId('chronological:2024:10'), 'monthIdToYearId failed');
|
||||||
|
|
||||||
|
$this->assertStringOrNull(null, ChronoID::dayIdToMonthId('chronological:2024:13:24'), 'invalid day ID should map to null month ID');
|
||||||
|
$this->assertStringOrNull(null, ChronoID::dayIdToMonthId('chronological:2024:10'), 'month ID should not map via dayIdToMonthId');
|
||||||
|
$this->assertStringOrNull(null, ChronoID::monthIdToYearId('chronological:2024:13'), 'invalid month ID should map to null year ID');
|
||||||
|
$this->assertStringOrNull(null, ChronoID::monthIdToYearId('chronological:2024:10:24'), 'day ID should not map via monthIdToYearId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration-style smoke test for canonical ID matrix acceptance/rejection.
|
||||||
|
*/
|
||||||
|
public function testCanonicalPageIdSmokeMatrix(): void
|
||||||
|
{
|
||||||
|
$accepted = [
|
||||||
|
['day', 'chronological:2024:10:24'],
|
||||||
|
['month', 'chronological:2024:10'],
|
||||||
|
['year', 'chronological:2024'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($accepted as [$kind, $id]) {
|
||||||
|
if ($kind === 'day') {
|
||||||
|
$this->assertBool(true, ChronoID::isDayId($id), 'Expected accepted day ID: ' . $id);
|
||||||
|
} elseif ($kind === 'month') {
|
||||||
|
$this->assertBool(true, ChronoID::isMonthId($id), 'Expected accepted month ID: ' . $id);
|
||||||
|
} else {
|
||||||
|
$this->assertBool(true, ChronoID::isYearId($id), 'Expected accepted year ID: ' . $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rejected = [
|
||||||
|
'2024:10:24',
|
||||||
|
'chronological:2024-10-24',
|
||||||
|
'chronological:2024:13:01',
|
||||||
|
'chronological:2024:00',
|
||||||
|
'chronological:0000',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($rejected as $id) {
|
||||||
|
$this->assertBool(false, ChronoID::isDayId($id), 'Unexpected day ID acceptance: ' . $id);
|
||||||
|
$this->assertBool(false, ChronoID::isMonthId($id), 'Unexpected month ID acceptance: ' . $id);
|
||||||
|
$this->assertBool(false, ChronoID::isYearId($id), 'Unexpected year ID acceptance: ' . $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
_test/ChronologicalDateAutoLinkerTest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
|
||||||
|
use DokuWikiTest;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for extracted chronological auto-linker.
|
||||||
|
*
|
||||||
|
* @group plugin_luxtools
|
||||||
|
* @group plugins
|
||||||
|
*/
|
||||||
|
class ChronologicalDateAutoLinkerTest extends DokuWikiTest
|
||||||
|
{
|
||||||
|
public function testLinksPlainTextDate(): void
|
||||||
|
{
|
||||||
|
$html = '<p>Meeting on 2024-10-24</p>';
|
||||||
|
$out = ChronologicalDateAutoLinker::linkHtml($html);
|
||||||
|
|
||||||
|
$decoded = urldecode($out);
|
||||||
|
if (strpos($decoded, 'chronological:2024:10:24') === false) {
|
||||||
|
throw new \Exception('Expected canonical link target not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSkipsCodeContent(): void
|
||||||
|
{
|
||||||
|
$html = '<p>Outside 2024-10-25</p><code>Inside 2024-10-24</code>';
|
||||||
|
$out = ChronologicalDateAutoLinker::linkHtml($html);
|
||||||
|
|
||||||
|
$decoded = urldecode($out);
|
||||||
|
if (strpos($decoded, 'chronological:2024:10:25') === false) {
|
||||||
|
throw new \Exception('Expected outside date link not found');
|
||||||
|
}
|
||||||
|
if (strpos($decoded, 'chronological:2024:10:24') !== false) {
|
||||||
|
throw new \Exception('Date inside code block should not be auto-linked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
_test/ChronologicalDayTemplateTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
|
||||||
|
use DokuWikiTest;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for German day template generation.
|
||||||
|
*
|
||||||
|
* @group plugin_luxtools
|
||||||
|
* @group plugins
|
||||||
|
*/
|
||||||
|
class ChronologicalDayTemplateTest extends DokuWikiTest
|
||||||
|
{
|
||||||
|
public function testBuildForDayIdGermanHeading(): void
|
||||||
|
{
|
||||||
|
$tpl = ChronologicalDayTemplate::buildForDayId('chronological:2026:02:13');
|
||||||
|
if (!is_string($tpl) || $tpl === '') {
|
||||||
|
throw new \Exception('Expected non-empty day template');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($tpl, '====== Freitag, 13. Februar 2026 ======') === false) {
|
||||||
|
throw new \Exception('Expected German formatted heading not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildForDayIdRejectsInvalid(): void
|
||||||
|
{
|
||||||
|
$tpl = ChronologicalDayTemplate::buildForDayId('chronological:2026:02');
|
||||||
|
if ($tpl !== null) {
|
||||||
|
throw new \Exception('Expected null for non-day ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
_test/ChronologicalIcsEventsTest.php
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
|
||||||
|
use DokuWikiTest;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for local ICS event parsing.
|
||||||
|
*
|
||||||
|
* @group plugin_luxtools
|
||||||
|
* @group plugins
|
||||||
|
*/
|
||||||
|
class ChronologicalIcsEventsTest extends DokuWikiTest
|
||||||
|
{
|
||||||
|
public function testEventsForDateParsesAllDayAndTimedEntries(): void
|
||||||
|
{
|
||||||
|
$dir = TMP_DIR . '/chrono_ics/' . uniqid('case_', true);
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
$ics = $dir . '/calendar.ics';
|
||||||
|
|
||||||
|
$content = "BEGIN:VCALENDAR\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "DTSTART;VALUE=DATE:20260216\n"
|
||||||
|
. "SUMMARY:Ganztag Event\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "DTSTART:20260216T134500\n"
|
||||||
|
. "SUMMARY:Termin A\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "DTSTART:20260217T090000\n"
|
||||||
|
. "SUMMARY:Anderer Tag\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "END:VCALENDAR\n";
|
||||||
|
@file_put_contents($ics, $content);
|
||||||
|
|
||||||
|
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
|
||||||
|
if (count($events) !== 2) {
|
||||||
|
throw new \Exception('Expected 2 events for 2026-02-16, got ' . count($events));
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
|
||||||
|
if (!in_array('Ganztag Event', $summaries, true)) {
|
||||||
|
throw new \Exception('Missing all-day event summary');
|
||||||
|
}
|
||||||
|
if (!in_array('Termin A', $summaries, true)) {
|
||||||
|
throw new \Exception('Missing timed event summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
$timed = null;
|
||||||
|
foreach ($events as $event) {
|
||||||
|
if ((string)($event['summary'] ?? '') === 'Termin A') {
|
||||||
|
$timed = $event;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($timed)) {
|
||||||
|
throw new \Exception('Timed event payload missing');
|
||||||
|
}
|
||||||
|
if (trim((string)($timed['startIso'] ?? '')) === '') {
|
||||||
|
throw new \Exception('Timed event should expose startIso for client-side timezone conversion');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsForDateReadsMultipleConfiguredFiles(): void
|
||||||
|
{
|
||||||
|
$dir = TMP_DIR . '/chrono_ics/' . uniqid('multi_', true);
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
$ics1 = $dir . '/one.ics';
|
||||||
|
$ics2 = $dir . '/two.ics';
|
||||||
|
|
||||||
|
@file_put_contents($ics1, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T100000\nSUMMARY:Eins\nEND:VEVENT\nEND:VCALENDAR\n");
|
||||||
|
@file_put_contents($ics2, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T110000\nSUMMARY:Zwei\nEND:VEVENT\nEND:VCALENDAR\n");
|
||||||
|
|
||||||
|
$config = $ics1 . "\n" . $ics2;
|
||||||
|
$events = ChronologicalIcsEvents::eventsForDate($config, '2026-02-18');
|
||||||
|
|
||||||
|
if (count($events) !== 2) {
|
||||||
|
throw new \Exception('Expected 2 events from two files, got ' . count($events));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsForDateSupportsWeeklyRecurrence(): void
|
||||||
|
{
|
||||||
|
$dir = TMP_DIR . '/chrono_ics/' . uniqid('rrule_', true);
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
$ics = $dir . '/recurring.ics';
|
||||||
|
|
||||||
|
$content = "BEGIN:VCALENDAR\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "UID:weekly-1\n"
|
||||||
|
. "DTSTART:20260205T090000\n"
|
||||||
|
. "RRULE:FREQ=WEEKLY;INTERVAL=1\n"
|
||||||
|
. "SUMMARY:Wiederkehrender Termin\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "END:VCALENDAR\n";
|
||||||
|
@file_put_contents($ics, $content);
|
||||||
|
|
||||||
|
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12');
|
||||||
|
if (count($events) < 1) {
|
||||||
|
throw new \Exception('Expected recurring event on 2026-02-12, got none');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
|
||||||
|
if (!in_array('Wiederkehrender Termin', $summaries, true)) {
|
||||||
|
throw new \Exception('Recurring summary not found on matching date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsForDateRespectsExdateForRecurringEvent(): void
|
||||||
|
{
|
||||||
|
$dir = TMP_DIR . '/chrono_ics/' . uniqid('exdate_', true);
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
$ics = $dir . '/recurring-exdate.ics';
|
||||||
|
|
||||||
|
$content = "BEGIN:VCALENDAR\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "UID:weekly-2\n"
|
||||||
|
. "DTSTART:20260205T090000\n"
|
||||||
|
. "RRULE:FREQ=WEEKLY;COUNT=4\n"
|
||||||
|
. "EXDATE:20260212T090000\n"
|
||||||
|
. "SUMMARY:Termin mit Ausnahme\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "END:VCALENDAR\n";
|
||||||
|
@file_put_contents($ics, $content);
|
||||||
|
|
||||||
|
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12');
|
||||||
|
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
|
||||||
|
if (in_array('Termin mit Ausnahme', $summaries, true)) {
|
||||||
|
throw new \Exception('Recurring event with EXDATE should not appear on excluded day');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsForDateKeepsUtcDateAndTimeAsIs(): void
|
||||||
|
{
|
||||||
|
$previousTimezone = date_default_timezone_get();
|
||||||
|
date_default_timezone_set('Europe/Berlin');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dir = TMP_DIR . '/chrono_ics/' . uniqid('tz_', true);
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
$ics = $dir . '/timezone.ics';
|
||||||
|
|
||||||
|
$content = "BEGIN:VCALENDAR\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "UID:utc-shift\n"
|
||||||
|
. "DTSTART:20260216T233000Z\n"
|
||||||
|
. "SUMMARY:UTC Spaet\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "END:VCALENDAR\n";
|
||||||
|
@file_put_contents($ics, $content);
|
||||||
|
|
||||||
|
$eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
|
||||||
|
$eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17');
|
||||||
|
|
||||||
|
$summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16);
|
||||||
|
$summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17);
|
||||||
|
|
||||||
|
if (!in_array('UTC Spaet', $summaries16, true)) {
|
||||||
|
throw new \Exception('UTC event should stay on its own UTC date');
|
||||||
|
}
|
||||||
|
if (in_array('UTC Spaet', $summaries17, true)) {
|
||||||
|
throw new \Exception('UTC event should not be shifted to next day by server timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
$utcEvent = null;
|
||||||
|
foreach ($eventsOn16 as $entry) {
|
||||||
|
if ((string)($entry['summary'] ?? '') === 'UTC Spaet') {
|
||||||
|
$utcEvent = $entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($utcEvent)) {
|
||||||
|
throw new \Exception('UTC event payload missing after day match');
|
||||||
|
}
|
||||||
|
if ((string)($utcEvent['time'] ?? '') !== '23:30') {
|
||||||
|
throw new \Exception('UTC event time should remain unchanged (expected 23:30)');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
date_default_timezone_set($previousTimezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsForDateShowsMultiDayAllDayEventOnOverlappingDays(): void
|
||||||
|
{
|
||||||
|
$dir = TMP_DIR . '/chrono_ics/' . uniqid('multiday_', true);
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
$ics = $dir . '/multiday.ics';
|
||||||
|
|
||||||
|
$content = "BEGIN:VCALENDAR\n"
|
||||||
|
. "BEGIN:VEVENT\n"
|
||||||
|
. "UID:multi-day-1\n"
|
||||||
|
. "DTSTART;VALUE=DATE:20260216\n"
|
||||||
|
. "DTEND;VALUE=DATE:20260218\n"
|
||||||
|
. "SUMMARY:Mehrtagesereignis\n"
|
||||||
|
. "END:VEVENT\n"
|
||||||
|
. "END:VCALENDAR\n";
|
||||||
|
@file_put_contents($ics, $content);
|
||||||
|
|
||||||
|
$eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
|
||||||
|
$eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17');
|
||||||
|
$eventsOn18 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-18');
|
||||||
|
|
||||||
|
$summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16);
|
||||||
|
$summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17);
|
||||||
|
$summaries18 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn18);
|
||||||
|
|
||||||
|
if (!in_array('Mehrtagesereignis', $summaries16, true)) {
|
||||||
|
throw new \Exception('Multi-day all-day event should appear on start day');
|
||||||
|
}
|
||||||
|
if (!in_array('Mehrtagesereignis', $summaries17, true)) {
|
||||||
|
throw new \Exception('Multi-day all-day event should appear on overlapping day');
|
||||||
|
}
|
||||||
|
if (in_array('Mehrtagesereignis', $summaries18, true)) {
|
||||||
|
throw new \Exception('Multi-day all-day event should respect exclusive DTEND day');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace dokuwiki\plugin\filelist\test;
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
use DokuWikiTest;
|
use DokuWikiTest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General tests for the filelist plugin
|
* General tests for the luxtools plugin
|
||||||
*
|
*
|
||||||
* @group plugin_filelist
|
* @group plugin_luxtools
|
||||||
* @group plugins
|
* @group plugins
|
||||||
*/
|
*/
|
||||||
class GeneralTest extends DokuWikiTest
|
class GeneralTest extends DokuWikiTest
|
||||||
@@ -31,7 +31,7 @@ class GeneralTest extends DokuWikiTest
|
|||||||
$this->assertArrayHasKey('desc', $info);
|
$this->assertArrayHasKey('desc', $info);
|
||||||
$this->assertArrayHasKey('url', $info);
|
$this->assertArrayHasKey('url', $info);
|
||||||
|
|
||||||
$this->assertEquals('filelist', $info['base']);
|
$this->assertEquals('luxtools', $info['base']);
|
||||||
$this->assertRegExp('/^https?:\/\//', $info['url']);
|
$this->assertRegExp('/^https?:\/\//', $info['url']);
|
||||||
$this->assertTrue(mail_isvalid($info['email']));
|
$this->assertTrue(mail_isvalid($info['email']));
|
||||||
$this->assertRegExp('/^\d\d\d\d-\d\d-\d\d$/', $info['date']);
|
$this->assertRegExp('/^\d\d\d\d-\d\d-\d\d$/', $info['date']);
|
||||||
@@ -39,48 +39,44 @@ class GeneralTest extends DokuWikiTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test to ensure that every conf['...'] entry in conf/default.php has a corresponding meta['...'] entry in
|
* luxtools settings are managed via the plugin's admin page, not via the Configuration Manager.
|
||||||
* conf/metadata.php.
|
* Ensure default config exists and (when present) metadata.php does not expose any settings.
|
||||||
*/
|
*/
|
||||||
public function testPluginConf(): void
|
public function testPluginConf(): void
|
||||||
{
|
{
|
||||||
$conf_file = __DIR__ . '/../conf/default.php';
|
$conf_file = __DIR__ . '/../conf/default.php';
|
||||||
$meta_file = __DIR__ . '/../conf/metadata.php';
|
$meta_file = __DIR__ . '/../conf/metadata.php';
|
||||||
|
|
||||||
if (!file_exists($conf_file) && !file_exists($meta_file)) {
|
if (!file_exists($conf_file)) {
|
||||||
self::markTestSkipped('No config files exist -> skipping test');
|
self::markTestSkipped('No config default.php exists -> skipping test');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file_exists($conf_file)) {
|
$conf = null;
|
||||||
|
$meta = null;
|
||||||
|
|
||||||
include($conf_file);
|
include($conf_file);
|
||||||
}
|
$this->assertIsArray(
|
||||||
|
$conf,
|
||||||
|
'The ' . DOKU_PLUGIN . 'luxtools/conf/default.php file needs to define $conf as an array.'
|
||||||
|
);
|
||||||
|
|
||||||
if (file_exists($meta_file)) {
|
if (file_exists($meta_file)) {
|
||||||
include($meta_file);
|
include($meta_file);
|
||||||
|
|
||||||
|
if ($meta === null) {
|
||||||
|
// If the file exists but does not define $meta, treat it as empty.
|
||||||
|
$meta = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertEquals(
|
$this->assertIsArray(
|
||||||
gettype($conf),
|
|
||||||
gettype($meta),
|
|
||||||
'Both ' . DOKU_PLUGIN . 'filelist/conf/default.php and ' . DOKU_PLUGIN . 'filelist/conf/metadata.php have to exist and contain the same keys.'
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conf !== null && $meta !== null) {
|
|
||||||
foreach ($conf as $key => $value) {
|
|
||||||
$this->assertArrayHasKey(
|
|
||||||
$key,
|
|
||||||
$meta,
|
$meta,
|
||||||
'Key $meta[\'' . $key . '\'] missing in ' . DOKU_PLUGIN . 'filelist/conf/metadata.php'
|
'The ' . DOKU_PLUGIN . 'luxtools/conf/metadata.php file needs to define $meta as an array.'
|
||||||
);
|
);
|
||||||
}
|
$this->assertEmpty(
|
||||||
|
$meta,
|
||||||
foreach ($meta as $key => $value) {
|
'luxtools should not expose settings via the Configuration Manager.'
|
||||||
$this->assertArrayHasKey(
|
|
||||||
$key,
|
|
||||||
$conf,
|
|
||||||
'Key $conf[\'' . $key . '\'] missing in ' . DOKU_PLUGIN . 'filelist/conf/default.php'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace dokuwiki\plugin\filelist\test;
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
use dokuwiki\plugin\filelist\Path;
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
use DokuWikiTest;
|
use DokuWikiTest;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Path related tests for the filelist plugin
|
* Path related tests for the luxtools plugin
|
||||||
*
|
*
|
||||||
* @group plugin_filelist
|
* @group plugin_luxtools
|
||||||
* @group plugins
|
* @group plugins
|
||||||
*/
|
*/
|
||||||
class PathTest extends DokuWikiTest
|
class PathTest extends DokuWikiTest
|
||||||
@@ -27,7 +29,6 @@ C:\\xampp\\htdocs\\wiki\\
|
|||||||
/linux/file/path/
|
/linux/file/path/
|
||||||
/linux/another/path/../..//another/blargh/../path
|
/linux/another/path/../..//another/blargh/../path
|
||||||
A> alias
|
A> alias
|
||||||
W> webfoo
|
|
||||||
EOT
|
EOT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,25 +41,25 @@ EOT
|
|||||||
$expect = [
|
$expect = [
|
||||||
'C:/xampp/htdocs/wiki/' => [
|
'C:/xampp/htdocs/wiki/' => [
|
||||||
'root' => 'C:/xampp/htdocs/wiki/',
|
'root' => 'C:/xampp/htdocs/wiki/',
|
||||||
'web' => '/lib/plugins/filelist/file.php?root=C%3A%2Fxampp%2Fhtdocs%2Fwiki%2F&file=',
|
'web' => '/lib/plugins/luxtools/file.php?root=C%3A%2Fxampp%2Fhtdocs%2Fwiki%2F&file=',
|
||||||
],
|
],
|
||||||
'\\\\server/share/path/' => [
|
'\\\\server/share/path/' => [
|
||||||
'root' => '\\\\server/share/path/',
|
'root' => '\\\\server/share/path/',
|
||||||
'web' => '/lib/plugins/filelist/file.php?root=%5C%5Cserver%2Fshare%2Fpath%2F&file=',
|
'web' => '/lib/plugins/luxtools/file.php?root=%5C%5Cserver%2Fshare%2Fpath%2F&file=',
|
||||||
],
|
],
|
||||||
'/linux/file/path/' => [
|
'/linux/file/path/' => [
|
||||||
'root' => '/linux/file/path/',
|
'root' => '/linux/file/path/',
|
||||||
'web' => '/lib/plugins/filelist/file.php?root=%2Flinux%2Ffile%2Fpath%2F&file=',
|
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Ffile%2Fpath%2F&file=',
|
||||||
],
|
],
|
||||||
'/linux/another/path/' => [
|
'/linux/another/path/' => [
|
||||||
'root' => '/linux/another/path/',
|
'root' => '/linux/another/path/',
|
||||||
'alias' => 'alias/',
|
'alias' => 'alias/',
|
||||||
'web' => 'webfoo',
|
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
|
||||||
],
|
],
|
||||||
'alias/' => [
|
'alias/' => [
|
||||||
'root' => '/linux/another/path/',
|
'root' => '/linux/another/path/',
|
||||||
'alias' => 'alias/',
|
'alias' => 'alias/',
|
||||||
'web' => 'webfoo',
|
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,4 +111,24 @@ EOT
|
|||||||
$this->expectExceptionMessageMatches('/Path not allowed/');
|
$this->expectExceptionMessageMatches('/Path not allowed/');
|
||||||
$this->path->getPathInfo($path);
|
$this->path->getPathInfo($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMapToAliasPath()
|
||||||
|
{
|
||||||
|
$mapped = $this->path->mapToAliasPath('/linux/another/path/some/folder');
|
||||||
|
$this->assertEquals('alias>some/folder', $mapped);
|
||||||
|
|
||||||
|
$mappedRoot = $this->path->mapToAliasPath('/linux/another/path/');
|
||||||
|
$this->assertEquals('alias>', $mappedRoot);
|
||||||
|
|
||||||
|
$unmapped = $this->path->mapToAliasPath('/linux/file/path/example.txt');
|
||||||
|
$this->assertEquals('/linux/file/path/example.txt', $unmapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMapToAliasPathLegacyAliasStyle()
|
||||||
|
{
|
||||||
|
$path = new Path("/srv/share/Datascape/\nA> /Scape/\n");
|
||||||
|
|
||||||
|
$mapped = $path->mapToAliasPath('/srv/share/Datascape/projects/demo');
|
||||||
|
$this->assertEquals('Scape>projects/demo', $mapped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
_test/ScratchpadMapTest.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
||||||
|
use DokuWikiTest;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScratchpadMap tests for the luxtools plugin
|
||||||
|
*
|
||||||
|
* @group plugin_luxtools
|
||||||
|
* @group plugins
|
||||||
|
*/
|
||||||
|
class ScratchpadMapTest extends DokuWikiTest
|
||||||
|
{
|
||||||
|
public function testResolveAndParsing()
|
||||||
|
{
|
||||||
|
$map = new ScratchpadMap(
|
||||||
|
<<<EOT
|
||||||
|
/var/scratchpads/startpad.txt
|
||||||
|
A> start
|
||||||
|
|
||||||
|
C:\\pads\\notes.md
|
||||||
|
A> notes
|
||||||
|
|
||||||
|
\\\\server\\share\\pads\\team.txt
|
||||||
|
A> team
|
||||||
|
EOT
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals('/var/scratchpads/startpad.txt', $map->resolve('start'));
|
||||||
|
$this->assertEquals('C:/pads/notes.md', $map->resolve('notes'));
|
||||||
|
$this->assertEquals('\\\\server/share/pads/team.txt', $map->resolve('team'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownAliasThrows()
|
||||||
|
{
|
||||||
|
$map = new ScratchpadMap("/tmp/pad.txt\nA> pad\n");
|
||||||
|
$this->expectExceptionMessageMatches('/Unknown scratchpad alias/');
|
||||||
|
$map->resolve('nope');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace dokuwiki\plugin\filelist\test;
|
namespace dokuwiki\plugin\luxtools\test;
|
||||||
|
|
||||||
use DokuWikiTest;
|
use DokuWikiTest;
|
||||||
use DOMWrap\Document;
|
use DOMWrap\Document;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for the filelist plugin.
|
* Tests for the luxtools plugin.
|
||||||
*
|
*
|
||||||
* These test assume that the directory filelist has the following content:
|
* These test assume that the directory luxtools has the following content:
|
||||||
* - exampledir (directory)
|
* - exampledir (directory)
|
||||||
* - example2.txt (text file)
|
* - example2.txt (text file)
|
||||||
* - example.txt (text file)
|
* - example.txt (text file)
|
||||||
* - exampleimage.png (image file)
|
* - exampleimage.png (image file)
|
||||||
*
|
*
|
||||||
* @group plugin_filelist
|
* @group plugin_luxtools
|
||||||
* @group plugins
|
* @group plugins
|
||||||
*/
|
*/
|
||||||
class plugin_filelist_test extends DokuWikiTest
|
class plugin_luxtools_test extends DokuWikiTest
|
||||||
{
|
{
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
global $conf;
|
global $conf;
|
||||||
|
|
||||||
$this->pluginsEnabled[] = 'filelist';
|
$this->pluginsEnabled[] = 'luxtools';
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
// Setup config so that access to the TMP directory will be allowed
|
// Setup config so that access to the TMP directory will be allowed
|
||||||
$conf ['plugin']['filelist']['paths'] = TMP_DIR . '/filelistdata/' . "\n" . 'W> http://localhost/';
|
// Use the built-in file.php endpoint.
|
||||||
|
$conf ['plugin']['luxtools']['paths'] = TMP_DIR . '/filelistdata/' . "\n" . 'A> /Scape';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +71,14 @@ class plugin_filelist_test extends DokuWikiTest
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This function checks that all files are listed in not recursive mode.
|
* This function checks that all files are listed in not recursive mode.
|
||||||
|
* Uses {{files>...}} syntax for backwards compatibility (now handled by directory syntax).
|
||||||
*/
|
*/
|
||||||
public function test_not_recursive()
|
public function test_not_recursive()
|
||||||
{
|
{
|
||||||
global $conf;
|
global $conf;
|
||||||
|
|
||||||
// Render filelist
|
// Render filelist using files syntax (now handled by directory plugin)
|
||||||
$instructions = p_get_instructions('{{filelist>' . TMP_DIR . '/filelistdata/*&style=list&direct=1}}');
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&direct=1}}');
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
// We should find:
|
// We should find:
|
||||||
@@ -90,11 +92,12 @@ class plugin_filelist_test extends DokuWikiTest
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This function checks that all files are listed in recursive mode.
|
* This function checks that all files are listed in recursive mode.
|
||||||
|
* Uses {{files>...}} syntax for backwards compatibility (now handled by directory syntax).
|
||||||
*/
|
*/
|
||||||
public function test_recursive()
|
public function test_recursive()
|
||||||
{
|
{
|
||||||
// Render filelist
|
// Render filelist using files syntax (now handled by directory plugin)
|
||||||
$instructions = p_get_instructions('{{filelist>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}');
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&direct=1&recursive=1}}');
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
// We should find:
|
// We should find:
|
||||||
@@ -113,54 +116,46 @@ class plugin_filelist_test extends DokuWikiTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function checks that the unordered list mode
|
* This function checks the rendering when style=list is explicitly specified.
|
||||||
* generates the expected XHTML structure.
|
* Note: The files syntax is now handled by directory syntax and always renders as table.
|
||||||
|
* This test is kept for backwards compatibility testing but expects table structure.
|
||||||
*/
|
*/
|
||||||
public function testUnorderedList()
|
public function testUnorderedList()
|
||||||
{
|
{
|
||||||
// Render filelist
|
// Render filelist with explicit style=list (now ignored, renders as table)
|
||||||
$instructions = p_get_instructions('{{filelist>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}');
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}');
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
$doc = new Document();
|
$doc = new Document();
|
||||||
$doc->html($xhtml);
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
// Now renders as a table instead of list
|
||||||
$structure = [
|
$structure = [
|
||||||
'div.filelist-plugin' => 1,
|
'div.luxtools-plugin' => 1,
|
||||||
'div.filelist-plugin > ul' => 1,
|
'div.luxtools-plugin table' => 1,
|
||||||
'div.filelist-plugin > ul > li' => 3,
|
|
||||||
'div.filelist-plugin > ul > li:nth-child(1)' => 1,
|
|
||||||
'div.filelist-plugin > ul > li:nth-child(1) a' => 'example.txt',
|
|
||||||
'div.filelist-plugin > ul > li:nth-child(2) ul' => 1,
|
|
||||||
'div.filelist-plugin > ul > li:nth-child(2) ul > li' => 1,
|
|
||||||
'div.filelist-plugin > ul > li:nth-child(2) ul > li a' => 'example2.txt',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->structureCheck($doc, $structure);
|
$this->structureCheck($doc, $structure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function checks that the ordered list mode
|
* This function checks the rendering when style=olist is explicitly specified.
|
||||||
* generates the expected XHTML structure.
|
* Note: The files syntax is now handled by directory syntax and always renders as table.
|
||||||
|
* This test is kept for backwards compatibility testing but expects table structure.
|
||||||
*/
|
*/
|
||||||
public function testOrderedList()
|
public function testOrderedList()
|
||||||
{
|
{
|
||||||
// Render filelist
|
// Render filelist with explicit style=olist (now ignored, renders as table)
|
||||||
$instructions = p_get_instructions('{{filelist>' . TMP_DIR . '/filelistdata/*&style=olist&direct=1&recursive=1}}');
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=olist&direct=1&recursive=1}}');
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
$doc = new Document();
|
$doc = new Document();
|
||||||
$doc->html($xhtml);
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
// Now renders as a table instead of ordered list
|
||||||
$structure = [
|
$structure = [
|
||||||
'div.filelist-plugin' => 1,
|
'div.luxtools-plugin' => 1,
|
||||||
'div.filelist-plugin > ol' => 1,
|
'div.luxtools-plugin table' => 1,
|
||||||
'div.filelist-plugin > ol > li' => 3,
|
|
||||||
'div.filelist-plugin > ol > li:nth-child(1)' => 1,
|
|
||||||
'div.filelist-plugin > ol > li:nth-child(1) a' => 'example.txt',
|
|
||||||
'div.filelist-plugin > ol > li:nth-child(2) ol' => 1,
|
|
||||||
'div.filelist-plugin > ol > li:nth-child(2) ol > li' => 1,
|
|
||||||
'div.filelist-plugin > ol > li:nth-child(2) ol > li a' => 'example2.txt',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->structureCheck($doc, $structure);
|
$this->structureCheck($doc, $structure);
|
||||||
@@ -175,21 +170,355 @@ class plugin_filelist_test extends DokuWikiTest
|
|||||||
global $conf;
|
global $conf;
|
||||||
|
|
||||||
// Render filelist
|
// Render filelist
|
||||||
$instructions = p_get_instructions('{{filelist>' . TMP_DIR . '/filelistdata/*&style=table&direct=1&recursive=1}}');
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=table&direct=1&recursive=1}}');
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
$doc = new Document();
|
$doc = new Document();
|
||||||
$doc->html($xhtml);
|
$doc->html($xhtml);
|
||||||
|
|
||||||
$structure = [
|
$structure = [
|
||||||
'div.filelist-plugin' => 1,
|
'div.luxtools-plugin' => 1,
|
||||||
'div.filelist-plugin table' => 1,
|
'div.luxtools-plugin table' => 1,
|
||||||
'div.filelist-plugin table > tbody > tr' => 3,
|
'div.luxtools-plugin table > tbody > tr' => 3,
|
||||||
'div.filelist-plugin table > tbody > tr:nth-child(1) a' => 'example.txt',
|
'div.luxtools-plugin table > tbody > tr:nth-child(1) a' => 'example.txt',
|
||||||
'div.filelist-plugin table > tbody > tr:nth-child(2) a' => 'exampledir/example2.txt',
|
'div.luxtools-plugin table > tbody > tr:nth-child(2) a' => 'exampledir/example2.txt',
|
||||||
'div.filelist-plugin table > tbody > tr:nth-child(3) a' => 'exampleimage.png',
|
'div.luxtools-plugin table > tbody > tr:nth-child(3) a' => 'exampleimage.png',
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->structureCheck($doc, $structure);
|
$this->structureCheck($doc, $structure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_default_maxheight_applies_scroll()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$style = (string)$doc->find('div.luxtools-plugin')->attr('style');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('max-height: 500px', $style);
|
||||||
|
$this->assertStringContainsString('overflow-y: auto', $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_maxheight_can_be_disabled()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=table&direct=1&recursive=1&maxheight=-1}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$style = $doc->find('div.luxtools-plugin')->attr('style');
|
||||||
|
|
||||||
|
$this->assertTrue($style === null || $style === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function checks that the images syntax renders a thumbnail gallery.
|
||||||
|
*/
|
||||||
|
public function test_images_gallery()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{images>' . TMP_DIR . '/filelistdata/*&direct=1}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'div.luxtools-plugin.luxtools-gallery' => 1,
|
||||||
|
'div.luxtools-plugin.luxtools-gallery a' => 1,
|
||||||
|
'div.luxtools-plugin.luxtools-gallery img' => 1,
|
||||||
|
];
|
||||||
|
$this->structureCheck($doc, $structure);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('exampleimage.png', $xhtml);
|
||||||
|
$this->assertStringContainsString('thumb=1', $xhtml);
|
||||||
|
$this->assertStringContainsString('width="150"', $xhtml);
|
||||||
|
$this->assertStringContainsString('height="150"', $xhtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grouping wrapper should use default flex mode with zero gap.
|
||||||
|
*/
|
||||||
|
public function test_grouping_default_flex()
|
||||||
|
{
|
||||||
|
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
||||||
|
$syntax = '<grouping>'
|
||||||
|
. '{{image>' . $imagePath . '|One|120}}'
|
||||||
|
. '{{image>' . $imagePath . '|Two|120}}'
|
||||||
|
. '</grouping>';
|
||||||
|
|
||||||
|
$instructions = p_get_instructions($syntax);
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
|
||||||
|
'div.luxtools-grouping .luxtools-imagebox' => 2,
|
||||||
|
];
|
||||||
|
$this->structureCheck($doc, $structure);
|
||||||
|
|
||||||
|
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-cols: 2', $style);
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-gap: 0', $style);
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-justify: start', $style);
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-align: start', $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grouping wrapper should accept custom flex layout and gap.
|
||||||
|
*/
|
||||||
|
public function test_grouping_custom_flex()
|
||||||
|
{
|
||||||
|
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
||||||
|
$syntax = '<grouping layout="flex" gap="8px">'
|
||||||
|
. '{{image>' . $imagePath . '|One|120}}'
|
||||||
|
. '{{image>' . $imagePath . '|Two|120}}'
|
||||||
|
. '</grouping>';
|
||||||
|
|
||||||
|
$instructions = p_get_instructions($syntax);
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
|
||||||
|
'div.luxtools-grouping .luxtools-imagebox' => 2,
|
||||||
|
];
|
||||||
|
$this->structureCheck($doc, $structure);
|
||||||
|
|
||||||
|
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-gap: 8px', $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grouping wrapper should accept justify and align controls.
|
||||||
|
*/
|
||||||
|
public function test_grouping_justify_and_align()
|
||||||
|
{
|
||||||
|
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
||||||
|
$syntax = '<grouping layout="flex" justify="space-between" align="center">'
|
||||||
|
. '{{image>' . $imagePath . '|One|120}}'
|
||||||
|
. '{{image>' . $imagePath . '|Two|120}}'
|
||||||
|
. '</grouping>';
|
||||||
|
|
||||||
|
$instructions = p_get_instructions($syntax);
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-justify: space-between', $style);
|
||||||
|
$this->assertStringContainsString('--luxtools-grouping-align: center', $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown grouping attributes should render a warning string.
|
||||||
|
*/
|
||||||
|
public function test_grouping_unknown_option_warning()
|
||||||
|
{
|
||||||
|
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
||||||
|
$syntax = '<grouping gpa="0.5rem">'
|
||||||
|
. '{{image>' . $imagePath . '|One|120}}'
|
||||||
|
. '</grouping>';
|
||||||
|
|
||||||
|
$instructions = p_get_instructions($syntax);
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('[grouping: unknown option(s): gpa]', $xhtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the built-in file endpoint includes the host page id so file.php can
|
||||||
|
* enforce per-page ACL.
|
||||||
|
*/
|
||||||
|
public function test_file_links_include_page_id_for_acl()
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
$ID = 'luxtools:acltest';
|
||||||
|
|
||||||
|
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$href = (string)$doc->find('div.luxtools-plugin a')->first()->attr('href');
|
||||||
|
$this->assertNotSame('', $href);
|
||||||
|
$this->assertStringContainsString('lib/plugins/luxtools/file.php', $href);
|
||||||
|
$this->assertStringContainsString('id=luxtools%3Aacltest', $href);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function checks that the open syntax renders an inline link.
|
||||||
|
*/
|
||||||
|
public function test_open_link()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{open>/tmp/somewhere|Open here}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'a.luxtools-open' => 1,
|
||||||
|
];
|
||||||
|
$this->structureCheck($doc, $structure);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Open here', $xhtml);
|
||||||
|
$this->assertStringContainsString('data-path="/tmp/somewhere"', $xhtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function checks that the directory syntax renders a flat table,
|
||||||
|
* listing both folders and files.
|
||||||
|
*/
|
||||||
|
public function test_directory_table_flat()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{directory>' . TMP_DIR . '/filelistdata/&direct=1}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
$doc = new Document();
|
||||||
|
$doc->html($xhtml);
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'div.luxtools-plugin' => 1,
|
||||||
|
'div.luxtools-plugin table' => 1,
|
||||||
|
'div.luxtools-plugin table > tbody > tr' => 3,
|
||||||
|
'a.luxtools-open' => 1,
|
||||||
|
];
|
||||||
|
$this->structureCheck($doc, $structure);
|
||||||
|
|
||||||
|
// Should list the top-level entries, but not recurse into exampledir
|
||||||
|
$this->assertStringContainsString('example.txt', $xhtml);
|
||||||
|
$this->assertStringContainsString('exampleimage.png', $xhtml);
|
||||||
|
$this->assertStringContainsString('exampledir', $xhtml);
|
||||||
|
$this->assertStringNotContainsString('example2.txt', $xhtml);
|
||||||
|
|
||||||
|
// Directory row should trigger the same behaviour as {{open>...}} for that folder
|
||||||
|
$this->assertStringContainsString('data-path="/Scape/exampledir"', $xhtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict ISO dates in plain text should be auto-linked to canonical day IDs.
|
||||||
|
*/
|
||||||
|
public function test_auto_link_iso_date_plain_text()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('Meeting with John on 2024-10-24.');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
if (strpos($xhtml, '>2024-10-24</a>') === false) {
|
||||||
|
throw new \Exception('Auto-link text for 2024-10-24 not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') === false) {
|
||||||
|
throw new \Exception('Auto-link target chronological:2024:10:24 not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-linking must not run inside code blocks.
|
||||||
|
*/
|
||||||
|
public function test_auto_link_skips_code_blocks()
|
||||||
|
{
|
||||||
|
$syntax = 'Outside date 2024-10-25.' . "\n\n" . '<code>Inside code 2024-10-24</code>';
|
||||||
|
$instructions = p_get_instructions($syntax);
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
if (strpos($xhtml, '>2024-10-25</a>') === false) {
|
||||||
|
throw new \Exception('Outside date 2024-10-25 was not auto-linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos(urldecode($xhtml), 'chronological:2024:10:25') === false) {
|
||||||
|
throw new \Exception('Outside auto-link target chronological:2024:10:25 not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') !== false) {
|
||||||
|
throw new \Exception('Date inside code block was incorrectly auto-linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'Inside code 2024-10-24') === false) {
|
||||||
|
throw new \Exception('Code block content was unexpectedly altered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar widget should render links to canonical day IDs.
|
||||||
|
*/
|
||||||
|
public function test_calendar_widget_links_canonical_day_ids()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{calendar>2024-10}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'luxtools-calendar') === false) {
|
||||||
|
throw new \Exception('Calendar container not rendered');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = urldecode($xhtml);
|
||||||
|
if (strpos($decoded, 'chronological:2024:10:01') === false) {
|
||||||
|
throw new \Exception('Expected canonical day link for 2024-10-01 not found');
|
||||||
|
}
|
||||||
|
if (strpos($decoded, 'chronological:2024:10:31') === false) {
|
||||||
|
throw new \Exception('Expected canonical day link for 2024-10-31 not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($decoded, 'chronological:2024:10') === false) {
|
||||||
|
throw new \Exception('Expected month link chronological:2024:10 not found in header');
|
||||||
|
}
|
||||||
|
if (strpos($decoded, 'chronological:2024') === false) {
|
||||||
|
throw new \Exception('Expected year link chronological:2024 not found in header');
|
||||||
|
}
|
||||||
|
if (strpos($decoded, 'chronological:2024:09') === false) {
|
||||||
|
throw new \Exception('Expected previous month canonical ID chronological:2024:09 not found');
|
||||||
|
}
|
||||||
|
if (strpos($decoded, 'chronological:2024:11') === false) {
|
||||||
|
throw new \Exception('Expected next month canonical ID chronological:2024:11 not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'luxtools-calendar-nav') === false) {
|
||||||
|
throw new \Exception('Calendar navigation container not rendered');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'luxtools-calendar-nav-button') === false) {
|
||||||
|
throw new \Exception('Calendar navigation buttons not rendered');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'data-luxtools-calendar="1"') === false) {
|
||||||
|
throw new \Exception('Calendar JS state attribute not rendered');
|
||||||
|
}
|
||||||
|
if (strpos($xhtml, 'data-luxtools-ajax-url=') === false) {
|
||||||
|
throw new \Exception('Calendar AJAX endpoint metadata not rendered');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'luxtools-calendar-day') === false || strpos($xhtml, '<td class="luxtools-calendar-day') === false) {
|
||||||
|
throw new \Exception('Calendar day cells not rendered as expected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty calendar target should default to current month rendering.
|
||||||
|
*/
|
||||||
|
public function test_calendar_widget_defaults_to_current_month()
|
||||||
|
{
|
||||||
|
$instructions = p_get_instructions('{{calendar>}}');
|
||||||
|
$xhtml = p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
if (strpos($xhtml, 'luxtools-calendar-table') === false) {
|
||||||
|
throw new \Exception('Calendar table not rendered for default month');
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$parts = explode('-', $today);
|
||||||
|
$expected = 'chronological:' . $parts[0] . ':' . $parts[1] . ':' . $parts[2];
|
||||||
|
|
||||||
|
if (strpos(urldecode($xhtml), $expected) === false) {
|
||||||
|
throw new \Exception('Expected canonical link for current date not found: ' . $expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
581
action.php
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\ActionPlugin;
|
||||||
|
use dokuwiki\Extension\Event;
|
||||||
|
use dokuwiki\Extension\EventHandler;
|
||||||
|
use dokuwiki\plugin\luxtools\ChronoID;
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
|
||||||
|
require_once(__DIR__ . '/autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools action plugin: register JS assets.
|
||||||
|
*/
|
||||||
|
class action_plugin_luxtools extends ActionPlugin
|
||||||
|
{
|
||||||
|
/** @var bool Guard to prevent postprocess appenders during internal renders */
|
||||||
|
protected static $internalRenderInProgress = false;
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function register(EventHandler $controller)
|
||||||
|
{
|
||||||
|
$controller->register_hook(
|
||||||
|
"TPL_METAHEADER_OUTPUT",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"addScripts",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"RENDERER_CONTENT_POSTPROCESS",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"autoLinkChronologicalDates",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"RENDERER_CONTENT_POSTPROCESS",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"appendChronologicalDayEvents",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"RENDERER_CONTENT_POSTPROCESS",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"appendChronologicalDayPhotos",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"COMMON_PAGETPL_LOAD",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"prefillChronologicalDayTemplate",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"TPL_ACT_RENDER",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"renderVirtualChronologicalDayPage",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"CSS_STYLES_INCLUDED",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"addTemporaryInputStyles",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"AJAX_CALL_UNKNOWN",
|
||||||
|
"BEFORE",
|
||||||
|
$this,
|
||||||
|
"handleCalendarWidgetAjax",
|
||||||
|
);
|
||||||
|
$controller->register_hook(
|
||||||
|
"TOOLBAR_DEFINE",
|
||||||
|
"AFTER",
|
||||||
|
$this,
|
||||||
|
"addToolbarButton",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add plugin JavaScript files in a deterministic order.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addScripts(Event $event, $param)
|
||||||
|
{
|
||||||
|
$plugin = $this->getPluginName();
|
||||||
|
$base = DOKU_BASE . "lib/plugins/$plugin/js/";
|
||||||
|
$scripts = [
|
||||||
|
"lightbox.js",
|
||||||
|
"gallery-thumbnails.js",
|
||||||
|
"open-service.js",
|
||||||
|
"scratchpads.js",
|
||||||
|
"date-fix.js",
|
||||||
|
"page-link.js",
|
||||||
|
"linkfavicon.js",
|
||||||
|
"calendar-widget.js",
|
||||||
|
"main.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($scripts as $script) {
|
||||||
|
$event->data["script"][] = [
|
||||||
|
"type" => "text/javascript",
|
||||||
|
"src" => $base . $script,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve server-rendered calendar widget HTML for month navigation.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handleCalendarWidgetAjax(Event $event, $param)
|
||||||
|
{
|
||||||
|
if ($event->data !== 'luxtools_calendar_month') return;
|
||||||
|
|
||||||
|
$event->preventDefault();
|
||||||
|
$event->stopPropagation();
|
||||||
|
|
||||||
|
global $INPUT;
|
||||||
|
|
||||||
|
$year = (int)$INPUT->int('year');
|
||||||
|
$month = (int)$INPUT->int('month');
|
||||||
|
$baseNs = trim((string)$INPUT->str('base'));
|
||||||
|
if ($baseNs === '') {
|
||||||
|
$baseNs = 'chronological';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) {
|
||||||
|
http_status(400);
|
||||||
|
echo 'Invalid month';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs);
|
||||||
|
if ($html === '') {
|
||||||
|
http_status(500);
|
||||||
|
echo 'Calendar rendering failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
echo $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include temporary global input styling via css.php so @ini_* placeholders resolve.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addTemporaryInputStyles(Event $event, $param)
|
||||||
|
{
|
||||||
|
if (!isset($event->data['mediatype']) || $event->data['mediatype'] !== 'screen') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($event->data['files']) || !is_array($event->data['files'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugin = $this->getPluginName();
|
||||||
|
$event->data['files'][DOKU_PLUGIN . $plugin . '/temp-input-colors.css'] = DOKU_BASE . 'lib/plugins/' . $plugin . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-link strict ISO dates (YYYY-MM-DD) in rendered XHTML text nodes.
|
||||||
|
*
|
||||||
|
* Excludes content inside tags where links should not be altered.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function autoLinkChronologicalDates(Event $event, $param)
|
||||||
|
{
|
||||||
|
if (!is_array($event->data)) return;
|
||||||
|
|
||||||
|
$mode = (string)($event->data[0] ?? '');
|
||||||
|
if ($mode !== 'xhtml') return;
|
||||||
|
|
||||||
|
$doc = $event->data[1] ?? null;
|
||||||
|
if (!is_string($doc) || $doc === '') return;
|
||||||
|
if (!preg_match('/\d{4}-\d{2}-\d{2}/', $doc)) return;
|
||||||
|
|
||||||
|
$event->data[1] = ChronologicalDateAutoLinker::linkHtml($doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill new chronological day pages with a German date headline.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function prefillChronologicalDayTemplate(Event $event, $param)
|
||||||
|
{
|
||||||
|
if (!is_array($event->data)) return;
|
||||||
|
|
||||||
|
$id = (string)($event->data['id'] ?? '');
|
||||||
|
if ($id === '') return;
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$id = (string)cleanID($id);
|
||||||
|
}
|
||||||
|
if ($id === '') return;
|
||||||
|
if (!ChronoID::isDayId($id)) return;
|
||||||
|
|
||||||
|
$template = ChronologicalDayTemplate::buildForDayId($id);
|
||||||
|
if ($template === null || $template === '') return;
|
||||||
|
|
||||||
|
$event->data['tpl'] = $template;
|
||||||
|
$event->data['tplfile'] = '';
|
||||||
|
$event->data['doreplace'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append matching date-prefixed photos to chronological day page output.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function appendChronologicalDayPhotos(Event $event, $param)
|
||||||
|
{
|
||||||
|
if (self::$internalRenderInProgress) return;
|
||||||
|
if (!is_array($event->data)) return;
|
||||||
|
|
||||||
|
$mode = (string)($event->data[0] ?? '');
|
||||||
|
if ($mode !== 'xhtml') return;
|
||||||
|
|
||||||
|
global $ACT;
|
||||||
|
if (!is_string($ACT) || $ACT !== 'show') return;
|
||||||
|
|
||||||
|
$doc = $event->data[1] ?? null;
|
||||||
|
if (!is_string($doc)) return;
|
||||||
|
if (str_contains($doc, 'luxtools-chronological-photos')) return;
|
||||||
|
|
||||||
|
global $ID;
|
||||||
|
$id = is_string($ID) ? $ID : '';
|
||||||
|
if ($id === '') return;
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$id = (string)cleanID($id);
|
||||||
|
}
|
||||||
|
if ($id === '') return;
|
||||||
|
|
||||||
|
$parts = ChronoID::parseDayId($id);
|
||||||
|
if ($parts === null) return;
|
||||||
|
|
||||||
|
if (!function_exists('page_exists') || !page_exists($id)) return;
|
||||||
|
|
||||||
|
$basePath = trim((string)$this->getConf('image_base_path'));
|
||||||
|
if ($basePath === '') return;
|
||||||
|
|
||||||
|
$dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']);
|
||||||
|
if (!$this->hasAnyChronologicalPhotos($dateIso)) return;
|
||||||
|
|
||||||
|
$photosHtml = $this->renderChronologicalPhotosMacro($dateIso);
|
||||||
|
if ($photosHtml === '') return;
|
||||||
|
|
||||||
|
$event->data[1] = $doc . $photosHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append local calendar events to existing chronological day pages.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function appendChronologicalDayEvents(Event $event, $param)
|
||||||
|
{
|
||||||
|
static $appendInProgress = false;
|
||||||
|
if ($appendInProgress) return;
|
||||||
|
if (self::$internalRenderInProgress) return;
|
||||||
|
|
||||||
|
if (!is_array($event->data)) return;
|
||||||
|
|
||||||
|
$mode = (string)($event->data[0] ?? '');
|
||||||
|
if ($mode !== 'xhtml') return;
|
||||||
|
|
||||||
|
global $ACT;
|
||||||
|
if (!is_string($ACT) || $ACT !== 'show') return;
|
||||||
|
|
||||||
|
$doc = $event->data[1] ?? null;
|
||||||
|
if (!is_string($doc)) return;
|
||||||
|
if (str_contains($doc, 'luxtools-chronological-events')) return;
|
||||||
|
|
||||||
|
global $ID;
|
||||||
|
$id = is_string($ID) ? $ID : '';
|
||||||
|
if ($id === '') return;
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$id = (string)cleanID($id);
|
||||||
|
}
|
||||||
|
if ($id === '') return;
|
||||||
|
|
||||||
|
$parts = ChronoID::parseDayId($id);
|
||||||
|
if ($parts === null) return;
|
||||||
|
if (!function_exists('page_exists') || !page_exists($id)) return;
|
||||||
|
|
||||||
|
$dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']);
|
||||||
|
$appendInProgress = true;
|
||||||
|
try {
|
||||||
|
$eventsHtml = $this->renderChronologicalEventsHtml($dateIso);
|
||||||
|
} finally {
|
||||||
|
$appendInProgress = false;
|
||||||
|
}
|
||||||
|
if ($eventsHtml === '') return;
|
||||||
|
|
||||||
|
$event->data[1] = $doc . $eventsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render chronological day photos using existing {{images>...}} syntax.
|
||||||
|
*
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function renderChronologicalPhotosMacro(string $dateIso): string
|
||||||
|
{
|
||||||
|
$syntax = $this->buildChronologicalImagesSyntax($dateIso);
|
||||||
|
if ($syntax === '') return '';
|
||||||
|
|
||||||
|
if (self::$internalRenderInProgress) return '';
|
||||||
|
self::$internalRenderInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$info = ['cache' => false];
|
||||||
|
$instructions = p_get_instructions($syntax);
|
||||||
|
$galleryHtml = (string)p_render('xhtml', $instructions, $info);
|
||||||
|
} finally {
|
||||||
|
self::$internalRenderInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($galleryHtml === '') return '';
|
||||||
|
|
||||||
|
$title = (string)$this->getLang('chronological_photos_title');
|
||||||
|
if ($title === '') $title = 'Photos';
|
||||||
|
|
||||||
|
return '<div class="luxtools-plugin luxtools-chronological-photos">'
|
||||||
|
. '<h2>' . hsc($title) . '</h2>'
|
||||||
|
. $galleryHtml
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build {{images>...}} syntax for a given day.
|
||||||
|
*
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function buildChronologicalImagesSyntax(string $dateIso): string
|
||||||
|
{
|
||||||
|
$basePath = trim((string)$this->getConf('image_base_path'));
|
||||||
|
if ($basePath === '') return '';
|
||||||
|
|
||||||
|
$base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath);
|
||||||
|
if (!is_dir($base) || !is_readable($base)) return '';
|
||||||
|
|
||||||
|
$yearDir = rtrim($base, '/') . '/' . substr($dateIso, 0, 4) . '/';
|
||||||
|
$targetDir = (is_dir($yearDir) && is_readable($yearDir)) ? $yearDir : $base;
|
||||||
|
|
||||||
|
return '{{images>' . $targetDir . $dateIso . '*&recursive=0}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a virtual day page for missing chronological day IDs.
|
||||||
|
*
|
||||||
|
* Shows a German date heading and existing day photos (if any) without creating the page.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function renderVirtualChronologicalDayPage(Event $event, $param)
|
||||||
|
{
|
||||||
|
if (!is_string($event->data) || $event->data !== 'show') return;
|
||||||
|
|
||||||
|
global $ID;
|
||||||
|
$id = is_string($ID) ? $ID : '';
|
||||||
|
if ($id === '') return;
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$id = (string)cleanID($id);
|
||||||
|
}
|
||||||
|
if ($id === '') return;
|
||||||
|
|
||||||
|
if (!ChronoID::isDayId($id)) return;
|
||||||
|
if (function_exists('page_exists') && page_exists($id)) return;
|
||||||
|
|
||||||
|
$wikiText = ChronologicalDayTemplate::buildForDayId($id) ?? '';
|
||||||
|
if ($wikiText === '') return;
|
||||||
|
|
||||||
|
$parts = ChronoID::parseDayId($id);
|
||||||
|
$extraHtml = '';
|
||||||
|
if ($parts !== null) {
|
||||||
|
$dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']);
|
||||||
|
|
||||||
|
$eventsHtml = $this->renderChronologicalEventsHtml($dateIso);
|
||||||
|
if ($eventsHtml !== '') {
|
||||||
|
$extraHtml .= $eventsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasAnyChronologicalPhotos($dateIso)) {
|
||||||
|
$photosHtml = $this->renderChronologicalPhotosMacro($dateIso);
|
||||||
|
if ($photosHtml !== '') {
|
||||||
|
$extraHtml .= $photosHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$editUrl = function_exists('wl') ? (string)wl($id, ['do' => 'edit']) : '';
|
||||||
|
$createLinkHtml = '';
|
||||||
|
if ($editUrl !== '') {
|
||||||
|
$label = (string)$this->getLang('btn_create');
|
||||||
|
if ($label === '') $label = 'Create this page';
|
||||||
|
$createLinkHtml = '<p><a href="' . hsc($editUrl) . '">✎ ' . hsc($label) . '</a></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = ['cache' => false];
|
||||||
|
$instructions = p_get_instructions($wikiText);
|
||||||
|
$html = (string)p_render('xhtml', $instructions, $info);
|
||||||
|
|
||||||
|
echo $html . $createLinkHtml . $extraHtml;
|
||||||
|
$event->preventDefault();
|
||||||
|
$event->stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there is at least one date-prefixed image for the given day.
|
||||||
|
*
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function hasAnyChronologicalPhotos(string $dateIso): bool
|
||||||
|
{
|
||||||
|
if (!ChronoID::isIsoDate($dateIso)) return false;
|
||||||
|
|
||||||
|
$basePath = trim((string)$this->getConf('image_base_path'));
|
||||||
|
if ($basePath === '') return false;
|
||||||
|
|
||||||
|
$base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath);
|
||||||
|
if (!is_dir($base) || !is_readable($base)) return false;
|
||||||
|
|
||||||
|
$yearDir = rtrim($base, '/') . '/' . substr($dateIso, 0, 4) . '/';
|
||||||
|
$targetDir = (is_dir($yearDir) && is_readable($yearDir)) ? $yearDir : $base;
|
||||||
|
|
||||||
|
$pattern = rtrim($targetDir, '/') . '/' . $dateIso . '*';
|
||||||
|
$matches = glob($pattern) ?: [];
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
if (!is_file($match)) continue;
|
||||||
|
$ext = strtolower(pathinfo($match, PATHINFO_EXTENSION));
|
||||||
|
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render local calendar events section for a given date.
|
||||||
|
*
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function renderChronologicalEventsHtml(string $dateIso): string
|
||||||
|
{
|
||||||
|
$icsConfig = (string)$this->getConf('calendar_ics_files');
|
||||||
|
if (trim($icsConfig) === '') return '';
|
||||||
|
|
||||||
|
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
|
||||||
|
if ($events === []) return '';
|
||||||
|
|
||||||
|
$title = (string)$this->getLang('chronological_events_title');
|
||||||
|
if ($title === '') $title = 'Events';
|
||||||
|
|
||||||
|
$items = '';
|
||||||
|
foreach ($events as $entry) {
|
||||||
|
$summary = trim((string)($entry['summary'] ?? ''));
|
||||||
|
if ($summary === '') $summary = '(ohne Titel)';
|
||||||
|
|
||||||
|
$time = trim((string)($entry['time'] ?? ''));
|
||||||
|
$startIso = trim((string)($entry['startIso'] ?? ''));
|
||||||
|
$isAllDay = (bool)($entry['allDay'] ?? false);
|
||||||
|
|
||||||
|
if ($isAllDay || $time === '') {
|
||||||
|
$items .= '<li>' . hsc($summary) . '</li>';
|
||||||
|
} else {
|
||||||
|
$timeHtml = '<span class="luxtools-event-time"';
|
||||||
|
if ($startIso !== '') {
|
||||||
|
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
|
||||||
|
}
|
||||||
|
$timeHtml .= '>' . hsc($time) . '</span>';
|
||||||
|
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($items === '') return '';
|
||||||
|
$html = '<ul>' . $items . '</ul>';
|
||||||
|
|
||||||
|
return '<div class="luxtools-plugin luxtools-chronological-events">'
|
||||||
|
. '<h2>' . hsc($title) . '</h2>'
|
||||||
|
. $html
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build wiki bullet list for local calendar events.
|
||||||
|
*
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function buildChronologicalEventsWiki(string $dateIso): string
|
||||||
|
{
|
||||||
|
$icsConfig = (string)$this->getConf('calendar_ics_files');
|
||||||
|
if (trim($icsConfig) === '') return '';
|
||||||
|
|
||||||
|
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
|
||||||
|
if ($events === []) return '';
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$summary = trim((string)($event['summary'] ?? ''));
|
||||||
|
if ($summary === '') $summary = '(ohne Titel)';
|
||||||
|
$summary = str_replace(["\n", "\r"], ' ', $summary);
|
||||||
|
|
||||||
|
$time = trim((string)($event['time'] ?? ''));
|
||||||
|
if ((bool)($event['allDay'] ?? false) || $time === '') {
|
||||||
|
$lines[] = ' * ' . $summary;
|
||||||
|
} else {
|
||||||
|
$lines[] = ' * ' . $time . ' - ' . $summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add custom toolbar button for code blocks.
|
||||||
|
*
|
||||||
|
* @param Event $event
|
||||||
|
* @param mixed $param
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addToolbarButton(Event $event, $param)
|
||||||
|
{
|
||||||
|
$event->data[] = [
|
||||||
|
"type" => "format",
|
||||||
|
"title" => $this->getLang("toolbar_code_title"),
|
||||||
|
"icon" => "../../plugins/luxtools/images/code.png",
|
||||||
|
"key" => "C",
|
||||||
|
"open" => "<code>",
|
||||||
|
"sample" => $this->getLang("toolbar_code_sample"),
|
||||||
|
"close" => "</code>",
|
||||||
|
"block" => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Date Fix: normalize selected timestamp
|
||||||
|
$event->data[] = [
|
||||||
|
"type" => "LuxtoolsDatefix",
|
||||||
|
"title" => $this->getLang("toolbar_datefix_title"),
|
||||||
|
"icon" => "../../plugins/luxtools/images/date-fix.svg",
|
||||||
|
"key" => "t",
|
||||||
|
"block" => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Date Fix All: normalize all timestamps on page
|
||||||
|
$event->data[] = [
|
||||||
|
"type" => "LuxtoolsDatefixAll",
|
||||||
|
"title" => $this->getLang("toolbar_datefix_all_title"),
|
||||||
|
"icon" => "../../plugins/luxtools/images/date-fix-all.svg",
|
||||||
|
"block" => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
3
admin.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="none" fill-opacity="0" stroke="#6c387d" stroke-width="1.9208228" d="M2.516 2.398l19.053.002-.003 19.054-19.053-.003zm12.5 18.659v-5.708M21.55 9.01H9.077M2.88 15.076h18.417M9.075 2.187v12.896M2.385 2.242l19.253.003-.003 19.264-19.253-.003z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
389
admin/main.php
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* luxtools: Admin settings page
|
||||||
|
*/
|
||||||
|
|
||||||
|
// must be run within Dokuwiki
|
||||||
|
if (!defined('DOKU_INC')) die();
|
||||||
|
|
||||||
|
class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||||
|
{
|
||||||
|
/** @var string[] */
|
||||||
|
protected $configKeys = [
|
||||||
|
'paths',
|
||||||
|
'scratchpad_paths',
|
||||||
|
'extensions',
|
||||||
|
'default_sort',
|
||||||
|
'default_order',
|
||||||
|
'default_tableheader',
|
||||||
|
'default_foldersfirst',
|
||||||
|
'default_recursive',
|
||||||
|
'default_titlefile',
|
||||||
|
'default_cache',
|
||||||
|
'default_randlinks',
|
||||||
|
'default_showsize',
|
||||||
|
'default_showdate',
|
||||||
|
'default_tablecolumns',
|
||||||
|
'default_maxheight',
|
||||||
|
'thumb_placeholder',
|
||||||
|
'gallery_thumb_scale',
|
||||||
|
'open_service_url',
|
||||||
|
'image_base_path',
|
||||||
|
'calendar_ics_files',
|
||||||
|
'pagelink_search_depth',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getMenuText($language)
|
||||||
|
{
|
||||||
|
return $this->getLang('menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMenuSort()
|
||||||
|
{
|
||||||
|
// keep near other plugin tools
|
||||||
|
return 1011;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forAdminOnly()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
global $INPUT;
|
||||||
|
|
||||||
|
if ($INPUT->str('luxtools_cmd') !== 'save') return;
|
||||||
|
|
||||||
|
if (!checkSecurityToken()) {
|
||||||
|
msg($this->getLang('err_security'), -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newConf = [];
|
||||||
|
// Normalize newlines to "\n" for consistent parsing
|
||||||
|
$paths = $INPUT->str('paths');
|
||||||
|
$paths = str_replace(["\r\n", "\r"], "\n", $paths);
|
||||||
|
$newConf['paths'] = $paths;
|
||||||
|
|
||||||
|
$scratchpadPaths = $INPUT->str('scratchpad_paths');
|
||||||
|
$scratchpadPaths = str_replace(["\r\n", "\r"], "\n", $scratchpadPaths);
|
||||||
|
$newConf['scratchpad_paths'] = $scratchpadPaths;
|
||||||
|
|
||||||
|
$newConf['extensions'] = $INPUT->str('extensions');
|
||||||
|
|
||||||
|
$newConf['default_sort'] = $INPUT->str('default_sort');
|
||||||
|
$newConf['default_order'] = $INPUT->str('default_order');
|
||||||
|
$newConf['default_tableheader'] = (int)$INPUT->bool('default_tableheader');
|
||||||
|
$newConf['default_foldersfirst'] = (int)$INPUT->bool('default_foldersfirst');
|
||||||
|
$newConf['default_recursive'] = (int)$INPUT->bool('default_recursive');
|
||||||
|
$newConf['default_titlefile'] = $INPUT->str('default_titlefile');
|
||||||
|
$newConf['default_cache'] = (int)$INPUT->bool('default_cache');
|
||||||
|
$newConf['default_randlinks'] = (int)$INPUT->bool('default_randlinks');
|
||||||
|
$newConf['default_showsize'] = (int)$INPUT->bool('default_showsize');
|
||||||
|
$newConf['default_showdate'] = (int)$INPUT->bool('default_showdate');
|
||||||
|
$newConf['default_tablecolumns'] = $INPUT->str('default_tablecolumns');
|
||||||
|
$newConf['default_maxheight'] = $INPUT->str('default_maxheight');
|
||||||
|
|
||||||
|
$newConf['thumb_placeholder'] = $INPUT->str('thumb_placeholder');
|
||||||
|
$newConf['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale');
|
||||||
|
$newConf['open_service_url'] = $INPUT->str('open_service_url');
|
||||||
|
$newConf['image_base_path'] = $INPUT->str('image_base_path');
|
||||||
|
|
||||||
|
$icsFiles = $INPUT->str('calendar_ics_files');
|
||||||
|
$icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles);
|
||||||
|
$newConf['calendar_ics_files'] = $icsFiles;
|
||||||
|
|
||||||
|
$depth = (int)$INPUT->int('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
$newConf['pagelink_search_depth'] = $depth;
|
||||||
|
|
||||||
|
if ($this->savePluginLocalConf($newConf)) {
|
||||||
|
msg($this->getLang('saved'), 1);
|
||||||
|
} else {
|
||||||
|
msg($this->getLang('err_save'), -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function html()
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
|
||||||
|
echo '<div class="plugin_luxtools_admin">';
|
||||||
|
echo '<h1>' . hsc($this->getLang('settings')) . '</h1>';
|
||||||
|
|
||||||
|
echo '<form action="' . hsc(wl($ID)) . '" method="post" class="plugin_luxtools_admin_form">';
|
||||||
|
echo '<input type="hidden" name="do" value="admin" />';
|
||||||
|
echo '<input type="hidden" name="page" value="luxtools_main" />';
|
||||||
|
echo '<input type="hidden" name="id" value="' . hsc($ID) . '" />';
|
||||||
|
echo '<input type="hidden" name="luxtools_cmd" value="save" />';
|
||||||
|
echo formSecurityToken();
|
||||||
|
|
||||||
|
echo '<fieldset>';
|
||||||
|
echo '<legend>' . hsc($this->getLang('legend')) . '</legend>';
|
||||||
|
|
||||||
|
// paths: multiline textarea
|
||||||
|
$paths = $this->normalizeMultilineDisplay((string)$this->getConf('paths'), 'paths');
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('paths')) . '</span><br />';
|
||||||
|
echo '<textarea name="paths" rows="8" cols="80" class="edit">' . hsc($paths) . '</textarea>';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// scratchpad_paths: multiline textarea
|
||||||
|
$scratchpadPaths = $this->normalizeMultilineDisplay((string)$this->getConf('scratchpad_paths'), 'scratchpad_paths');
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('scratchpad_paths')) . '</span><br />';
|
||||||
|
echo '<textarea name="scratchpad_paths" rows="6" cols="80" class="edit">' . hsc($scratchpadPaths) . '</textarea>';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// extensions
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('extensions')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="extensions" value="' . hsc((string)$this->getConf('extensions')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
echo '<h2>' . hsc($this->getLang('listing_defaults')) . '</h2>';
|
||||||
|
|
||||||
|
// default_sort
|
||||||
|
$defaultSort = (string)$this->getConf('default_sort');
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_sort')) . '</span>';
|
||||||
|
echo '<select name="default_sort" class="edit">';
|
||||||
|
foreach (['name', 'iname', 'ctime', 'mtime', 'size'] as $opt) {
|
||||||
|
$sel = ($defaultSort === $opt) ? ' selected="selected"' : '';
|
||||||
|
echo '<option value="' . hsc($opt) . '"' . $sel . '>' . hsc($opt) . '</option>';
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_order
|
||||||
|
$defaultOrder = (string)$this->getConf('default_order');
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_order')) . '</span>';
|
||||||
|
echo '<select name="default_order" class="edit">';
|
||||||
|
foreach (['asc', 'desc'] as $opt) {
|
||||||
|
$sel = ($defaultOrder === $opt) ? ' selected="selected"' : '';
|
||||||
|
echo '<option value="' . hsc($opt) . '"' . $sel . '>' . hsc($opt) . '</option>';
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_tableheader
|
||||||
|
$checked = $this->getConf('default_tableheader') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_tableheader')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_tableheader" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_foldersfirst
|
||||||
|
$checked = $this->getConf('default_foldersfirst') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_foldersfirst')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_foldersfirst" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_recursive
|
||||||
|
$checked = $this->getConf('default_recursive') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_recursive')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_recursive" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_titlefile
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_titlefile')) . '</span>';
|
||||||
|
echo '<input type="text" class="edit" name="default_titlefile" value="' . hsc((string)$this->getConf('default_titlefile')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_cache
|
||||||
|
$checked = $this->getConf('default_cache') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_cache')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_cache" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_randlinks
|
||||||
|
$checked = $this->getConf('default_randlinks') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_randlinks')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_randlinks" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_showsize
|
||||||
|
$checked = $this->getConf('default_showsize') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_showsize')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_showsize" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_showdate
|
||||||
|
$checked = $this->getConf('default_showdate') ? ' checked="checked"' : '';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_showdate')) . '</span> ';
|
||||||
|
echo '<input type="checkbox" name="default_showdate" value="1"' . $checked . ' />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_tablecolumns
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_tablecolumns')) . '</span>';
|
||||||
|
echo '<input type="text" class="edit" name="default_tablecolumns" value="' . hsc((string)$this->getConf('default_tablecolumns')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// default_maxheight
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('default_maxheight')) . '</span>';
|
||||||
|
echo '<input type="number" class="edit" name="default_maxheight" value="' . hsc((string)$this->getConf('default_maxheight')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// thumb_placeholder
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('thumb_placeholder')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="thumb_placeholder" value="' . hsc((string)$this->getConf('thumb_placeholder')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// gallery_thumb_scale
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('gallery_thumb_scale')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="gallery_thumb_scale" value="' . hsc((string)$this->getConf('gallery_thumb_scale')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// open_service_url
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('open_service_url')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="open_service_url" value="' . hsc((string)$this->getConf('open_service_url')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// image_base_path
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('image_base_path')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// calendar_ics_files
|
||||||
|
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files');
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />';
|
||||||
|
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// pagelink_search_depth
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
|
||||||
|
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
|
||||||
|
|
||||||
|
echo '</fieldset>';
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist plugin settings to conf/local.php.
|
||||||
|
*
|
||||||
|
* DokuWiki loads conf/local.php on each request; values written there will
|
||||||
|
* be available via getConf(). We write into a dedicated BEGIN/END block so
|
||||||
|
* updates are idempotent.
|
||||||
|
*
|
||||||
|
* @param array $newConf
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function savePluginLocalConf(array $newConf)
|
||||||
|
{
|
||||||
|
if (!defined('DOKU_CONF')) return false;
|
||||||
|
|
||||||
|
$plugin = 'luxtools';
|
||||||
|
$file = DOKU_CONF . 'local.php';
|
||||||
|
|
||||||
|
$existing = '';
|
||||||
|
if (@is_file($file) && @is_readable($file)) {
|
||||||
|
$existing = (string)file_get_contents($file);
|
||||||
|
}
|
||||||
|
if ($existing === '') {
|
||||||
|
$existing = "<?php\n";
|
||||||
|
}
|
||||||
|
if (!str_starts_with($existing, "<?php")) {
|
||||||
|
// unexpected format - do not overwrite
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$begin = "// BEGIN LUXTOOLS\n";
|
||||||
|
$end = "// END LUXTOOLS\n";
|
||||||
|
|
||||||
|
// Build the block
|
||||||
|
$lines = [$begin];
|
||||||
|
foreach ($this->configKeys as $key) {
|
||||||
|
if (!array_key_exists($key, $newConf)) continue;
|
||||||
|
$value = $newConf[$key];
|
||||||
|
$lines[] = '$conf[\'plugin\'][\'' . $plugin . '\'][' . var_export($key, true) . '] = ' . $this->exportPhpValue($value, $key) . ';';
|
||||||
|
}
|
||||||
|
$lines[] = $end;
|
||||||
|
$block = implode("\n", $lines);
|
||||||
|
|
||||||
|
// Replace or append the block in conf/local.php
|
||||||
|
$beginPos = strpos($existing, $begin);
|
||||||
|
if ($beginPos !== false) {
|
||||||
|
$endPos = strpos($existing, $end, $beginPos);
|
||||||
|
if ($endPos === false) {
|
||||||
|
// malformed existing block - append a new one
|
||||||
|
$content = rtrim($existing) . "\n\n" . $block;
|
||||||
|
} else {
|
||||||
|
$endPos += strlen($end);
|
||||||
|
$content = substr($existing, 0, $beginPos) . $block . substr($existing, $endPos);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$content = rtrim($existing) . "\n\n" . $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = false;
|
||||||
|
if (function_exists('io_saveFile')) {
|
||||||
|
$ok = (bool)io_saveFile($file, $content);
|
||||||
|
} else {
|
||||||
|
$ok = @file_put_contents($file, $content, LOCK_EX) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the updated conf/local.php is picked up immediately even when
|
||||||
|
// OPcache is configured to revalidate infrequently (e.g. revalidate_freq=60).
|
||||||
|
if ($ok && function_exists('opcache_invalidate')) {
|
||||||
|
@opcache_invalidate($file, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort cleanup: stop creating/using legacy conf/plugins/luxtools.local.php
|
||||||
|
$legacy = DOKU_CONF . 'plugins/' . $plugin . '.local.php';
|
||||||
|
if (@is_file($legacy)) {
|
||||||
|
@unlink($legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a value to PHP code.
|
||||||
|
*
|
||||||
|
* We use nowdoc for multiline strings to safely preserve newlines.
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param string $key
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function exportPhpValue($value, string $key): string
|
||||||
|
{
|
||||||
|
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
|
||||||
|
return var_export($value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = (string)$value;
|
||||||
|
|
||||||
|
if (str_contains($value, "\n") || str_contains($value, "\r")) {
|
||||||
|
$marker = strtoupper('LUXTOOLS_' . preg_replace('/[^A-Z0-9_]/i', '_', $key) . '_EOT');
|
||||||
|
// Extremely unlikely, but avoid delimiter collision.
|
||||||
|
while (str_contains($value, $marker)) {
|
||||||
|
$marker .= '_X';
|
||||||
|
}
|
||||||
|
return "<<<'$marker'\n" . $value . "\n$marker";
|
||||||
|
}
|
||||||
|
|
||||||
|
return var_export($value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip nowdoc markers from values when displaying in the admin form.
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @param string $key
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function normalizeMultilineDisplay(string $value, string $key): string
|
||||||
|
{
|
||||||
|
$marker = strtoupper('LUXTOOLS_' . preg_replace('/[^A-Z0-9_]/i', '_', $key) . '_EOT');
|
||||||
|
$prefix = "<<<'$marker'\n";
|
||||||
|
$suffix = "\n$marker";
|
||||||
|
|
||||||
|
if (str_starts_with($value, $prefix) && str_ends_with($value, $suffix)) {
|
||||||
|
return substr($value, strlen($prefix), -strlen($suffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
autoload.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools plugin autoloader.
|
||||||
|
*
|
||||||
|
* DokuWiki will often load plugin entrypoints (syntax.php, action.php, ...)
|
||||||
|
* directly. We keep those files small and place reusable code in src/.
|
||||||
|
*
|
||||||
|
* This file registers a minimal autoloader for the plugin namespace.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$composerAutoload = __DIR__ . '/vendor/autoload.php';
|
||||||
|
if (is_file($composerAutoload)) {
|
||||||
|
require_once $composerAutoload;
|
||||||
|
}
|
||||||
|
|
||||||
|
spl_autoload_register(static function ($class) {
|
||||||
|
$prefix = 'dokuwiki\\plugin\\luxtools\\';
|
||||||
|
$prefixLen = strlen($prefix);
|
||||||
|
|
||||||
|
if (strncmp($class, $prefix, $prefixLen) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = substr($class, $prefixLen);
|
||||||
|
if ($relative === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
|
||||||
|
if (is_file($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
5
composer.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"sabre/vobject": "^4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
252
composer.lock
generated
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "440454aa6bd2975652e94f60998e9adc",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "sabre/uri",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/uri.git",
|
||||||
|
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||||
|
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.4",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.6",
|
||||||
|
"phpunit/phpunit": "^9.6"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Uri\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Functions for making sense out of URIs.",
|
||||||
|
"homepage": "http://sabre.io/uri/",
|
||||||
|
"keywords": [
|
||||||
|
"rfc3986",
|
||||||
|
"uri",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/uri/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-uri"
|
||||||
|
},
|
||||||
|
"time": "2024-09-04T15:30:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/vobject",
|
||||||
|
"version": "4.5.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/vobject.git",
|
||||||
|
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||||
|
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "~2.17.1",
|
||||||
|
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
|
||||||
|
"phpunit/php-invoker": "^2.0 || ^3.1",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"hoa/bench": "If you would like to run the benchmark scripts"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/vobject",
|
||||||
|
"bin/generate_vcards"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "4.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\VObject\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dominik Tobschall",
|
||||||
|
"email": "dominik@fruux.com",
|
||||||
|
"homepage": "http://tobschall.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ivan Enderlin",
|
||||||
|
"email": "ivan.enderlin@hoa-project.net",
|
||||||
|
"homepage": "http://mnt.io/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
|
||||||
|
"homepage": "http://sabre.io/vobject/",
|
||||||
|
"keywords": [
|
||||||
|
"availability",
|
||||||
|
"freebusy",
|
||||||
|
"iCalendar",
|
||||||
|
"ical",
|
||||||
|
"ics",
|
||||||
|
"jCal",
|
||||||
|
"jCard",
|
||||||
|
"recurrence",
|
||||||
|
"rfc2425",
|
||||||
|
"rfc2426",
|
||||||
|
"rfc2739",
|
||||||
|
"rfc4770",
|
||||||
|
"rfc5545",
|
||||||
|
"rfc5546",
|
||||||
|
"rfc6321",
|
||||||
|
"rfc6350",
|
||||||
|
"rfc6351",
|
||||||
|
"rfc6474",
|
||||||
|
"rfc6638",
|
||||||
|
"rfc6715",
|
||||||
|
"rfc6868",
|
||||||
|
"vCalendar",
|
||||||
|
"vCard",
|
||||||
|
"vcf",
|
||||||
|
"xCal",
|
||||||
|
"xCard"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/vobject/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-vobject"
|
||||||
|
},
|
||||||
|
"time": "2026-01-12T10:45:19+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/xml",
|
||||||
|
"version": "4.0.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/xml.git",
|
||||||
|
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||||
|
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"lib-libxml": ">=2.6.20",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"sabre/uri": ">=2.0,<4.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpunit/phpunit": "^9.6"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/Deserializer/functions.php",
|
||||||
|
"lib/Serializer/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Xml\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Markus Staab",
|
||||||
|
"email": "markus.staab@redaxo.de",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "sabre/xml is an XML library that you may not hate.",
|
||||||
|
"homepage": "https://sabre.io/xml/",
|
||||||
|
"keywords": [
|
||||||
|
"XMLReader",
|
||||||
|
"XMLWriter",
|
||||||
|
"dom",
|
||||||
|
"xml"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/xml/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-xml"
|
||||||
|
},
|
||||||
|
"time": "2024-09-06T08:00:55+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.9.0"
|
||||||
|
}
|
||||||
@@ -1,10 +1,48 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for the filelist plugin
|
* Options for the luxtools plugin
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$conf['paths'] = '';
|
$conf['paths'] = '';
|
||||||
$conf['allow_in_comments'] = 0;
|
$conf['scratchpad_paths'] = '';
|
||||||
|
// Legacy (advanced): additional default flags in the same syntax as inline options.
|
||||||
$conf['defaults'] = '';
|
$conf['defaults'] = '';
|
||||||
$conf['extensions'] = '';
|
$conf['extensions'] = '';
|
||||||
|
|
||||||
|
// Listing defaults (applied to directory/images unless overridden inline)
|
||||||
|
$conf['default_sort'] = 'name'; // name|iname|ctime|mtime|size
|
||||||
|
$conf['default_order'] = 'asc'; // asc|desc
|
||||||
|
$conf['default_tableheader'] = 0; // 0|1
|
||||||
|
$conf['default_foldersfirst'] = 0; // 0|1
|
||||||
|
$conf['default_recursive'] = 0; // 0|1
|
||||||
|
$conf['default_titlefile'] = '_title.txt';
|
||||||
|
$conf['default_cache'] = 0; // 0|1
|
||||||
|
$conf['default_randlinks'] = 0; // 0|1
|
||||||
|
$conf['default_showsize'] = 0; // 0|1
|
||||||
|
$conf['default_showdate'] = 0; // 0|1
|
||||||
|
$conf['default_tablecolumns'] = 'name'; // Comma-separated: name, size, date
|
||||||
|
$conf['default_maxheight'] = 500; // -1 disables scroll container
|
||||||
|
|
||||||
|
// MediaManager ID for gallery thumbnail placeholder
|
||||||
|
$conf['thumb_placeholder'] = ':wiki:thumb-placeholder.png';
|
||||||
|
|
||||||
|
// Multiplier for gallery thumbnail generation size (1 = 150x150, 2 = 300x300).
|
||||||
|
// Thumbnails are still displayed as 150x150 on the page.
|
||||||
|
$conf['gallery_thumb_scale'] = 1;
|
||||||
|
|
||||||
|
// Local client service used by {{open>...}}.
|
||||||
|
$conf['open_service_url'] = 'http://127.0.0.1:8765';
|
||||||
|
|
||||||
|
// Base filesystem path for chronological photo integration.
|
||||||
|
$conf['image_base_path'] = '';
|
||||||
|
|
||||||
|
// Local calendar ICS files (one absolute file path per line).
|
||||||
|
$conf['calendar_ics_files'] = '';
|
||||||
|
|
||||||
|
// Maximum depth when searching for .pagelink files under allowed roots.
|
||||||
|
$conf['pagelink_search_depth'] = 3;
|
||||||
|
|
||||||
|
// Image syntax defaults
|
||||||
|
$conf['default_image_width'] = 250;
|
||||||
|
$conf['default_image_align'] = 'right'; // left|right|center
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata for configuration manager plugin
|
* Metadata for configuration manager plugin
|
||||||
* Additions for the filelist plugin
|
|
||||||
*
|
*
|
||||||
* @author Gina Haeussge <osd@foosel.net>
|
* NOTE: luxtools settings are managed via the plugin's dedicated admin page
|
||||||
|
* (Admin -> Additional Plugins). Therefore, we intentionally do not expose
|
||||||
|
* any settings to the Configuration Manager.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$meta['paths'] = array('');
|
$meta = [];
|
||||||
$meta['allow_in_comments'] = array('onoff');
|
|
||||||
$meta['defaults'] = array('string');
|
|
||||||
$meta['extensions'] = array('string');
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# This is a list of files that were present in previous releases
|
|
||||||
# but were removed later. They should not exist in your installation.
|
|
||||||
.travis.yml
|
|
||||||
_test/filelist.test.php
|
|
||||||
script.js
|
|
||||||
112
deploy.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Deploy this luxtools plugin checkout into a mounted DokuWiki plugins dir.
|
||||||
|
# Default target is the user's mount path.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh # deploy to /thebe/Web/lib/plugins/luxtools/
|
||||||
|
# ./deploy.sh --dry-run # show what would change
|
||||||
|
# ./deploy.sh /path/to/luxtools
|
||||||
|
# ./deploy.sh --no-delete # don't delete extraneous files at target
|
||||||
|
|
||||||
|
TARGET="/thebe/Web/lib/plugins/luxtools"
|
||||||
|
DRY_RUN=0
|
||||||
|
DELETE=1
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run|-n)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-delete)
|
||||||
|
DELETE=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '1,80p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
TARGET="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v rsync >/dev/null 2>&1; then
|
||||||
|
echo "Error: rsync is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Safety checks: make sure source looks like luxtools plugin
|
||||||
|
if [[ ! -f "$SRC_DIR/plugin.info.txt" ]]; then
|
||||||
|
echo "Error: '$SRC_DIR' doesn't look like luxtools (missing plugin.info.txt)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure target dir exists
|
||||||
|
mkdir -p "$TARGET"
|
||||||
|
|
||||||
|
# Safety check: refuse to deploy to an obviously wrong directory.
|
||||||
|
# Allow empty dir (fresh install) OR existing luxtools plugin dir.
|
||||||
|
if [[ -e "$TARGET/plugin.info.txt" ]]; then
|
||||||
|
if ! grep -qi "^base\s\+luxtools" "$TARGET/plugin.info.txt" 2>/dev/null; then
|
||||||
|
echo "Error: target '$TARGET' has a plugin.info.txt, but it doesn't look like luxtools." >&2
|
||||||
|
echo "Refusing to deploy." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
RSYNC_ARGS=(
|
||||||
|
-a
|
||||||
|
--human-readable
|
||||||
|
--itemize-changes
|
||||||
|
--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r
|
||||||
|
--exclude=deploy.sh
|
||||||
|
--exclude=.git/
|
||||||
|
--exclude=_dokuwiki/
|
||||||
|
--exclude=_agent-data/
|
||||||
|
--exclude=.github/
|
||||||
|
--exclude=.vscode/
|
||||||
|
--exclude=_test/
|
||||||
|
--exclude=deleted.files
|
||||||
|
--exclude=*.swp
|
||||||
|
--exclude=*.swo
|
||||||
|
--exclude=.DS_Store
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if ((DRY_RUN)); then
|
||||||
|
RSYNC_ARGS+=(--dry-run)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((DELETE)); then
|
||||||
|
RSYNC_ARGS+=(--delete)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying luxtools from: $SRC_DIR/"
|
||||||
|
echo "Deploying luxtools to: $TARGET/"
|
||||||
|
|
||||||
|
rsync "${RSYNC_ARGS[@]}" "$SRC_DIR/" "$TARGET/"
|
||||||
|
|
||||||
|
# Invalidate DokuWiki cache by touching conf/local.php
|
||||||
|
# This forces DokuWiki to rebuild JavaScript/CSS bundles
|
||||||
|
CONF_LOCAL="$(dirname "$TARGET")/../../conf/local.php"
|
||||||
|
if [[ -f "$CONF_LOCAL" ]]; then
|
||||||
|
if ((DRY_RUN)); then
|
||||||
|
echo "(dry-run) Would touch $CONF_LOCAL to invalidate cache"
|
||||||
|
elif touch "$CONF_LOCAL" 2>/dev/null; then
|
||||||
|
echo "Cache invalidated (touched conf/local.php)"
|
||||||
|
else
|
||||||
|
echo "Note: Cannot touch conf/local.php (permission denied)."
|
||||||
|
echo " Run 'touch conf/local.php' on the server to clear cache."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Note: conf/local.php not found at expected path, skip cache invalidation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
258
file.php
@@ -2,21 +2,225 @@
|
|||||||
|
|
||||||
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||||
|
|
||||||
use dokuwiki\plugin\filelist\Path;
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/autoload.php');
|
||||||
|
|
||||||
if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
|
if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
|
||||||
if (!defined('NOSESSION')) define('NOSESSION', true); // we do not use a session or authentication here (better caching)
|
|
||||||
if (!defined('DOKU_DISABLE_GZIP_OUTPUT')) define('DOKU_DISABLE_GZIP_OUTPUT', 1); // we gzip ourself here
|
if (!defined('DOKU_DISABLE_GZIP_OUTPUT')) define('DOKU_DISABLE_GZIP_OUTPUT', 1); // we gzip ourself here
|
||||||
require_once(DOKU_INC . 'inc/init.php');
|
require_once(DOKU_INC . 'inc/init.php');
|
||||||
|
|
||||||
|
// Close the session early to prevent blocking concurrent requests.
|
||||||
|
// PHP sessions are locked by default - if we hold the lock during thumbnail
|
||||||
|
// generation, all other requests from this user (including page navigation)
|
||||||
|
// will be blocked until we finish. Since we only need session data for ACL
|
||||||
|
// checks (which happen before this point via init.php), we can safely close it.
|
||||||
|
if (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
global $INPUT;
|
global $INPUT;
|
||||||
|
|
||||||
$syntax = plugin_load('syntax', 'filelist');
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
if (!$syntax) die('plugin disabled?');
|
if (!$syntax) die('plugin disabled?');
|
||||||
|
|
||||||
$pathUtil = new Path($syntax->getConf('paths'));
|
$pathUtil = new Path($syntax->getConf('paths'));
|
||||||
$path = $INPUT->str('root') . $INPUT->str('file');
|
$path = $INPUT->str('root') . $INPUT->str('file');
|
||||||
|
|
||||||
|
// Require the user to be able to read the page that rendered the link.
|
||||||
|
$pageId = (string)$INPUT->str('id');
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pageId === '') {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
http_status(403);
|
||||||
|
echo 'forbidden';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('auth_quickaclcheck') || !defined('AUTH_READ')) {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
http_status(403);
|
||||||
|
echo 'forbidden';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth_quickaclcheck($pageId) < AUTH_READ) {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
http_status(403);
|
||||||
|
echo 'forbidden';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a file to the client with basic caching headers.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param string $mime
|
||||||
|
* @param bool $download
|
||||||
|
* @param string|null $downloadName
|
||||||
|
* @param string|null $etag
|
||||||
|
* @param int|null $mtime
|
||||||
|
* @param int|null $maxAge
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function luxtools_sendfile($path, $mime, $download = false, $downloadName = null, $etag = null, $mtime = null, $maxAge = null)
|
||||||
|
{
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
if ($download) {
|
||||||
|
$downloadName = $downloadName ?: basename($path);
|
||||||
|
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($etag !== null) {
|
||||||
|
header('ETag: "' . $etag . '"');
|
||||||
|
}
|
||||||
|
if ($mtime !== null) {
|
||||||
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($maxAge !== null) {
|
||||||
|
// Authentication may apply; keep caching private.
|
||||||
|
header('Cache-Control: private, max-age=' . (int)$maxAge . ', immutable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional request handling
|
||||||
|
if ($etag !== null && isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
|
||||||
|
if (trim($_SERVER['HTTP_IF_NONE_MATCH']) === '"' . $etag . '"') {
|
||||||
|
http_status(304);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($mtime !== null && isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
|
||||||
|
$ims = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
|
||||||
|
if ($ims !== false && $ims >= $mtime) {
|
||||||
|
http_status(304);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http_sendfile($path);
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a thumbnail file using GD.
|
||||||
|
*
|
||||||
|
* @param string $src
|
||||||
|
* @param string $dst
|
||||||
|
* @param int $maxW
|
||||||
|
* @param int $maxH
|
||||||
|
* @param string $dstFormat 'jpg' or 'png'
|
||||||
|
* @param int $quality JPEG quality 0-100
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
function luxtools_create_thumb_gd($src, $dst, $maxW, $maxH, $dstFormat, $quality)
|
||||||
|
{
|
||||||
|
if (!function_exists('imagecreatetruecolor')) return false;
|
||||||
|
if (!is_readable($src)) return false;
|
||||||
|
|
||||||
|
$info = @getimagesize($src);
|
||||||
|
if (!is_array($info) || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return false;
|
||||||
|
|
||||||
|
$srcW = (int)$info[0];
|
||||||
|
$srcH = (int)$info[1];
|
||||||
|
$srcMime = (string)$info['mime'];
|
||||||
|
if ($srcW <= 0 || $srcH <= 0) return false;
|
||||||
|
|
||||||
|
$maxW = max(1, (int)$maxW);
|
||||||
|
$maxH = max(1, (int)$maxH);
|
||||||
|
|
||||||
|
$scale = min($maxW / $srcW, $maxH / $srcH, 1);
|
||||||
|
$dstW = max(1, (int)floor($srcW * $scale));
|
||||||
|
$dstH = max(1, (int)floor($srcH * $scale));
|
||||||
|
|
||||||
|
switch ($srcMime) {
|
||||||
|
case 'image/jpeg':
|
||||||
|
if (!function_exists('imagecreatefromjpeg')) return false;
|
||||||
|
$srcImg = @imagecreatefromjpeg($src);
|
||||||
|
break;
|
||||||
|
case 'image/png':
|
||||||
|
if (!function_exists('imagecreatefrompng')) return false;
|
||||||
|
$srcImg = @imagecreatefrompng($src);
|
||||||
|
break;
|
||||||
|
case 'image/gif':
|
||||||
|
if (!function_exists('imagecreatefromgif')) return false;
|
||||||
|
$srcImg = @imagecreatefromgif($src);
|
||||||
|
break;
|
||||||
|
case 'image/webp':
|
||||||
|
if (!function_exists('imagecreatefromwebp')) return false;
|
||||||
|
$srcImg = @imagecreatefromwebp($src);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$srcImg) return false;
|
||||||
|
|
||||||
|
$dstImg = imagecreatetruecolor($dstW, $dstH);
|
||||||
|
if (!$dstImg) {
|
||||||
|
imagedestroy($srcImg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve transparency for formats that support it
|
||||||
|
if ($dstFormat === 'png') {
|
||||||
|
imagealphablending($dstImg, false);
|
||||||
|
imagesavealpha($dstImg, true);
|
||||||
|
$transparent = imagecolorallocatealpha($dstImg, 0, 0, 0, 127);
|
||||||
|
imagefilledrectangle($dstImg, 0, 0, $dstW, $dstH, $transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
|
||||||
|
imagedestroy($srcImg);
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
imagedestroy($dstImg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to a temporary file then rename atomically
|
||||||
|
$tmp = $dst . '.tmp.' . getmypid();
|
||||||
|
@io_mkdir_p(dirname($dst));
|
||||||
|
|
||||||
|
$written = false;
|
||||||
|
if ($dstFormat === 'png') {
|
||||||
|
if (!function_exists('imagepng')) {
|
||||||
|
$written = false;
|
||||||
|
} else {
|
||||||
|
// compression: 0 (none) .. 9 (max). Use a reasonable default.
|
||||||
|
$written = @imagepng($dstImg, $tmp, 6);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!function_exists('imagejpeg')) {
|
||||||
|
$written = false;
|
||||||
|
} else {
|
||||||
|
$q = max(0, min(100, (int)$quality));
|
||||||
|
$written = @imagejpeg($dstImg, $tmp, $q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imagedestroy($dstImg);
|
||||||
|
|
||||||
|
if (!$written || !is_file($tmp)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort atomic move
|
||||||
|
if (!@rename($tmp, $dst)) {
|
||||||
|
// fallback copy
|
||||||
|
$ok = @copy($tmp, $dst);
|
||||||
|
@unlink($tmp);
|
||||||
|
if (!$ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pathInfo = $pathUtil->getPathInfo($path, false);
|
$pathInfo = $pathUtil->getPathInfo($path, false);
|
||||||
if ($pathUtil::isWikiControlled($pathInfo['path'])) {
|
if ($pathUtil::isWikiControlled($pathInfo['path'])) {
|
||||||
@@ -29,14 +233,48 @@ try {
|
|||||||
echo 'Path not readable: ' . $pathInfo['path'];
|
echo 'Path not readable: ' . $pathInfo['path'];
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
[$ext, $mime, $download] = mimetype($pathInfo['path'], false);
|
[, $mime, $download] = mimetype($pathInfo['path'], false);
|
||||||
$basename = basename($pathInfo['path']);
|
|
||||||
header('Content-Type: ' . $mime);
|
// Optional thumbnail mode: ?thumb=1&w=150&h=150
|
||||||
if ($download) {
|
$thumb = (int)$INPUT->int('thumb');
|
||||||
header('Content-Disposition: attachment; filename="' . $basename . '"');
|
$w = (int)$INPUT->int('w');
|
||||||
|
$h = (int)$INPUT->int('h');
|
||||||
|
$q = (int)$INPUT->int('q');
|
||||||
|
if ($q <= 0) $q = 80;
|
||||||
|
|
||||||
|
$isImage = is_string($mime) && str_starts_with($mime, 'image/');
|
||||||
|
$wantThumb = $thumb === 1 && $isImage && ($w > 0 || $h > 0);
|
||||||
|
|
||||||
|
if ($wantThumb) {
|
||||||
|
if ($w <= 0) $w = $h;
|
||||||
|
if ($h <= 0) $h = $w;
|
||||||
|
|
||||||
|
global $conf;
|
||||||
|
$srcMtime = @filemtime($pathInfo['path']) ?: time();
|
||||||
|
|
||||||
|
// Decide output format (prefer PNG when transparency is likely)
|
||||||
|
$dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg';
|
||||||
|
$dstMime = ($dstFormat === 'png') ? 'image/png' : 'image/jpeg';
|
||||||
|
$hash = sha1($pathInfo['path'] . '|' . $srcMtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat);
|
||||||
|
$sub = substr($hash, 0, 2);
|
||||||
|
$cacheDir = rtrim($conf['cachedir'], '/');
|
||||||
|
$thumbPath = $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat;
|
||||||
|
|
||||||
|
if (!is_file($thumbPath)) {
|
||||||
|
$ok = luxtools_create_thumb_gd($pathInfo['path'], $thumbPath, $w, $h, $dstFormat, $q);
|
||||||
|
if (!$ok || !is_file($thumbPath)) {
|
||||||
|
// Fallback: serve original if we cannot thumbnail
|
||||||
|
luxtools_sendfile($pathInfo['path'], $mime, $download, basename($pathInfo['path']), null, $srcMtime, 3600);
|
||||||
}
|
}
|
||||||
http_sendfile($pathInfo['path']);
|
}
|
||||||
readfile($pathInfo['path']);
|
|
||||||
|
// Cached thumbs are immutable because filename includes mtime
|
||||||
|
luxtools_sendfile($thumbPath, $dstMime, false, null, $hash, @filemtime($thumbPath) ?: $srcMtime, 31536000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: serve original file
|
||||||
|
$basename = basename($pathInfo['path']);
|
||||||
|
luxtools_sendfile($pathInfo['path'], $mime, $download, $basename, null, @filemtime($pathInfo['path']) ?: null, 3600);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
header('Content-Type: text/plain');
|
header('Content-Type: text/plain');
|
||||||
http_status(403);
|
http_status(403);
|
||||||
|
|||||||
BIN
images/code.png
Normal file
|
After Width: | Height: | Size: 410 B |
3
images/code.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path fill="#333" d="M5.5 4L2 8l3.5 4 1-1.5L4 8l2.5-2.5L5.5 4zm5 0l-1 1.5L12 8l-2.5 2.5 1 1.5L14 8l-3.5-4z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 203 B |
10
images/date-fix-all.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<rect x="2" y="3" width="12" height="11" rx="1" ry="1" fill="none" stroke="#000" stroke-width="1" />
|
||||||
|
<rect x="2" y="5" width="12" height="2" fill="#000" />
|
||||||
|
<rect x="4" y="1" width="2" height="4" fill="#000" />
|
||||||
|
<rect x="10" y="1" width="2" height="4" fill="#000" />
|
||||||
|
<path d="M4 9h8" stroke="#000" stroke-width="1" />
|
||||||
|
<path d="M4 11h8" stroke="#000" stroke-width="1" />
|
||||||
|
<circle cx="13" cy="13" r="2.2" fill="#000" />
|
||||||
|
<path d="M12.2 13l0.6 0.6 1-1" stroke="#fff" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 665 B |
8
images/date-fix.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<rect x="2" y="3" width="12" height="11" rx="1" ry="1" fill="none" stroke="#000" stroke-width="1" />
|
||||||
|
<rect x="2" y="5" width="12" height="2" fill="#000" />
|
||||||
|
<rect x="4" y="1" width="2" height="4" fill="#000" />
|
||||||
|
<rect x="10" y="1" width="2" height="4" fill="#000" />
|
||||||
|
<path d="M5 9h6" stroke="#000" stroke-width="1" />
|
||||||
|
<path d="M5 11h4" stroke="#000" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 490 B |
49
images/folder.svg
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="folder.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs3" /><sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="5.7134228"
|
||||||
|
inkscape:cx="66.860097"
|
||||||
|
inkscape:cy="56.008458"
|
||||||
|
inkscape:window-width="1633"
|
||||||
|
inkscape:window-height="1059"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3" /><style
|
||||||
|
id="style1">.st1{fill:#a87d45}</style><path
|
||||||
|
fill="#fff"
|
||||||
|
d="M0 0h100v100H0z"
|
||||||
|
id="path1" /><path
|
||||||
|
class="st1"
|
||||||
|
d="M100 100H0V0h100v100zM9.7 90h80.7V10H9.7"
|
||||||
|
id="path2" /><path
|
||||||
|
d="m 75.489689,40.94749 v -5.60846 c 0,-1.548724 -1.255453,-2.804239 -2.80423,-2.804239 H 44.643148 L 41.838918,26.92633 H 27.817766 l -2.212116,4.424267 c -0.389393,0.77876 -0.592114,1.637504 -0.592114,2.508186 v 7.088707"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path1-3"
|
||||||
|
style="fill:#a87d45;stroke-width:2.80423;fill-opacity:1;stroke:#ffffff;stroke-opacity:1" /><path
|
||||||
|
d="m 27.574388,71.794032 h 45.354476 c 1.450348,0 2.661499,-1.105996 2.792732,-2.550454 L 78.016021,44.005505 C 78.165205,42.363397 76.872176,40.94749 75.223289,40.94749 H 25.280016 c -1.648967,0 -2.941996,1.415907 -2.792706,3.058015 l 2.294372,25.238073 c 0.131321,1.444458 1.342358,2.550454 2.792706,2.550454 z"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path2-6"
|
||||||
|
style="fill:#a87d45;stroke-width:2.804;fill-opacity:1;stroke-dasharray:none;stroke:#ffffff;stroke-opacity:1" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
43
images/open-folder.svg
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="open-folder.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs3" /><sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="5.7134228"
|
||||||
|
inkscape:cx="67.210149"
|
||||||
|
inkscape:cy="56.183484"
|
||||||
|
inkscape:window-width="1633"
|
||||||
|
inkscape:window-height="1059"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3" /><style
|
||||||
|
id="style1">.st1{fill:#a87d45}</style><path
|
||||||
|
d="m 85.019893,39.020089 v -7.70915 c 0,-2.12881 -1.725693,-3.854588 -3.854577,-3.854588 H 42.619548 l -3.854576,-7.70915 H 19.492093 l -3.04068,6.081408 c -0.535243,1.07045 -0.813896,2.250843 -0.813896,3.447646 v 9.743834"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path1-3"
|
||||||
|
style="fill:#ebc8ab;fill-opacity:0;stroke:#888888;stroke-width:8;stroke-opacity:1;stroke-dasharray:none" /><path
|
||||||
|
d="m 19.157556,81.420435 h 62.342335 c 1.993587,0 3.658383,-1.520255 3.838771,-3.505746 L 88.492481,43.223508 C 88.697542,40.966336 86.9202,39.020089 84.65371,39.020089 h -68.6499 c -2.266601,0 -4.043943,1.946247 -3.838736,4.203419 l 3.153747,34.691181 c 0.180508,1.985491 1.845149,3.505746 3.838735,3.505746 z"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path2-6"
|
||||||
|
style="fill:#ebc8ab;fill-opacity:0;stroke:#888888;stroke-width:8;stroke-dasharray:none;stroke-opacity:1" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
5
images/pagelink.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 7l-1.5 1.5" />
|
||||||
|
<path d="M14 11a5 5 0 0 1 0 7l-2 2a5 5 0 0 1-7-7L6.5 11.5" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
5
images/placeholder.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
129
js/calendar-widget.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* global window, document, fetch, URLSearchParams */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
function findCalendarRoot(target) {
|
||||||
|
var el = target;
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.classList && el.classList.contains('luxtools-calendar') && el.getAttribute('data-luxtools-calendar') === '1') {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextMonth(year, month, direction) {
|
||||||
|
var cursor = new Date(year, month - 1, 1);
|
||||||
|
cursor.setMonth(cursor.getMonth() + direction);
|
||||||
|
return {
|
||||||
|
year: cursor.getFullYear(),
|
||||||
|
month: cursor.getMonth() + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCalendarFromHtml(html) {
|
||||||
|
if (!html) return null;
|
||||||
|
|
||||||
|
var wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = html;
|
||||||
|
|
||||||
|
return wrapper.querySelector('div.luxtools-calendar[data-luxtools-calendar="1"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCalendarBusy(calendar, busy) {
|
||||||
|
if (!calendar) return;
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
calendar.setAttribute('data-luxtools-loading', '1');
|
||||||
|
} else {
|
||||||
|
calendar.removeAttribute('data-luxtools-loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttons = calendar.querySelectorAll('button.luxtools-calendar-nav-button');
|
||||||
|
for (var i = 0; i < buttons.length; i++) {
|
||||||
|
buttons[i].disabled = !!busy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCalendarMonth(calendar, year, month) {
|
||||||
|
var ajaxUrl = calendar.getAttribute('data-luxtools-ajax-url') || '';
|
||||||
|
if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url'));
|
||||||
|
|
||||||
|
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
|
||||||
|
var params = new URLSearchParams({
|
||||||
|
call: 'luxtools_calendar_month',
|
||||||
|
year: String(year),
|
||||||
|
month: String(month),
|
||||||
|
base: baseNs
|
||||||
|
});
|
||||||
|
|
||||||
|
var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString();
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Calendar request failed: ' + response.status);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateCalendarMonth(calendar, direction) {
|
||||||
|
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
|
||||||
|
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
|
||||||
|
if (!year || !month) return;
|
||||||
|
|
||||||
|
var next = getNextMonth(year, month, direction);
|
||||||
|
setCalendarBusy(calendar, true);
|
||||||
|
|
||||||
|
fetchCalendarMonth(calendar, next.year, next.month)
|
||||||
|
.then(function (html) {
|
||||||
|
var replacement = parseCalendarFromHtml(html);
|
||||||
|
if (!replacement) {
|
||||||
|
throw new Error('Calendar markup missing in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.replaceWith(replacement);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
var fallbackLink = calendar.querySelector('a.luxtools-calendar-month-link');
|
||||||
|
if (fallbackLink && fallbackLink.href) {
|
||||||
|
window.location.href = fallbackLink.href;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
setCalendarBusy(calendar, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCalendarClick(event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!target || !target.classList || !target.classList.contains('luxtools-calendar-nav-button')) return;
|
||||||
|
|
||||||
|
var calendar = findCalendarRoot(target);
|
||||||
|
if (!calendar) return;
|
||||||
|
|
||||||
|
var direction = parseInt(target.getAttribute('data-luxtools-dir') || '0', 10);
|
||||||
|
if (direction !== -1 && direction !== 1) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
navigateCalendarMonth(calendar, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCalendarWidgets() {
|
||||||
|
document.addEventListener('click', onCalendarClick, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Luxtools.CalendarWidget = {
|
||||||
|
init: initCalendarWidgets
|
||||||
|
};
|
||||||
|
})();
|
||||||
235
js/date-fix.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/* global window, document, DWgetSelection, DWsetSelection, pasteText */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
var DateFix = Luxtools.DateFix || (Luxtools.DateFix = {});
|
||||||
|
|
||||||
|
// Month name patterns for regex (English and German)
|
||||||
|
var MONTH_PATTERN = '(?:jan|feb|m[aä]r|apr|ma[iy]|jun|jul|aug|sep|sept|okt|oct|nov|de[cz])[a-z]*\\.?';
|
||||||
|
|
||||||
|
// Regex to find date candidates in text for "fix all" feature
|
||||||
|
var CANDIDATE_REGEX = new RegExp(
|
||||||
|
'\\b(?:' +
|
||||||
|
'\\d{4}[-\\/.][\\d]{1,2}[-\\/.][\\d]{1,2}|' + // YYYY-MM-DD
|
||||||
|
'\\d{1,2}[-\\/.][\\d]{1,2}[-\\/.][\\d]{4}|' + // DD-MM-YYYY
|
||||||
|
MONTH_PATTERN + '\\s+\\d{1,2}(?:st|nd|rd|th)?[,]?\\s+\\d{4}|' + // Month DD, YYYY
|
||||||
|
'\\d{1,2}(?:st|nd|rd|th)?\\.?\\s+' + MONTH_PATTERN + '\\s+\\d{4}|' + // DD Month YYYY
|
||||||
|
'\\d{4}\\s+' + MONTH_PATTERN + '\\s+\\d{1,2}' + // YYYY Month DD
|
||||||
|
')(?:[T\\s]+\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\s*(?:am|pm))?)?\\b',
|
||||||
|
'gi'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map month names (English and German) to month numbers (1-12)
|
||||||
|
var MONTH_MAP = {
|
||||||
|
'jan': 1, 'januar': 1, 'january': 1,
|
||||||
|
'feb': 2, 'februar': 2, 'february': 2,
|
||||||
|
'mar': 3, 'mär': 3, 'märz': 3, 'march': 3, 'maerz': 3,
|
||||||
|
'apr': 4, 'april': 4,
|
||||||
|
'may': 5, 'mai': 5,
|
||||||
|
'jun': 6, 'juni': 6, 'june': 6,
|
||||||
|
'jul': 7, 'juli': 7, 'july': 7,
|
||||||
|
'aug': 8, 'august': 8,
|
||||||
|
'sep': 9, 'sept': 9, 'september': 9,
|
||||||
|
'oct': 10, 'okt': 10, 'oktober': 10, 'october': 10,
|
||||||
|
'nov': 11, 'november': 11,
|
||||||
|
'dec': 12, 'dez': 12, 'dezember': 12, 'december': 12
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return n < 10 ? '0' + n : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a month name (English or German) and return its number (1-12).
|
||||||
|
* Returns null if not found.
|
||||||
|
*/
|
||||||
|
function parseMonthName(name) {
|
||||||
|
if (!name) return null;
|
||||||
|
var key = name.toLowerCase().replace(/\.$/, '');
|
||||||
|
return MONTH_MAP[key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preprocess input to make it parseable by Date.
|
||||||
|
* Handles formats Date.parse() doesn't understand natively.
|
||||||
|
*/
|
||||||
|
function preprocess(input) {
|
||||||
|
var s = input
|
||||||
|
.replace(/^\s*[([{"'`]+|[)\]}",'`]+\s*$/g, '') // strip surrounding brackets/quotes
|
||||||
|
.replace(/\s*(Z|[+-]\d{2}:?\d{2})$/i, '') // strip timezone
|
||||||
|
.replace(/(\d)(st|nd|rd|th)\b/gi, '$1') // strip ordinal suffixes
|
||||||
|
.replace(/,/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Handle month names (English and German) - convert to YYYY-MM-DD format
|
||||||
|
// Pattern: DD Month YYYY or DD. Month YYYY
|
||||||
|
var monthMatch = s.match(/^(\d{1,2})\.?\s+([a-zäö]+)\.?\s+(\d{4})(.*)$/i);
|
||||||
|
if (monthMatch) {
|
||||||
|
var day = parseInt(monthMatch[1], 10);
|
||||||
|
var monthNum = parseMonthName(monthMatch[2]);
|
||||||
|
var year = monthMatch[3];
|
||||||
|
var rest = monthMatch[4] || '';
|
||||||
|
if (monthNum) {
|
||||||
|
return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern: Month DD, YYYY
|
||||||
|
monthMatch = s.match(/^([a-zäö]+)\.?\s+(\d{1,2})\s+(\d{4})(.*)$/i);
|
||||||
|
if (monthMatch) {
|
||||||
|
var monthNum = parseMonthName(monthMatch[1]);
|
||||||
|
var day = parseInt(monthMatch[2], 10);
|
||||||
|
var year = monthMatch[3];
|
||||||
|
var rest = monthMatch[4] || '';
|
||||||
|
if (monthNum) {
|
||||||
|
return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern: YYYY Month DD
|
||||||
|
monthMatch = s.match(/^(\d{4})\s+([a-zäö]+)\.?\s+(\d{1,2})(.*)$/i);
|
||||||
|
if (monthMatch) {
|
||||||
|
var year = monthMatch[1];
|
||||||
|
var monthNum = parseMonthName(monthMatch[2]);
|
||||||
|
var day = parseInt(monthMatch[3], 10);
|
||||||
|
var rest = monthMatch[4] || '';
|
||||||
|
if (monthNum) {
|
||||||
|
return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DD.MM.YYYY or DD/MM/YYYY -> rearrange to YYYY-MM-DD for Date
|
||||||
|
var match = s.match(/^(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{4})(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
var a = parseInt(match[1], 10);
|
||||||
|
var b = parseInt(match[2], 10);
|
||||||
|
var year = match[3];
|
||||||
|
var rest = match[4] || '';
|
||||||
|
// Disambiguate: if first > 12, it must be day (DMY); else assume DMY
|
||||||
|
var day = a > 12 ? a : (b > 12 ? b : a);
|
||||||
|
var month = a > 12 ? b : (b > 12 ? a : b);
|
||||||
|
s = year + '-' + pad2(month) + '-' + pad2(day) + rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to parse a date/time string into a Date object.
|
||||||
|
* Returns null if parsing fails or produces an invalid date.
|
||||||
|
*/
|
||||||
|
function tryParse(input) {
|
||||||
|
if (!input || typeof input !== 'string') return null;
|
||||||
|
|
||||||
|
var preprocessed = preprocess(input);
|
||||||
|
if (!preprocessed) return null;
|
||||||
|
|
||||||
|
// Try native parsing
|
||||||
|
var ts = Date.parse(preprocessed);
|
||||||
|
if (!isNaN(ts)) return new Date(ts);
|
||||||
|
|
||||||
|
// Fallback: replace dots/slashes with dashes and try again
|
||||||
|
var normalized = preprocessed.replace(/[\/\.]/g, '-');
|
||||||
|
ts = Date.parse(normalized);
|
||||||
|
if (!isNaN(ts)) return new Date(ts);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the original input contained a time component.
|
||||||
|
*/
|
||||||
|
function hasTimeComponent(input) {
|
||||||
|
return /\d{1,2}:\d{2}/.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date object to YYYY-MM-DD or YYYY-MM-DD HH:MM:SS.
|
||||||
|
*/
|
||||||
|
function formatDate(date, includeTime) {
|
||||||
|
var out = date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
|
||||||
|
if (includeTime) {
|
||||||
|
out += ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a timestamp string to YYYY-MM-DD (or YYYY-MM-DD HH:MM:SS if time present).
|
||||||
|
* Returns null if the input cannot be parsed as a valid date.
|
||||||
|
*/
|
||||||
|
function normalizeTimestamp(input) {
|
||||||
|
var date = tryParse(input);
|
||||||
|
if (!date) return null;
|
||||||
|
return formatDate(date, hasTimeComponent(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditor(edid) {
|
||||||
|
return document.getElementById(edid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixSelection(edid) {
|
||||||
|
if (typeof DWgetSelection !== 'function' || typeof pasteText !== 'function') return false;
|
||||||
|
var textarea = getEditor(edid);
|
||||||
|
if (!textarea) return false;
|
||||||
|
|
||||||
|
var selection = DWgetSelection(textarea);
|
||||||
|
var text = selection.getText();
|
||||||
|
if (!text || !text.trim()) return false;
|
||||||
|
|
||||||
|
var normalized = normalizeTimestamp(text);
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
pasteText(selection, normalized, { nosel: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixAll(edid) {
|
||||||
|
var textarea = getEditor(edid);
|
||||||
|
if (!textarea) return false;
|
||||||
|
|
||||||
|
var selection = typeof DWgetSelection === 'function' ? DWgetSelection(textarea) : null;
|
||||||
|
var original = textarea.value;
|
||||||
|
var replaced = original.replace(CANDIDATE_REGEX, function (match) {
|
||||||
|
var normalized = normalizeTimestamp(match);
|
||||||
|
return normalized || match;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replaced !== original) {
|
||||||
|
textarea.value = replaced;
|
||||||
|
if (selection && typeof DWsetSelection === 'function') {
|
||||||
|
selection.start = Math.min(selection.start, textarea.value.length);
|
||||||
|
selection.end = Math.min(selection.end, textarea.value.length);
|
||||||
|
DWsetSelection(selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateFix.normalize = normalizeTimestamp;
|
||||||
|
DateFix.fixSelection = fixSelection;
|
||||||
|
DateFix.fixAll = fixAll;
|
||||||
|
|
||||||
|
// Toolbar button action handlers
|
||||||
|
// DokuWiki toolbar looks for addBtnAction<Type> functions for custom button types.
|
||||||
|
// The buttons are registered via PHP (TOOLBAR_DEFINE hook in action.php).
|
||||||
|
window.addBtnActionLuxtoolsDatefix = function ($btn, props, edid) {
|
||||||
|
$btn.on('click', function () {
|
||||||
|
fixSelection(edid);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return 'luxtools-datefix';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addBtnActionLuxtoolsDatefixAll = function ($btn, props, edid) {
|
||||||
|
$btn.on('click', function () {
|
||||||
|
fixAll(edid);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return 'luxtools-datefix-all';
|
||||||
|
};
|
||||||
|
})();
|
||||||
112
js/gallery-thumbnails.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* global window, document */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Gallery Thumbnails Module
|
||||||
|
// ============================================================
|
||||||
|
// Uses fetch() with AbortController to load thumbnails.
|
||||||
|
// This allows true HTTP request cancellation on navigation,
|
||||||
|
// unlike native loading="lazy" where queued requests block.
|
||||||
|
Luxtools.GalleryThumbnails = (function () {
|
||||||
|
var controller = null;
|
||||||
|
var maxConcurrent = 4;
|
||||||
|
var activeCount = 0;
|
||||||
|
var queue = [];
|
||||||
|
|
||||||
|
function abortAll() {
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
controller = null;
|
||||||
|
}
|
||||||
|
queue = [];
|
||||||
|
activeCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processQueue() {
|
||||||
|
if (!controller) return;
|
||||||
|
while (activeCount < maxConcurrent && queue.length > 0) {
|
||||||
|
var img = queue.shift();
|
||||||
|
loadThumb(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThumb(img) {
|
||||||
|
if (!controller) return;
|
||||||
|
|
||||||
|
var src = img.getAttribute('data-src');
|
||||||
|
if (!src) {
|
||||||
|
processQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCount++;
|
||||||
|
|
||||||
|
fetch(src, { signal: controller.signal })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
return response.blob();
|
||||||
|
})
|
||||||
|
.then(function (blob) {
|
||||||
|
img.src = URL.createObjectURL(blob);
|
||||||
|
img.removeAttribute('data-src');
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
// Aborted or failed - ignore
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
// Keep data-src for potential retry, just log
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
activeCount--;
|
||||||
|
processQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueThumb(img) {
|
||||||
|
if (!controller) return;
|
||||||
|
if (!img.getAttribute('data-src')) return;
|
||||||
|
if (img.getAttribute('data-queued') === '1') return;
|
||||||
|
img.setAttribute('data-queued', '1');
|
||||||
|
queue.push(img);
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var imgs = document.querySelectorAll(
|
||||||
|
'div.luxtools-gallery img.luxtools-thumb[data-src], div.luxtools-imagebox img[data-src]'
|
||||||
|
);
|
||||||
|
if (!imgs || !imgs.length) return;
|
||||||
|
|
||||||
|
// Create abort controller for all requests
|
||||||
|
controller = new AbortController();
|
||||||
|
|
||||||
|
// Abort all pending requests on navigation
|
||||||
|
window.addEventListener('beforeunload', abortAll);
|
||||||
|
window.addEventListener('pagehide', abortAll);
|
||||||
|
|
||||||
|
// Use IntersectionObserver to trigger loading
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
var io = new IntersectionObserver(function (entries) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
queueThumb(entry.target);
|
||||||
|
io.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
}, { rootMargin: '200px' });
|
||||||
|
|
||||||
|
imgs.forEach(function (img) { io.observe(img); });
|
||||||
|
} else {
|
||||||
|
// Fallback: queue all
|
||||||
|
imgs.forEach(queueThumb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
})();
|
||||||
265
js/lightbox.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/* global window, document */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Lightbox Module
|
||||||
|
// ============================================================
|
||||||
|
Luxtools.Lightbox = (function () {
|
||||||
|
var lb = null;
|
||||||
|
var img = null;
|
||||||
|
var cap = null;
|
||||||
|
var items = [];
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
// Zoom/pan state
|
||||||
|
var scale = 1;
|
||||||
|
var panX = 0;
|
||||||
|
var panY = 0;
|
||||||
|
var minScale = 1;
|
||||||
|
var maxScale = 5;
|
||||||
|
var isPanning = false;
|
||||||
|
var panStartX = 0;
|
||||||
|
var panStartY = 0;
|
||||||
|
|
||||||
|
// History state
|
||||||
|
var pushedHistory = false;
|
||||||
|
var closingFromPopstate = false;
|
||||||
|
|
||||||
|
function ensureElement() {
|
||||||
|
if (lb) return;
|
||||||
|
|
||||||
|
lb = document.createElement('div');
|
||||||
|
lb.className = 'luxtools-lightbox';
|
||||||
|
lb.setAttribute('role', 'dialog');
|
||||||
|
lb.setAttribute('aria-modal', 'true');
|
||||||
|
lb.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
lb.innerHTML =
|
||||||
|
'<div class="luxtools-lightbox-backdrop" data-luxtools-action="close"></div>' +
|
||||||
|
'<div class="luxtools-lightbox-stage">' +
|
||||||
|
'<button type="button" class="luxtools-lightbox-close" data-luxtools-action="close" aria-label="Close">×</button>' +
|
||||||
|
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-prev" data-luxtools-action="prev" aria-label="Previous"></button>' +
|
||||||
|
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-next" data-luxtools-action="next" aria-label="Next"></button>' +
|
||||||
|
'<div class="luxtools-lightbox-media">' +
|
||||||
|
'<img class="luxtools-lightbox-img" alt="" />' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="luxtools-lightbox-caption"></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.body.appendChild(lb);
|
||||||
|
img = lb.querySelector('img.luxtools-lightbox-img');
|
||||||
|
cap = lb.querySelector('.luxtools-lightbox-caption');
|
||||||
|
|
||||||
|
lb.addEventListener('click', onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampIndex(n) {
|
||||||
|
if (n < 0) return items.length - 1;
|
||||||
|
if (n >= items.length) return 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
if (scale <= 1 && panX === 0 && panY === 0) {
|
||||||
|
img.style.transform = '';
|
||||||
|
} else {
|
||||||
|
img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)';
|
||||||
|
}
|
||||||
|
img.style.cursor = scale > 1 ? 'grab' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
scale = 1;
|
||||||
|
panX = 0;
|
||||||
|
panY = 0;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var it = items[index];
|
||||||
|
img.src = it.full;
|
||||||
|
img.setAttribute('data-luxtools-index', String(index));
|
||||||
|
if (cap) cap.textContent = (it.name || '').trim();
|
||||||
|
resetZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
index = clampIndex(index + 1);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
index = clampIndex(index - 1);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||||
|
scale = Math.max(minScale, Math.min(maxScale, scale + delta));
|
||||||
|
if (scale <= 1) { panX = 0; panY = 0; }
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (scale > 1) {
|
||||||
|
scale = 1;
|
||||||
|
panX = 0;
|
||||||
|
panY = 0;
|
||||||
|
} else {
|
||||||
|
scale = 2.5;
|
||||||
|
}
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e) {
|
||||||
|
if (scale > 1 && e.button === 0) {
|
||||||
|
isPanning = true;
|
||||||
|
panStartX = e.clientX - panX * scale;
|
||||||
|
panStartY = e.clientY - panY * scale;
|
||||||
|
img.style.cursor = 'grabbing';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
if (isPanning && scale > 1) {
|
||||||
|
panX = (e.clientX - panStartX) / scale;
|
||||||
|
panY = (e.clientY - panStartY) / scale;
|
||||||
|
applyTransform();
|
||||||
|
img.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isPanning = false;
|
||||||
|
img.style.cursor = scale > 1 ? 'grab' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (!lb || !lb.classList.contains('is-open')) return;
|
||||||
|
var key = e.key || '';
|
||||||
|
if (key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
} else if (key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
next();
|
||||||
|
} else if (key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
prev();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopState() {
|
||||||
|
if (!lb || !lb.classList.contains('is-open')) return;
|
||||||
|
closingFromPopstate = true;
|
||||||
|
try { close(); } finally { closingFromPopstate = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t || !t.getAttribute) return;
|
||||||
|
var action = t.getAttribute('data-luxtools-action') || '';
|
||||||
|
if (action === 'close') { e.preventDefault(); close(); return; }
|
||||||
|
if (action === 'next') { e.preventDefault(); next(); return; }
|
||||||
|
if (action === 'prev') { e.preventDefault(); prev(); return; }
|
||||||
|
|
||||||
|
if (t.closest && t.closest('button.luxtools-lightbox-zone')) return;
|
||||||
|
if (t.closest && t.closest('img.luxtools-lightbox-img')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachListeners() {
|
||||||
|
document.addEventListener('keydown', onKeyDown, true);
|
||||||
|
window.addEventListener('popstate', onPopState, true);
|
||||||
|
img.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
img.addEventListener('dblclick', onDblClick);
|
||||||
|
img.addEventListener('mousedown', onMouseDown);
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachListeners() {
|
||||||
|
document.removeEventListener('keydown', onKeyDown, true);
|
||||||
|
window.removeEventListener('popstate', onPopState, true);
|
||||||
|
img.removeEventListener('wheel', onWheel);
|
||||||
|
img.removeEventListener('dblclick', onDblClick);
|
||||||
|
img.removeEventListener('mousedown', onMouseDown);
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(galleryEl, startEl) {
|
||||||
|
var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]');
|
||||||
|
items = [];
|
||||||
|
links.forEach(function (a) {
|
||||||
|
var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || '';
|
||||||
|
var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || '';
|
||||||
|
if (!full) return;
|
||||||
|
items.push({ el: a, full: full, name: name });
|
||||||
|
});
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
index = 0;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].el === startEl) { index = i; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureElement();
|
||||||
|
pushedHistory = false;
|
||||||
|
closingFromPopstate = false;
|
||||||
|
|
||||||
|
lb.classList.add('is-open');
|
||||||
|
lb.setAttribute('aria-hidden', 'false');
|
||||||
|
try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {}
|
||||||
|
try { document.body.style.overflow = 'hidden'; } catch (e) {}
|
||||||
|
|
||||||
|
attachListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.history && window.history.pushState) {
|
||||||
|
window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href);
|
||||||
|
pushedHistory = true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!lb) return;
|
||||||
|
|
||||||
|
lb.classList.remove('is-open');
|
||||||
|
lb.setAttribute('aria-hidden', 'true');
|
||||||
|
try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {}
|
||||||
|
try { document.body.style.overflow = ''; } catch (e) {}
|
||||||
|
img.src = '';
|
||||||
|
resetZoom();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
|
||||||
|
if (pushedHistory && !closingFromPopstate) {
|
||||||
|
try {
|
||||||
|
if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: open,
|
||||||
|
close: close
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
})();
|
||||||
116
js/linkfavicon.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/* global document */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link Favicon Module
|
||||||
|
*
|
||||||
|
* Displays favicons for external links using DuckDuckGo's favicon service.
|
||||||
|
* Based on the linkfavicon plugin by Shao Yanmin.
|
||||||
|
*
|
||||||
|
* Note: DDG returns a grey placeholder icon for domains without favicons.
|
||||||
|
* Detecting this placeholder client-side is not reliably possible due to
|
||||||
|
* CORS restrictions preventing canvas pixel inspection.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
// Cache states for favicon URLs
|
||||||
|
var ICON_NOT_FOUND = -1;
|
||||||
|
var ICON_LOADING = 0;
|
||||||
|
var ICON_LOADED = 1;
|
||||||
|
var faviconCache = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload an image to verify it loads.
|
||||||
|
* @param {string} src - Image URL to load
|
||||||
|
* @returns {Promise} Resolves on load, rejects on error
|
||||||
|
*/
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var image = new Image();
|
||||||
|
image.addEventListener('load', resolve);
|
||||||
|
image.addEventListener('error', reject);
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the favicon as background image to all matching links.
|
||||||
|
* @param {string} faviconUrl - The favicon URL to apply
|
||||||
|
*/
|
||||||
|
function applyFavicon(faviconUrl) {
|
||||||
|
if (faviconCache[faviconUrl] !== ICON_LOADED) return;
|
||||||
|
|
||||||
|
var links = document.querySelectorAll('[data-linkfavicon="' + faviconUrl + '"]');
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
links[i].classList.add('linkfavicon');
|
||||||
|
links[i].style.backgroundImage = 'url(' + faviconUrl + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain from URL.
|
||||||
|
* @param {string} url - Full URL
|
||||||
|
* @returns {string|null} Domain or null if invalid
|
||||||
|
*/
|
||||||
|
function getDomain(url) {
|
||||||
|
try {
|
||||||
|
var parsed = new URL(url);
|
||||||
|
return parsed.hostname;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize favicons for all external links on the page.
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
// Find all external links (DokuWiki marks them with class 'urlextern')
|
||||||
|
var links = document.querySelectorAll('a.urlextern');
|
||||||
|
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
var link = links[i];
|
||||||
|
var href = link.getAttribute('href');
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
var domain = getDomain(href);
|
||||||
|
if (!domain) continue;
|
||||||
|
|
||||||
|
// DuckDuckGo favicon service URL
|
||||||
|
var faviconUrl = 'https://icons.duckduckgo.com/ip3/' + domain + '.ico';
|
||||||
|
|
||||||
|
// Mark the link with the favicon URL for later reference
|
||||||
|
link.setAttribute('data-linkfavicon', faviconUrl);
|
||||||
|
|
||||||
|
// Load and cache the favicon if not already processed
|
||||||
|
if (faviconCache[faviconUrl] === undefined) {
|
||||||
|
faviconCache[faviconUrl] = ICON_LOADING;
|
||||||
|
|
||||||
|
(function (url) {
|
||||||
|
loadImage(url)
|
||||||
|
.then(function () {
|
||||||
|
faviconCache[url] = ICON_LOADED;
|
||||||
|
applyFavicon(url);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
faviconCache[url] = ICON_NOT_FOUND;
|
||||||
|
});
|
||||||
|
})(faviconUrl);
|
||||||
|
} else if (faviconCache[faviconUrl] === ICON_LOADED) {
|
||||||
|
// Already loaded, apply immediately
|
||||||
|
link.classList.add('linkfavicon');
|
||||||
|
link.style.backgroundImage = 'url(' + faviconUrl + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for potential external use
|
||||||
|
Luxtools.LinkFavicon = {
|
||||||
|
init: init
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', init, false);
|
||||||
|
})();
|
||||||
130
js/main.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* global window, document */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
var Lightbox = Luxtools.Lightbox;
|
||||||
|
var OpenService = Luxtools.OpenService;
|
||||||
|
var GalleryThumbnails = Luxtools.GalleryThumbnails;
|
||||||
|
var Scratchpads = Luxtools.Scratchpads;
|
||||||
|
var CalendarWidget = Luxtools.CalendarWidget;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Click Handlers
|
||||||
|
// ============================================================
|
||||||
|
function findOpenElement(target) {
|
||||||
|
var el = target;
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.classList && el.classList.contains('luxtools-open')) return el;
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGalleryItem(target) {
|
||||||
|
var el = target;
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.classList && el.classList.contains('luxtools-gallery-item')) return el;
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(event) {
|
||||||
|
// Image gallery lightbox: intercept clicks so we don't navigate away.
|
||||||
|
var galleryItem = findGalleryItem(event.target);
|
||||||
|
if (galleryItem && Lightbox && Lightbox.open) {
|
||||||
|
var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null;
|
||||||
|
if (gallery) {
|
||||||
|
event.preventDefault();
|
||||||
|
Lightbox.open(gallery, galleryItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var el = findOpenElement(event.target);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// {{open>...}} renders as a link; avoid jumping to '#'.
|
||||||
|
if (el.tagName && el.tagName.toLowerCase() === 'a') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = el.getAttribute('data-path') || '';
|
||||||
|
if (!raw) return;
|
||||||
|
if (!OpenService || !OpenService.openViaService) return;
|
||||||
|
|
||||||
|
// Prefer local client service.
|
||||||
|
OpenService.openViaService(el, raw)
|
||||||
|
.catch(function (err) {
|
||||||
|
// If the browser blocks the request before it reaches localhost (mixed-content,
|
||||||
|
// extensions, stricter CORS handling), fall back to a no-CORS GET ping.
|
||||||
|
if (OpenService && OpenService.pingOpenViaImage) {
|
||||||
|
OpenService.pingOpenViaImage(el, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to old behavior (often blocked in modern browsers).
|
||||||
|
var url = OpenService && OpenService.normalizeToFileUrl ? OpenService.normalizeToFileUrl(raw) : '';
|
||||||
|
if (!url) return;
|
||||||
|
console.warn('Local client service failed, falling back to file:// navigation:', err);
|
||||||
|
try {
|
||||||
|
window.open(url, '_blank', 'noopener');
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('Failed to open file URL:', e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChronologicalEventTimes() {
|
||||||
|
var nodes = document.querySelectorAll('.luxtools-event-time[data-luxtools-start]');
|
||||||
|
if (!nodes || nodes.length === 0) return;
|
||||||
|
|
||||||
|
var formatter;
|
||||||
|
try {
|
||||||
|
formatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
formatter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var node = nodes[i];
|
||||||
|
var raw = node.getAttribute('data-luxtools-start') || '';
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
var date = new Date(raw);
|
||||||
|
if (isNaN(date.getTime())) continue;
|
||||||
|
|
||||||
|
var label;
|
||||||
|
if (formatter) {
|
||||||
|
label = formatter.format(date);
|
||||||
|
} else {
|
||||||
|
var hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
var mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
label = hh + ':' + mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.textContent = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Initialize
|
||||||
|
// ============================================================
|
||||||
|
document.addEventListener('click', onClick, false);
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init();
|
||||||
|
initChronologicalEventTimes();
|
||||||
|
if (CalendarWidget && CalendarWidget.init) CalendarWidget.init();
|
||||||
|
}, false);
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (Scratchpads && Scratchpads.init) Scratchpads.init();
|
||||||
|
}, false);
|
||||||
|
})();
|
||||||
94
js/open-service.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/* global window */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Open Service Module (file:// links)
|
||||||
|
// ============================================================
|
||||||
|
Luxtools.OpenService = (function () {
|
||||||
|
function getServiceUrl(el) {
|
||||||
|
var url = el.getAttribute('data-service-url') || '';
|
||||||
|
url = (url || '').trim();
|
||||||
|
if (!url) return '';
|
||||||
|
// strip trailing slashes
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pingOpenViaImage(el, rawPath) {
|
||||||
|
var baseUrl = getServiceUrl(el);
|
||||||
|
if (!baseUrl) return;
|
||||||
|
var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath);
|
||||||
|
|
||||||
|
// Fire-and-forget without CORS.
|
||||||
|
try {
|
||||||
|
var img = new window.Image();
|
||||||
|
img.src = url;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openViaService(el, rawPath) {
|
||||||
|
var baseUrl = getServiceUrl(el);
|
||||||
|
if (!baseUrl) return Promise.reject(new Error('No client service configured'));
|
||||||
|
|
||||||
|
var headers = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
return window.fetch(baseUrl + '/open', {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify({ path: rawPath })
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (body) {
|
||||||
|
var msg = (body && body.message) ? body.message : ('HTTP ' + res.status);
|
||||||
|
throw new Error(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json().catch(function () { return { ok: true }; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToFileUrl(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
|
||||||
|
// already a file URL
|
||||||
|
if (/^file:\/\//i.test(path)) return path;
|
||||||
|
|
||||||
|
// UNC path: \\server\share\path
|
||||||
|
if (/^\\\\/.test(path)) {
|
||||||
|
var p = path.replace(/^\\\\/, '');
|
||||||
|
p = p.replace(/\\/g, '/');
|
||||||
|
return 'file://///' + p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows drive: C:\path\to\file
|
||||||
|
if (/^[a-zA-Z]:\\/.test(path)) {
|
||||||
|
var drive = path[0].toUpperCase();
|
||||||
|
var rest = path.slice(2).replace(/\\/g, '/');
|
||||||
|
return 'file:///' + drive + ':' + rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSIX absolute: /home/user/file
|
||||||
|
if (path[0] === '/') {
|
||||||
|
return 'file://' + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to using the provided string.
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openViaService: openViaService,
|
||||||
|
pingOpenViaImage: pingOpenViaImage,
|
||||||
|
normalizeToFileUrl: normalizeToFileUrl
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
})();
|
||||||
180
js/page-link.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/* global window, document */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function getSectok() {
|
||||||
|
try {
|
||||||
|
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
|
||||||
|
if (inp && inp.value) return String(inp.value);
|
||||||
|
} catch (e2) {}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageId() {
|
||||||
|
try {
|
||||||
|
if (window.JSINFO && window.JSINFO.id) return String(window.JSINFO.id);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var input = document.querySelector('input[name="id"]');
|
||||||
|
if (input && input.value) return String(input.value);
|
||||||
|
} catch (e2) {}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl() {
|
||||||
|
try {
|
||||||
|
if (window.DOKU_BASE) return String(window.DOKU_BASE);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.JSINFO && window.JSINFO.base) return String(window.JSINFO.base);
|
||||||
|
} catch (e2) {}
|
||||||
|
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPageLink(cmd, params) {
|
||||||
|
var pageId = getPageId();
|
||||||
|
if (!pageId) return Promise.reject(new Error('missing page id'));
|
||||||
|
|
||||||
|
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
|
||||||
|
|
||||||
|
var payload = new window.URLSearchParams();
|
||||||
|
payload.set('cmd', cmd);
|
||||||
|
payload.set('id', pageId);
|
||||||
|
if (params && typeof params === 'object') {
|
||||||
|
Object.keys(params).forEach(function (key) {
|
||||||
|
payload.set(key, String(params[key]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||||
|
body: payload.toString()
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (body) {
|
||||||
|
if (!res.ok || !body || body.ok !== true) {
|
||||||
|
throw new Error('request failed');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePageLink() {
|
||||||
|
return requestPageLink('ensure', { sectok: getSectok() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkPageLink() {
|
||||||
|
return requestPageLink('unlink', { sectok: getSectok() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(pageId) {
|
||||||
|
try {
|
||||||
|
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
|
||||||
|
var href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId);
|
||||||
|
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = href;
|
||||||
|
a.download = '.pagelink';
|
||||||
|
a.style.display = 'none';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPageLinkInfo(pageId) {
|
||||||
|
if (!pageId) return Promise.reject(new Error('missing page id'));
|
||||||
|
|
||||||
|
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
|
||||||
|
var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId);
|
||||||
|
|
||||||
|
return window.fetch(query, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (body) {
|
||||||
|
if (!res.ok || !body || body.ok !== true) {
|
||||||
|
throw new Error('request failed');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachDocInfoLink() {
|
||||||
|
var container = document.querySelector('.docInfo');
|
||||||
|
if (!container || !container.getAttribute) return;
|
||||||
|
if (container.getAttribute('data-luxtools-pagelink-docinfo') === '1') return;
|
||||||
|
container.setAttribute('data-luxtools-pagelink-docinfo', '1');
|
||||||
|
|
||||||
|
var pageId = getPageId();
|
||||||
|
if (!pageId) return;
|
||||||
|
|
||||||
|
fetchPageLinkInfo(pageId).then(function (info) {
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.href = '#';
|
||||||
|
|
||||||
|
if (!info || !info.uuid) {
|
||||||
|
link.textContent = 'Link Page';
|
||||||
|
link.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
ensurePageLink().then(function (res) {
|
||||||
|
if (!res || !res.uuid) throw new Error('no uuid');
|
||||||
|
triggerDownload(pageId);
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (window.console && window.console.warn) {
|
||||||
|
window.console.warn('PageLink ensure failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (info.linked) {
|
||||||
|
link.textContent = 'Unlink Page';
|
||||||
|
link.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!window.confirm('Unlink page?')) return;
|
||||||
|
unlinkPageLink().then(function () {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
try { window.location.reload(); } catch (e2) {}
|
||||||
|
}, 400);
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (window.console && window.console.warn) {
|
||||||
|
window.console.warn('PageLink unlink failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
link.textContent = 'Download Link File';
|
||||||
|
link.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerDownload(pageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = container.firstChild;
|
||||||
|
container.insertBefore(link, first);
|
||||||
|
if (first) {
|
||||||
|
container.insertBefore(document.createTextNode(' · '), first);
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
// ignore failures
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
attachDocInfoLink();
|
||||||
|
}, false);
|
||||||
|
})();
|
||||||
187
js/scratchpads.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* global window, document */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Scratchpads Module
|
||||||
|
// ============================================================
|
||||||
|
Luxtools.Scratchpads = (function () {
|
||||||
|
function setEditMode(root, isEditing) {
|
||||||
|
if (!root || !root.classList) return;
|
||||||
|
|
||||||
|
var view = root.querySelector('.luxtools-scratchpad-view');
|
||||||
|
var editor = root.querySelector('.luxtools-scratchpad-editor');
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
root.classList.add('is-editing');
|
||||||
|
if (view) view.hidden = true;
|
||||||
|
if (editor) editor.hidden = false;
|
||||||
|
} else {
|
||||||
|
root.classList.remove('is-editing');
|
||||||
|
if (view) view.hidden = false;
|
||||||
|
if (editor) editor.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(root, msg) {
|
||||||
|
var el = root.querySelector('.luxtools-scratchpad-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSectok(root) {
|
||||||
|
// Prefer a token embedded with the rendered scratchpad.
|
||||||
|
try {
|
||||||
|
if (root && root.getAttribute) {
|
||||||
|
var t = String(root.getAttribute('data-sectok') || '').trim();
|
||||||
|
if (t) return t;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Fall back to DokuWiki's global JSINFO.
|
||||||
|
try {
|
||||||
|
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Last resort: find any security token input on the page.
|
||||||
|
try {
|
||||||
|
var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
|
||||||
|
if (inp && inp.value) return String(inp.value);
|
||||||
|
} catch (e2) {}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPad(root) {
|
||||||
|
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
|
||||||
|
var pad = (root.getAttribute('data-pad') || '').trim();
|
||||||
|
var pageId = (root.getAttribute('data-pageid') || '').trim();
|
||||||
|
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
|
||||||
|
|
||||||
|
var url = endpoint + '?cmd=load&pad=' + encodeURIComponent(pad) + '&id=' + encodeURIComponent(pageId);
|
||||||
|
return window.fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (body) {
|
||||||
|
if (!res.ok || !body || body.ok !== true) {
|
||||||
|
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return body.text || '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePad(root, text) {
|
||||||
|
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
|
||||||
|
var pad = (root.getAttribute('data-pad') || '').trim();
|
||||||
|
var pageId = (root.getAttribute('data-pageid') || '').trim();
|
||||||
|
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
|
||||||
|
|
||||||
|
var params = new window.URLSearchParams();
|
||||||
|
params.set('cmd', 'save');
|
||||||
|
params.set('pad', pad);
|
||||||
|
params.set('id', pageId);
|
||||||
|
params.set('text', text || '');
|
||||||
|
params.set('sectok', getSectok(root));
|
||||||
|
|
||||||
|
return window.fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||||
|
body: params.toString()
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (body) {
|
||||||
|
if (!res.ok || !body || body.ok !== true) {
|
||||||
|
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(root) {
|
||||||
|
var editor = root.querySelector('.luxtools-scratchpad-editor');
|
||||||
|
var textarea = root.querySelector('textarea.luxtools-scratchpad-text');
|
||||||
|
if (!editor || !textarea) return;
|
||||||
|
|
||||||
|
setEditMode(root, true);
|
||||||
|
setStatus(root, 'Loading…');
|
||||||
|
textarea.disabled = true;
|
||||||
|
|
||||||
|
loadPad(root).then(function (text) {
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.disabled = false;
|
||||||
|
setStatus(root, '');
|
||||||
|
textarea.focus();
|
||||||
|
}).catch(function (e) {
|
||||||
|
textarea.disabled = false;
|
||||||
|
setStatus(root, 'Load failed: ' + (e && e.message ? e.message : 'error'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor(root) {
|
||||||
|
var editor = root.querySelector('.luxtools-scratchpad-editor');
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
setEditMode(root, false);
|
||||||
|
setStatus(root, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t) return;
|
||||||
|
|
||||||
|
var edit = t.closest ? t.closest('a.luxtools-scratchpad-edit') : null;
|
||||||
|
if (edit) {
|
||||||
|
var root = edit.closest('div.luxtools-scratchpad');
|
||||||
|
if (!root) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openEditor(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var save = t.closest ? t.closest('button.luxtools-scratchpad-save') : null;
|
||||||
|
if (save) {
|
||||||
|
var rootS = save.closest('div.luxtools-scratchpad');
|
||||||
|
if (!rootS) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var textareaS = rootS.querySelector('textarea.luxtools-scratchpad-text');
|
||||||
|
if (!textareaS) return;
|
||||||
|
textareaS.disabled = true;
|
||||||
|
setStatus(rootS, 'Saving…');
|
||||||
|
savePad(rootS, textareaS.value).then(function () {
|
||||||
|
setStatus(rootS, 'Saved. Reloading…');
|
||||||
|
try { window.location.reload(); } catch (err) {}
|
||||||
|
}).catch(function (err) {
|
||||||
|
textareaS.disabled = false;
|
||||||
|
setStatus(rootS, 'Save failed: ' + (err && err.message ? err.message : 'error'));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancel = t.closest ? t.closest('button.luxtools-scratchpad-cancel') : null;
|
||||||
|
if (cancel) {
|
||||||
|
var rootC = cancel.closest('div.luxtools-scratchpad');
|
||||||
|
if (!rootC) return;
|
||||||
|
e.preventDefault();
|
||||||
|
closeEditor(rootC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]');
|
||||||
|
if (!pads || !pads.length) return;
|
||||||
|
document.addEventListener('click', onClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
})();
|
||||||
@@ -7,8 +7,81 @@
|
|||||||
* @author Gina Haeussge <osd@foosel.net>
|
* @author Gina Haeussge <osd@foosel.net>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$lang['filename'] = 'Dateiname';
|
$lang["filename"] = "Dateiname";
|
||||||
$lang['filesize'] = 'Dateigröße';
|
$lang["filesize"] = "Dateigröße";
|
||||||
$lang['lastmodified'] = 'Letzte Änderung';
|
$lang["lastmodified"] = "Letzte Änderung";
|
||||||
$lang['error_nomatch'] = 'Keine Treffer';
|
$lang["openlocation"] = "Ort öffnen";
|
||||||
$lang['error_outsidejail'] = 'Zugriff verweigert';
|
$lang["error_nomatch"] = "Keine Treffer";
|
||||||
|
$lang["error_outsidejail"] = "Zugriff verweigert";
|
||||||
|
|
||||||
|
$lang["empty_files"] = "Keine Dateien";
|
||||||
|
$lang["empty_images"] = "Keine Bilder";
|
||||||
|
|
||||||
|
$lang["menu"] = "luxtools";
|
||||||
|
$lang["settings"] = "luxtools-Einstellungen";
|
||||||
|
$lang["legend"] = "Einstellungen";
|
||||||
|
$lang["btn_save"] = "Speichern";
|
||||||
|
$lang["saved"] = "Einstellungen gespeichert.";
|
||||||
|
$lang["err_save"] =
|
||||||
|
"Einstellungen konnten nicht gespeichert werden. Bitte Schreibrechte für conf/local.php prüfen.";
|
||||||
|
$lang["err_security"] = "Sicherheits-Token ungültig. Bitte erneut versuchen.";
|
||||||
|
|
||||||
|
$lang["paths"] =
|
||||||
|
"Erlaubte Basis-Pfade (eine pro Zeile). Optional: Pfad mit A>-Alias ergaenzen.";
|
||||||
|
$lang["scratchpad_paths"] =
|
||||||
|
"Scratchpad-Dateien (eine pro Zeile). Jeder Dateipfad muss eine Erweiterung enthalten. Mit einer folgenden A>-Zeile wird der Pad-Name gesetzt, der im Wiki verwendet wird.";
|
||||||
|
$lang["extensions"] = "Kommagetrennte Liste erlaubter Dateiendungen.";
|
||||||
|
|
||||||
|
$lang["listing_defaults"] = "Listen-Standardwerte";
|
||||||
|
$lang["default_sort"] =
|
||||||
|
"Standard-Sortierschlüssel (name|iname|ctime|mtime|size).";
|
||||||
|
$lang["default_order"] = "Standard-Sortierreihenfolge (asc|desc).";
|
||||||
|
$lang["default_style"] = "Standard-Ausgabeformat (list|olist|table).";
|
||||||
|
$lang["default_tableheader"] = "Tabellenkopf standardmäßig anzeigen.";
|
||||||
|
$lang["default_foldersfirst"] = "Ordner standardmäßig vor Dateien sortieren.";
|
||||||
|
$lang["default_recursive"] = "Unterverzeichnisse standardmäßig einbeziehen.";
|
||||||
|
$lang["default_titlefile"] = "Standard-Name der Titeldatei (z.B. _title.txt).";
|
||||||
|
$lang["default_cache"] =
|
||||||
|
"Seiten-Caching standardmäßig aktivieren (0 deaktiviert Caching).";
|
||||||
|
$lang["default_randlinks"] =
|
||||||
|
"Standardmäßig Cache-Busting-Parameter auf Basis mtime hinzufügen.";
|
||||||
|
$lang["default_showsize"] =
|
||||||
|
"Dateigröße standardmäßig anzeigen (falls unterstützt).";
|
||||||
|
$lang["default_showdate"] =
|
||||||
|
"Änderungsdatum standardmäßig anzeigen (falls unterstützt).";
|
||||||
|
$lang["default_listsep"] =
|
||||||
|
'Standard-Trennzeichen für Listenausgabe (z.B. ", ").';
|
||||||
|
$lang["default_maxheight"] =
|
||||||
|
"Standard max-height in px für Scroll-Container (-1 deaktiviert).";
|
||||||
|
|
||||||
|
$lang["defaults"] =
|
||||||
|
"Legacy-Standardoptionen (fortgeschritten). Vorhandene Werte werden weiterhin berücksichtigt, sind aber nicht mehr über die UI konfigurierbar.";
|
||||||
|
$lang["thumb_placeholder"] =
|
||||||
|
"MediaManager-ID für den Platzhalter der Galerie-Thumbnails.";
|
||||||
|
$lang["gallery_thumb_scale"] =
|
||||||
|
"Skalierungsfaktor für Galerie-Thumbnails. 2 erzeugt schärfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150×150).";
|
||||||
|
$lang["open_service_url"] =
|
||||||
|
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
|
||||||
|
$lang["image_base_path"] =
|
||||||
|
"Basis-Dateisystempfad für die chronologische Foto-Integration.";
|
||||||
|
$lang["calendar_ics_files"] =
|
||||||
|
"Lokale Kalender-.ics-Dateien (ein absoluter Dateipfad pro Zeile).";
|
||||||
|
$lang["pagelink_search_depth"] =
|
||||||
|
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
|
||||||
|
|
||||||
|
$lang["scratchpad_edit"] = "Scratchpad bearbeiten";
|
||||||
|
$lang["scratchpad_save"] = "Speichern";
|
||||||
|
$lang["scratchpad_cancel"] = "Abbrechen";
|
||||||
|
$lang["scratchpad_err_nopath"] = "Scratchpad-Pfad fehlt";
|
||||||
|
$lang["scratchpad_err_badpath"] = "Ungültiger Scratchpad-Pfad";
|
||||||
|
$lang["scratchpad_err_unknown"] = "Unbekannter Scratchpad-Name";
|
||||||
|
$lang["scratchpad_err_unreadable"] = "Scratchpad-Datei ist nicht lesbar";
|
||||||
|
|
||||||
|
$lang["toolbar_code_title"] = "Code-Block";
|
||||||
|
$lang["toolbar_code_sample"] = "Ihr Code hier";
|
||||||
|
$lang["toolbar_datefix_title"] = "Datums-Fix";
|
||||||
|
$lang["toolbar_datefix_all_title"] = "Datums-Fix (Alle)";
|
||||||
|
$lang["pagelink_unlinked"] = "Seite nicht verknüpft";
|
||||||
|
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
|
||||||
|
$lang["chronological_photos_title"] = "Fotos";
|
||||||
|
$lang["chronological_events_title"] = "Termine";
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
$lang['allow_in_comments'] = 'Filelistsyntax in Kommentaren erlauben.';
|
$lang["paths"] = "Erlaubte Basis-Pfade (eine pro Zeile). Optional: Pfad mit A>-Alias ergaenzen.";
|
||||||
|
$lang["scratchpad_paths"] = "Scratchpad-Dateien (eine pro Zeile). Jeder Dateipfad muss eine Erweiterung enthalten. Mit einer folgenden A>-Zeile wird der Pad-Name gesetzt, der im Wiki verwendet wird.";
|
||||||
|
$lang["extensions"] = "Kommagetrennte Liste erlaubter Dateiendungen.";
|
||||||
|
|
||||||
|
$lang["listing_defaults"] = "Listen-Standardwerte";
|
||||||
|
$lang["default_sort"] = "Standard-Sortierschlüssel (name|iname|ctime|mtime|size).";
|
||||||
|
$lang["default_order"] = "Standard-Sortierreihenfolge (asc|desc).";
|
||||||
|
$lang["default_style"] = "Standard-Ausgabeformat (list|olist|table).";
|
||||||
|
$lang["default_tableheader"] = "Tabellenkopf standardmäßig anzeigen.";
|
||||||
|
$lang["default_foldersfirst"] = "Ordner standardmäßig vor Dateien sortieren.";
|
||||||
|
$lang["default_recursive"] = "Unterverzeichnisse standardmäßig einbeziehen.";
|
||||||
|
$lang["default_titlefile"] = "Standard-Name der Titeldatei (z.B. _title.txt).";
|
||||||
|
$lang["default_cache"] = "Seiten-Caching standardmäßig aktivieren (0 deaktiviert Caching).";
|
||||||
|
$lang["default_randlinks"] = "Standardmäßig Cache-Busting-Parameter auf Basis mtime hinzufügen.";
|
||||||
|
$lang["default_showsize"] = "Dateigröße standardmäßig anzeigen (falls unterstützt).";
|
||||||
|
$lang["default_showdate"] = "Änderungsdatum standardmäßig anzeigen (falls unterstützt).";
|
||||||
|
$lang["default_listsep"] = "Standard-Trennzeichen für Listenausgabe (z.B. \", \").";
|
||||||
|
$lang["default_maxheight"] = "Standard max-height in px für Scroll-Container (-1 deaktiviert).";
|
||||||
|
|
||||||
|
$lang["defaults"] = "Legacy-Standardoptionen (fortgeschritten). Vorhandene Werte werden weiterhin berücksichtigt, sind aber nicht mehr über die UI konfigurierbar.";
|
||||||
|
|
||||||
|
$lang["thumb_placeholder"] = "MediaManager-ID fuer den Platzhalter der Galerie-Thumbnails.";
|
||||||
|
|
||||||
|
$lang["gallery_thumb_scale"] = "Skalierungsfaktor fuer Galerie-Thumbnails. 2 erzeugt schaerfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150x150).";
|
||||||
|
|
||||||
|
$lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765).";
|
||||||
|
|
||||||
|
$lang["pagelink_search_depth"] = "Maximale Verzeichnisebene fuer .pagelink-Suche (0 = nur Root).";
|
||||||
|
|||||||
@@ -7,8 +7,82 @@
|
|||||||
* @author Gina Haeussge <osd@foosel.net>
|
* @author Gina Haeussge <osd@foosel.net>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$lang['filename'] = 'Filename';
|
$lang["filename"] = "Filename";
|
||||||
$lang['filesize'] = 'Filesize';
|
$lang["filesize"] = "Filesize";
|
||||||
$lang['lastmodified'] = 'Last modified';
|
$lang["lastmodified"] = "Last modified";
|
||||||
$lang['error_nomatch'] = 'No match';
|
$lang["openlocation"] = "Open Location";
|
||||||
$lang['error_outsidejail'] = 'Access denied';
|
$lang["error_nomatch"] = "No match";
|
||||||
|
$lang["error_outsidejail"] = "Access denied";
|
||||||
|
|
||||||
|
$lang["empty_files"] = "No Files";
|
||||||
|
$lang["empty_images"] = "No Images";
|
||||||
|
|
||||||
|
$lang["menu"] = "luxtools";
|
||||||
|
$lang["settings"] = "luxtools settings";
|
||||||
|
$lang["legend"] = "Settings";
|
||||||
|
$lang["btn_save"] = "Save";
|
||||||
|
$lang["saved"] = "Settings saved.";
|
||||||
|
$lang["err_save"] =
|
||||||
|
"Could not save settings. Please check write permissions for conf/local.php.";
|
||||||
|
$lang["err_security"] = "Security token mismatch. Please retry.";
|
||||||
|
|
||||||
|
$lang["paths"] =
|
||||||
|
"Allowed base paths (one per line). Optional: follow a path with A> alias.";
|
||||||
|
$lang["scratchpad_paths"] =
|
||||||
|
"Scratchpad files (one per line). Each file path must include the extension. Use a following A> line to set the pad name used in the wiki.";
|
||||||
|
$lang["extensions"] =
|
||||||
|
"Comma-separated list of allowed file extensions to list.";
|
||||||
|
|
||||||
|
$lang["listing_defaults"] = "Listing defaults";
|
||||||
|
$lang["default_sort"] = "Default sort key (name|iname|ctime|mtime|size).";
|
||||||
|
$lang["default_order"] = "Default sort order (asc|desc).";
|
||||||
|
$lang["default_style"] = "Default output style (list|olist|table).";
|
||||||
|
$lang["default_tableheader"] = "Render table header row by default.";
|
||||||
|
$lang["default_foldersfirst"] = "Group folders before files by default.";
|
||||||
|
$lang["default_recursive"] = "Recurse into subdirectories by default.";
|
||||||
|
$lang["default_titlefile"] = "Default title file name (e.g. _title.txt).";
|
||||||
|
$lang["default_cache"] = "Enable page caching by default (0 disables caching).";
|
||||||
|
$lang["default_randlinks"] =
|
||||||
|
"Add cache-busting query parameter based on mtime by default.";
|
||||||
|
$lang["default_showsize"] = "Show file size by default (where supported).";
|
||||||
|
$lang["default_showdate"] =
|
||||||
|
"Show last modified date by default (where supported).";
|
||||||
|
$lang["default_tablecolumns"] =
|
||||||
|
'Default table columns (comma-separated). Available: name, size, date. Example: "name,size,date" shows all columns.';
|
||||||
|
$lang["default_listsep"] =
|
||||||
|
'Default separator used in list-style rendering (e.g. ", ").';
|
||||||
|
$lang["default_maxheight"] =
|
||||||
|
"Default max-height in px for scroll container (-1 disables).";
|
||||||
|
|
||||||
|
$lang["defaults"] =
|
||||||
|
"Legacy default options string (advanced). Existing values are still honored, but this is no longer configurable via the UI.";
|
||||||
|
$lang["thumb_placeholder"] =
|
||||||
|
"MediaManager ID for the gallery thumbnail placeholder.";
|
||||||
|
$lang["gallery_thumb_scale"] =
|
||||||
|
"Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).";
|
||||||
|
$lang["open_service_url"] =
|
||||||
|
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
|
||||||
|
$lang["image_base_path"] =
|
||||||
|
"Base filesystem path for chronological photo integration.";
|
||||||
|
$lang["calendar_ics_files"] =
|
||||||
|
"Local calendar .ics files (one absolute file path per line).";
|
||||||
|
$lang["pagelink_search_depth"] =
|
||||||
|
"Maximum directory depth for .pagelink search (0 = only root).";
|
||||||
|
|
||||||
|
$lang["scratchpad_edit"] = "Edit scratchpad";
|
||||||
|
$lang["scratchpad_save"] = "Save";
|
||||||
|
$lang["scratchpad_cancel"] = "Cancel";
|
||||||
|
$lang["scratchpad_err_nopath"] = "Scratchpad path missing";
|
||||||
|
$lang["scratchpad_err_badpath"] = "Invalid scratchpad path";
|
||||||
|
$lang["scratchpad_err_unknown"] = "Unknown scratchpad pad name";
|
||||||
|
$lang["scratchpad_err_unreadable"] = "Scratchpad file is not readable";
|
||||||
|
|
||||||
|
$lang["toolbar_code_title"] = "Code Block";
|
||||||
|
$lang["toolbar_code_sample"] = "your code here";
|
||||||
|
$lang["toolbar_datefix_title"] = "Date Fix";
|
||||||
|
$lang["toolbar_datefix_all_title"] = "Date Fix (All)";
|
||||||
|
$lang["pagelink_unlinked"] = "Page not linked";
|
||||||
|
$lang["pagelink_multi_warning"] = "Multiple folders linked";
|
||||||
|
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
|
||||||
|
$lang["chronological_photos_title"] = "Photos";
|
||||||
|
$lang["chronological_events_title"] = "Events";
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
$lang['allow_in_comments'] = 'Whether to allow the filelist syntax to be used in comments.';
|
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias.';
|
||||||
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration';
|
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension; use a following A> line to set the pad name used in the wiki.';
|
||||||
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list';
|
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list';
|
||||||
|
|
||||||
|
$lang['listing_defaults'] = 'Listing defaults';
|
||||||
|
$lang['default_sort'] = 'Default sort key (name|iname|ctime|mtime|size).';
|
||||||
|
$lang['default_order'] = 'Default sort order (asc|desc).';
|
||||||
|
$lang['default_style'] = 'Default output style (list|olist|table).';
|
||||||
|
$lang['default_tableheader'] = 'Render table header row by default.';
|
||||||
|
$lang['default_foldersfirst'] = 'Group folders before files by default.';
|
||||||
|
$lang['default_recursive'] = 'Recurse into subdirectories by default.';
|
||||||
|
$lang['default_titlefile'] = 'Default title file name (e.g. _title.txt).';
|
||||||
|
$lang['default_cache'] = 'Enable page caching by default (0 disables caching).';
|
||||||
|
$lang['default_randlinks'] = 'Add cache-busting query parameter based on mtime by default.';
|
||||||
|
$lang['default_showsize'] = 'Show file size by default (where supported).';
|
||||||
|
$lang['default_showdate'] = 'Show last modified date by default (where supported).';
|
||||||
|
$lang['default_listsep'] = 'Default separator used in list-style rendering (e.g. ", ").';
|
||||||
|
$lang['default_maxheight'] = 'Default max-height in px for scroll container (-1 disables).';
|
||||||
|
|
||||||
|
$lang['defaults'] = 'Legacy default options string (advanced). Existing values are still honored, but this is no longer configurable via the UI.';
|
||||||
|
|
||||||
|
$lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placeholder';
|
||||||
|
|
||||||
|
$lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).';
|
||||||
|
|
||||||
|
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} link (e.g. http://127.0.0.1:8765).';
|
||||||
|
|
||||||
|
$lang['pagelink_search_depth'] = 'Maximum directory depth for .pagelink search (0 = only root).';
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
/*
|
|
||||||
* additional language strings used by plugin
|
|
||||||
*
|
|
||||||
* dutch version
|
|
||||||
*
|
|
||||||
* @author Mark C. Prins <mprins@users.sf.net>
|
|
||||||
*/
|
|
||||||
$lang['filename'] = 'Bestandsnaam';
|
|
||||||
$lang['filesize'] = 'Bestandsgrootte';
|
|
||||||
$lang['lastmodified'] = 'Laatst gewijzigd';
|
|
||||||
$lang['error_nomatch'] = 'Niets gevonden';
|
|
||||||
$lang['error_outsidejail'] = 'Toegang geweigerd';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Dutch language file for settings.
|
|
||||||
*
|
|
||||||
* @author Mark C. Prins <mprins@users.sf.net>
|
|
||||||
*/
|
|
||||||
$lang['allow_in_comments'] = 'Of de filelist syntax toegestaan is voor gebruik in commentaar.';
|
|
||||||
$lang['defaults'] = 'Default options. Gebruik dezelfde syntax als de inline configuratie.';
|
|
||||||
$lang['extensions'] = 'Komma-gescheiden lijst van toegestane bestandsextensies voor de lijst.';
|
|
||||||
168
pagelink.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\PageLink;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/autoload.php');
|
||||||
|
|
||||||
|
if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
|
||||||
|
require_once(DOKU_INC . 'inc/init.php');
|
||||||
|
|
||||||
|
global $INPUT;
|
||||||
|
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
|
if (!$syntax) {
|
||||||
|
http_status(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'plugin disabled']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON response.
|
||||||
|
*
|
||||||
|
* @param int $status
|
||||||
|
* @param array $payload
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function luxtools_pagelink_json(int $status, array $payload): void
|
||||||
|
{
|
||||||
|
http_status($status);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
echo json_encode($payload);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = (string)$INPUT->str('cmd');
|
||||||
|
$pageId = (string)$INPUT->str('id');
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === '' || $pageId === '') {
|
||||||
|
luxtools_pagelink_json(400, ['ok' => false, 'error' => 'missing parameters']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('auth_quickaclcheck')) {
|
||||||
|
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$acl = auth_quickaclcheck($pageId);
|
||||||
|
if ($cmd === 'info' || $cmd === 'download') {
|
||||||
|
if (!defined('AUTH_READ') || $acl < AUTH_READ) {
|
||||||
|
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!defined('AUTH_EDIT') || $acl < AUTH_EDIT) {
|
||||||
|
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'info') {
|
||||||
|
$depth = (int)$syntax->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
if ($uuid === null) {
|
||||||
|
luxtools_pagelink_json(200, [
|
||||||
|
'ok' => true,
|
||||||
|
'uuid' => null,
|
||||||
|
'linked' => false,
|
||||||
|
'folder' => null,
|
||||||
|
'multiple' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = $pageLink->resolveUuid($uuid);
|
||||||
|
$folder = $info['folder'] ?? null;
|
||||||
|
$multiple = !empty($info['multiple']);
|
||||||
|
|
||||||
|
luxtools_pagelink_json(200, [
|
||||||
|
'ok' => true,
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'linked' => is_string($folder) && $folder !== '',
|
||||||
|
'folder' => is_string($folder) ? $folder : null,
|
||||||
|
'multiple' => $multiple,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'download') {
|
||||||
|
$depth = (int)$syntax->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
if ($uuid === null || $uuid === '') {
|
||||||
|
http_status(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
echo 'not linked';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_status(200);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename=".pagelink"; filename*=UTF-8\'\'%2Epagelink');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
echo $uuid;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'ensure') {
|
||||||
|
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
luxtools_pagelink_json(405, ['ok' => false, 'error' => 'method not allowed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkSecurityToken()) {
|
||||||
|
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'bad token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = (int)$syntax->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
|
||||||
|
if ($uuid !== null) {
|
||||||
|
luxtools_pagelink_json(200, ['ok' => true, 'uuid' => $uuid, 'created' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = PageLink::createUuidV4();
|
||||||
|
$ok = $pageLink->setPageUuid($pageId, $uuid);
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
luxtools_pagelink_json(500, ['ok' => false, 'error' => 'save failed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
luxtools_pagelink_json(200, ['ok' => true, 'uuid' => $uuid, 'created' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'unlink') {
|
||||||
|
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
luxtools_pagelink_json(405, ['ok' => false, 'error' => 'method not allowed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkSecurityToken()) {
|
||||||
|
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'bad token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = (int)$syntax->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
|
||||||
|
$result = $pageLink->unlinkPage($pageId);
|
||||||
|
|
||||||
|
luxtools_pagelink_json(200, [
|
||||||
|
'ok' => true,
|
||||||
|
'uuid' => $result['uuid'] ?? null,
|
||||||
|
'folder' => $result['folder'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
luxtools_pagelink_json(400, ['ok' => false, 'error' => 'unknown command']);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
base filelist
|
base luxtools
|
||||||
author Gina Häußge, Dokufreaks
|
author luxick
|
||||||
email freaks@dokuwiki.org
|
email dokuwiki@luxick.de
|
||||||
date 2024-03-13
|
date 2026-01-05
|
||||||
name Filelist Plugin
|
name luxtools
|
||||||
desc Lists files matching a given glob pattern.
|
desc Integrates host filesystem with DokuWiki (files, directories, images, open links, scratchpads).
|
||||||
url https://www.dokuwiki.org/plugin:filelist
|
url https://www.dokuwiki.org/plugin:luxtools
|
||||||
|
|||||||
128
scratchpad.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/autoload.php');
|
||||||
|
|
||||||
|
if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
|
||||||
|
require_once(DOKU_INC . 'inc/init.php');
|
||||||
|
|
||||||
|
global $INPUT;
|
||||||
|
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
|
if (!$syntax) {
|
||||||
|
http_status(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'plugin disabled']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON response.
|
||||||
|
*
|
||||||
|
* @param int $status
|
||||||
|
* @param array $payload
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function luxtools_scratchpad_json(int $status, array $payload): void
|
||||||
|
{
|
||||||
|
http_status($status);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
echo json_encode($payload);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = (string)$INPUT->str('cmd');
|
||||||
|
$pad = (string)$INPUT->str('pad');
|
||||||
|
$pageId = (string)$INPUT->str('id');
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = cleanID($pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === '' || $pad === '' || $pageId === '') {
|
||||||
|
luxtools_scratchpad_json(400, ['ok' => false, 'error' => 'missing parameters']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require the user to at least be able to read the host page.
|
||||||
|
if (function_exists('auth_quickaclcheck') && auth_quickaclcheck($pageId) < AUTH_READ) {
|
||||||
|
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = new ScratchpadMap((string)$syntax->getConf('scratchpad_paths'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resolved = (string)$map->resolve($pad);
|
||||||
|
|
||||||
|
if ($resolved === '' || str_ends_with($resolved, '/')) {
|
||||||
|
throw new Exception('Invalid scratchpad path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never allow writing/reading within DokuWiki-controlled paths.
|
||||||
|
if (Path::isWikiControlled(Path::cleanPath($resolved, false))) {
|
||||||
|
throw new Exception('Access to wiki files is not allowed');
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'access denied']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'load') {
|
||||||
|
$text = '';
|
||||||
|
$exists = @is_file($resolved);
|
||||||
|
if ($exists) {
|
||||||
|
if (!@is_readable($resolved)) {
|
||||||
|
luxtools_scratchpad_json(500, ['ok' => false, 'error' => 'unreadable']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$read = io_readFile($resolved, false);
|
||||||
|
if ($read === false) {
|
||||||
|
luxtools_scratchpad_json(500, ['ok' => false, 'error' => 'unreadable']);
|
||||||
|
}
|
||||||
|
$text = (string)$read;
|
||||||
|
}
|
||||||
|
luxtools_scratchpad_json(200, ['ok' => true, 'text' => $text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'save') {
|
||||||
|
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
luxtools_scratchpad_json(405, ['ok' => false, 'error' => 'method not allowed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require edit permission on the host page.
|
||||||
|
if (function_exists('auth_quickaclcheck') && auth_quickaclcheck($pageId) < AUTH_EDIT) {
|
||||||
|
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'forbidden']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkSecurityToken()) {
|
||||||
|
luxtools_scratchpad_json(403, ['ok' => false, 'error' => 'bad token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = (string)$INPUT->str('text');
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
$dir = dirname($resolved);
|
||||||
|
if (function_exists('io_mkdir_p')) {
|
||||||
|
io_mkdir_p($dir);
|
||||||
|
} elseif (!@is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = false;
|
||||||
|
if (function_exists('io_saveFile')) {
|
||||||
|
$ok = (bool)io_saveFile($resolved, $text);
|
||||||
|
} else {
|
||||||
|
$ok = @file_put_contents($resolved, $text, LOCK_EX) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
luxtools_scratchpad_json(500, ['ok' => false, 'error' => 'save failed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
luxtools_scratchpad_json(200, ['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
luxtools_scratchpad_json(400, ['ok' => false, 'error' => 'unknown command']);
|
||||||
241
src/ChronoID.php
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for canonical chronological page IDs.
|
||||||
|
*
|
||||||
|
* Canonical structure:
|
||||||
|
* - Day: baseNs:YYYY:MM:DD
|
||||||
|
* - Month: baseNs:YYYY:MM
|
||||||
|
* - Year: baseNs:YYYY
|
||||||
|
*
|
||||||
|
* Date input format:
|
||||||
|
* - Strict ISO date only: YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
class ChronoID
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if a string is a strict ISO date and a valid Gregorian date.
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isIsoDate(string $value): bool
|
||||||
|
{
|
||||||
|
return self::parseIsoDate($value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert YYYY-MM-DD to canonical day page ID.
|
||||||
|
*
|
||||||
|
* @param string $value Date in strict YYYY-MM-DD format
|
||||||
|
* @param string $baseNs Base namespace, default chronological
|
||||||
|
* @return string|null Canonical day ID or null on invalid input
|
||||||
|
*/
|
||||||
|
public static function dateToDayId(string $value, string $baseNs = 'chronological'): ?string
|
||||||
|
{
|
||||||
|
$parts = self::parseIsoDate($value);
|
||||||
|
if ($parts === null) return null;
|
||||||
|
|
||||||
|
$ns = self::normalizeBaseNs($baseNs);
|
||||||
|
if ($ns === null) return null;
|
||||||
|
|
||||||
|
[$year, $month, $day] = $parts;
|
||||||
|
return sprintf('%s:%04d:%02d:%02d', $ns, $year, $month, $day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a page ID is a canonical day ID.
|
||||||
|
*
|
||||||
|
* @param string $id
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isDayId(string $id, string $baseNs = 'chronological'): bool
|
||||||
|
{
|
||||||
|
return self::parseDayId($id, $baseNs) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a page ID is a canonical month ID.
|
||||||
|
*
|
||||||
|
* @param string $id
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isMonthId(string $id, string $baseNs = 'chronological'): bool
|
||||||
|
{
|
||||||
|
return self::parseMonthId($id, $baseNs) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a page ID is a canonical year ID.
|
||||||
|
*
|
||||||
|
* @param string $id
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isYearId(string $id, string $baseNs = 'chronological'): bool
|
||||||
|
{
|
||||||
|
return self::parseYearId($id, $baseNs) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert canonical day ID to canonical month ID.
|
||||||
|
*
|
||||||
|
* @param string $dayId
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public static function dayIdToMonthId(string $dayId, string $baseNs = 'chronological'): ?string
|
||||||
|
{
|
||||||
|
$parts = self::parseDayId($dayId, $baseNs);
|
||||||
|
if ($parts === null) return null;
|
||||||
|
|
||||||
|
$ns = self::normalizeBaseNs($baseNs);
|
||||||
|
if ($ns === null) return null;
|
||||||
|
|
||||||
|
return sprintf('%s:%04d:%02d', $ns, $parts['year'], $parts['month']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert canonical month ID to canonical year ID.
|
||||||
|
*
|
||||||
|
* @param string $monthId
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public static function monthIdToYearId(string $monthId, string $baseNs = 'chronological'): ?string
|
||||||
|
{
|
||||||
|
$parts = self::parseMonthId($monthId, $baseNs);
|
||||||
|
if ($parts === null) return null;
|
||||||
|
|
||||||
|
$ns = self::normalizeBaseNs($baseNs);
|
||||||
|
if ($ns === null) return null;
|
||||||
|
|
||||||
|
return sprintf('%s:%04d', $ns, $parts['year']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse canonical day ID.
|
||||||
|
*
|
||||||
|
* @param string $id
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return array{year:int,month:int,day:int}|null
|
||||||
|
*/
|
||||||
|
public static function parseDayId(string $id, string $baseNs = 'chronological'): ?array
|
||||||
|
{
|
||||||
|
$ns = self::normalizeBaseNs($baseNs);
|
||||||
|
if ($ns === null) return null;
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4}):(\d{2}):(\d{2})$/';
|
||||||
|
if (!preg_match($pattern, $id, $matches)) return null;
|
||||||
|
|
||||||
|
$year = (int)$matches[1];
|
||||||
|
$month = (int)$matches[2];
|
||||||
|
$day = (int)$matches[3];
|
||||||
|
if ($year < 1) return null;
|
||||||
|
if (!checkdate($month, $day, $year)) return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'day' => $day,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse canonical month ID.
|
||||||
|
*
|
||||||
|
* @param string $id
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return array{year:int,month:int}|null
|
||||||
|
*/
|
||||||
|
public static function parseMonthId(string $id, string $baseNs = 'chronological'): ?array
|
||||||
|
{
|
||||||
|
$ns = self::normalizeBaseNs($baseNs);
|
||||||
|
if ($ns === null) return null;
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4}):(\d{2})$/';
|
||||||
|
if (!preg_match($pattern, $id, $matches)) return null;
|
||||||
|
|
||||||
|
$year = (int)$matches[1];
|
||||||
|
$month = (int)$matches[2];
|
||||||
|
if ($year < 1) return null;
|
||||||
|
if ($month < 1 || $month > 12) return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse canonical year ID.
|
||||||
|
*
|
||||||
|
* @param string $id
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return array{year:int}|null
|
||||||
|
*/
|
||||||
|
public static function parseYearId(string $id, string $baseNs = 'chronological'): ?array
|
||||||
|
{
|
||||||
|
$ns = self::normalizeBaseNs($baseNs);
|
||||||
|
if ($ns === null) return null;
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4})$/';
|
||||||
|
if (!preg_match($pattern, $id, $matches)) return null;
|
||||||
|
|
||||||
|
$year = (int)$matches[1];
|
||||||
|
if ($year < 1) return null;
|
||||||
|
|
||||||
|
return ['year' => $year];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse strict ISO date YYYY-MM-DD.
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @return int[]|null [year, month, day] or null
|
||||||
|
*/
|
||||||
|
protected static function parseIsoDate(string $value): ?array
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int)$matches[1];
|
||||||
|
$month = (int)$matches[2];
|
||||||
|
$day = (int)$matches[3];
|
||||||
|
|
||||||
|
if ($year < 1) return null;
|
||||||
|
if (!checkdate($month, $day, $year)) return null;
|
||||||
|
|
||||||
|
return [$year, $month, $day];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and validate base namespace.
|
||||||
|
*
|
||||||
|
* Allows one or more namespace segments with characters [a-z0-9_-].
|
||||||
|
*
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
protected static function normalizeBaseNs(string $baseNs): ?string
|
||||||
|
{
|
||||||
|
$baseNs = strtolower(trim($baseNs));
|
||||||
|
$baseNs = trim($baseNs, ':');
|
||||||
|
if ($baseNs === '') return null;
|
||||||
|
|
||||||
|
if (!preg_match('/^[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)*$/', $baseNs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseNs;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/ChronologicalCalendarWidget.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the chronological calendar widget markup.
|
||||||
|
*/
|
||||||
|
class ChronologicalCalendarWidget
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render full calendar widget HTML for one month.
|
||||||
|
*
|
||||||
|
* @param int $year
|
||||||
|
* @param int $month
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
|
||||||
|
{
|
||||||
|
if (!self::isValidMonth($year, $month)) return '';
|
||||||
|
|
||||||
|
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
|
||||||
|
$daysInMonth = (int)date('t', $firstDayTs);
|
||||||
|
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
|
||||||
|
|
||||||
|
$monthCursor = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
|
||||||
|
if (!($monthCursor instanceof \DateTimeImmutable)) return '';
|
||||||
|
|
||||||
|
$prevMonth = $monthCursor->sub(new \DateInterval('P1M'));
|
||||||
|
$nextMonth = $monthCursor->add(new \DateInterval('P1M'));
|
||||||
|
|
||||||
|
$monthStartDate = sprintf('%04d-%02d-01', $year, $month);
|
||||||
|
$monthDayId = ChronoID::dateToDayId($monthStartDate, $baseNs);
|
||||||
|
$monthId = $monthDayId !== null ? ChronoID::dayIdToMonthId($monthDayId, $baseNs) : null;
|
||||||
|
$yearId = $monthId !== null ? ChronoID::monthIdToYearId($monthId, $baseNs) : null;
|
||||||
|
|
||||||
|
$prevStartDate = $prevMonth->format('Y-m-d');
|
||||||
|
$prevDayId = ChronoID::dateToDayId($prevStartDate, $baseNs);
|
||||||
|
$prevMonthId = $prevDayId !== null ? ChronoID::dayIdToMonthId($prevDayId, $baseNs) : null;
|
||||||
|
|
||||||
|
$nextStartDate = $nextMonth->format('Y-m-d');
|
||||||
|
$nextDayId = ChronoID::dateToDayId($nextStartDate, $baseNs);
|
||||||
|
$nextMonthId = $nextDayId !== null ? ChronoID::dayIdToMonthId($nextDayId, $baseNs) : null;
|
||||||
|
|
||||||
|
$leadingEmpty = $firstWeekday - 1;
|
||||||
|
$totalCells = (int)ceil(($leadingEmpty + $daysInMonth) / 7) * 7;
|
||||||
|
|
||||||
|
$todayY = (int)date('Y');
|
||||||
|
$todayM = (int)date('m');
|
||||||
|
$todayD = (int)date('d');
|
||||||
|
|
||||||
|
$dayUrlTemplate = function_exists('wl') ? (string)wl('__LUXTOOLS_ID_RAW__') : '';
|
||||||
|
$monthUrlTemplate = $dayUrlTemplate;
|
||||||
|
$yearUrlTemplate = $dayUrlTemplate;
|
||||||
|
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
|
||||||
|
|
||||||
|
$html = '<div class="luxtools-plugin luxtools-calendar" data-luxtools-calendar="1"'
|
||||||
|
. ' data-base-ns="' . hsc($baseNs) . '"'
|
||||||
|
. ' data-current-year="' . hsc((string)$year) . '"'
|
||||||
|
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
|
||||||
|
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
|
||||||
|
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
|
||||||
|
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
|
||||||
|
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
|
||||||
|
. ' data-prev-month-id="' . hsc((string)$prevMonthId) . '"'
|
||||||
|
. ' data-next-month-id="' . hsc((string)$nextMonthId) . '"'
|
||||||
|
. '>';
|
||||||
|
|
||||||
|
$html .= '<div class="luxtools-calendar-nav">';
|
||||||
|
$html .= '<div class="luxtools-calendar-nav-prev">';
|
||||||
|
$html .= '<button type="button" class="luxtools-calendar-nav-button" data-luxtools-dir="-1" aria-label="Previous month">◀</button>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= '<div class="luxtools-calendar-title">';
|
||||||
|
$monthLabel = date('F', $firstDayTs);
|
||||||
|
if ($monthId !== null && function_exists('wl')) {
|
||||||
|
$html .= '<a class="luxtools-calendar-month-link" href="' . hsc((string)wl($monthId)) . '">' . hsc($monthLabel) . '</a>';
|
||||||
|
} else {
|
||||||
|
$html .= hsc($monthLabel);
|
||||||
|
}
|
||||||
|
$html .= ' ';
|
||||||
|
if ($yearId !== null && function_exists('wl')) {
|
||||||
|
$html .= '<a class="luxtools-calendar-year-link" href="' . hsc((string)wl($yearId)) . '">' . hsc((string)$year) . '</a>';
|
||||||
|
} else {
|
||||||
|
$html .= hsc((string)$year);
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= '<div class="luxtools-calendar-nav-next">';
|
||||||
|
$html .= '<button type="button" class="luxtools-calendar-nav-button" data-luxtools-dir="1" aria-label="Next month">▶</button>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= '<table class="luxtools-calendar-table">';
|
||||||
|
$html .= '<thead><tr>';
|
||||||
|
foreach (['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as $weekday) {
|
||||||
|
$html .= '<th scope="col">' . hsc($weekday) . '</th>';
|
||||||
|
}
|
||||||
|
$html .= '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
for ($cell = 0; $cell < $totalCells; $cell++) {
|
||||||
|
if ($cell % 7 === 0) $html .= '<tr>';
|
||||||
|
|
||||||
|
$dayNumber = $cell - $leadingEmpty + 1;
|
||||||
|
if ($dayNumber < 1 || $dayNumber > $daysInMonth) {
|
||||||
|
$html .= '<td class="luxtools-calendar-day luxtools-calendar-day-empty"></td>';
|
||||||
|
} else {
|
||||||
|
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
|
||||||
|
$dayId = ChronoID::dateToDayId($date, $baseNs);
|
||||||
|
|
||||||
|
$classes = 'luxtools-calendar-day';
|
||||||
|
if ($year === $todayY && $month === $todayM && $dayNumber === $todayD) {
|
||||||
|
$classes .= ' luxtools-calendar-day-today';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<td class="' . hsc($classes) . '">';
|
||||||
|
if ($dayId !== null && function_exists('html_wikilink')) {
|
||||||
|
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
|
||||||
|
} else {
|
||||||
|
$html .= hsc((string)$dayNumber);
|
||||||
|
}
|
||||||
|
$html .= '</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cell % 7 === 6) $html .= '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</tbody></table></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $year
|
||||||
|
* @param int $month
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isValidMonth(int $year, int $month): bool
|
||||||
|
{
|
||||||
|
if ($year < 1) return false;
|
||||||
|
if ($month < 1 || $month > 12) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/ChronologicalDateAutoLinker.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-links strict ISO dates in rendered XHTML fragments.
|
||||||
|
*/
|
||||||
|
class ChronologicalDateAutoLinker
|
||||||
|
{
|
||||||
|
/** @var string[] Tags where auto-linking must be disabled */
|
||||||
|
protected static $blockedTags = [
|
||||||
|
'a',
|
||||||
|
'code',
|
||||||
|
'pre',
|
||||||
|
'script',
|
||||||
|
'style',
|
||||||
|
'textarea',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link valid ISO dates in HTML text nodes while skipping blocked tags.
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function linkHtml(string $html): string
|
||||||
|
{
|
||||||
|
$parts = preg_split('/(<[^>]+>)/u', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||||
|
if (!is_array($parts)) return $html;
|
||||||
|
|
||||||
|
$blocked = [];
|
||||||
|
foreach (self::$blockedTags as $tag) {
|
||||||
|
$blocked[$tag] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = '';
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if ($part === '') {
|
||||||
|
$out .= $part;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($part, '<')) {
|
||||||
|
self::updateBlockedTagCounters($part, $blocked);
|
||||||
|
$out .= $part;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isBlockedContext($blocked)) {
|
||||||
|
$out .= $part;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out .= self::linkText($part);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link strict ISO dates in plain text.
|
||||||
|
*
|
||||||
|
* @param string $text
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function linkText(string $text): string
|
||||||
|
{
|
||||||
|
$replaced = preg_replace_callback(
|
||||||
|
'/(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/',
|
||||||
|
static function (array $matches): string {
|
||||||
|
$date = (string)($matches[1] ?? '');
|
||||||
|
if ($date === '') return $matches[0];
|
||||||
|
|
||||||
|
$id = ChronoID::dateToDayId($date);
|
||||||
|
if ($id === null) return $matches[0];
|
||||||
|
|
||||||
|
if (function_exists('html_wikilink')) {
|
||||||
|
return (string)html_wikilink($id, $date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('wl')) {
|
||||||
|
return '<a href="' . hsc((string)wl($id)) . '">' . hsc($date) . '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matches[0];
|
||||||
|
},
|
||||||
|
$text
|
||||||
|
);
|
||||||
|
|
||||||
|
return is_string($replaced) ? $replaced : $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update blocked-tag counters while traversing HTML tokens.
|
||||||
|
*
|
||||||
|
* @param string $token
|
||||||
|
* @param array<string,int> $blocked
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected static function updateBlockedTagCounters(string $token, array &$blocked): void
|
||||||
|
{
|
||||||
|
if (!preg_match('/^<\s*(\/?)\s*([a-zA-Z0-9:_-]+)/', $token, $matches)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isClosing = $matches[1] === '/';
|
||||||
|
$tag = strtolower((string)$matches[2]);
|
||||||
|
if (!array_key_exists($tag, $blocked)) return;
|
||||||
|
|
||||||
|
if ($isClosing) {
|
||||||
|
if ($blocked[$tag] > 0) $blocked[$tag]--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selfClosing = preg_match('/\/\s*>$/', $token) === 1;
|
||||||
|
if (!$selfClosing) {
|
||||||
|
$blocked[$tag]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if traversal is currently inside a blocked context.
|
||||||
|
*
|
||||||
|
* @param array<string,int> $blocked
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected static function isBlockedContext(array $blocked): bool
|
||||||
|
{
|
||||||
|
foreach ($blocked as $count) {
|
||||||
|
if ($count > 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/ChronologicalDayTemplate.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds page template content for chronological day pages.
|
||||||
|
*/
|
||||||
|
class ChronologicalDayTemplate
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build a German date heading template for a canonical day ID.
|
||||||
|
*
|
||||||
|
* Example output:
|
||||||
|
* ====== Freitag, 13. Februar 2026 ======
|
||||||
|
*
|
||||||
|
* @param string $dayId
|
||||||
|
* @param string $baseNs
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public static function buildForDayId(string $dayId, string $baseNs = 'chronological'): ?string
|
||||||
|
{
|
||||||
|
$parts = ChronoID::parseDayId($dayId, $baseNs);
|
||||||
|
if ($parts === null) return null;
|
||||||
|
|
||||||
|
$formatted = self::formatGermanDate($parts['year'], $parts['month'], $parts['day']);
|
||||||
|
return '====== ' . $formatted . " ======\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date with German day/month names and style:
|
||||||
|
* Freitag, 13. Februar 2026
|
||||||
|
*
|
||||||
|
* @param int $year
|
||||||
|
* @param int $month
|
||||||
|
* @param int $day
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function formatGermanDate(int $year, int $month, int $day): string
|
||||||
|
{
|
||||||
|
$weekdays = [
|
||||||
|
1 => 'Montag',
|
||||||
|
2 => 'Dienstag',
|
||||||
|
3 => 'Mittwoch',
|
||||||
|
4 => 'Donnerstag',
|
||||||
|
5 => 'Freitag',
|
||||||
|
6 => 'Samstag',
|
||||||
|
7 => 'Sonntag',
|
||||||
|
];
|
||||||
|
|
||||||
|
$months = [
|
||||||
|
1 => 'Januar',
|
||||||
|
2 => 'Februar',
|
||||||
|
3 => 'März',
|
||||||
|
4 => 'April',
|
||||||
|
5 => 'Mai',
|
||||||
|
6 => 'Juni',
|
||||||
|
7 => 'Juli',
|
||||||
|
8 => 'August',
|
||||||
|
9 => 'September',
|
||||||
|
10 => 'Oktober',
|
||||||
|
11 => 'November',
|
||||||
|
12 => 'Dezember',
|
||||||
|
];
|
||||||
|
|
||||||
|
$weekdayIndex = (int)date('N', mktime(0, 0, 0, $month, $day, $year));
|
||||||
|
$weekday = $weekdays[$weekdayIndex] ?? '';
|
||||||
|
$monthName = $months[$month] ?? '';
|
||||||
|
|
||||||
|
return sprintf('%s, %d. %s %d', $weekday, $day, $monthName, $year);
|
||||||
|
}
|
||||||
|
}
|
||||||
283
src/ChronologicalIcsEvents.php
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
use DateInterval;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Sabre\VObject\Component\VCalendar;
|
||||||
|
use Sabre\VObject\Component\VEvent;
|
||||||
|
use Sabre\VObject\Reader;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read local ICS files using sabre/vobject and expose events for one day.
|
||||||
|
*/
|
||||||
|
class ChronologicalIcsEvents
|
||||||
|
{
|
||||||
|
/** @var array<string,array<int,array{summary:string,time:string,startIso:string,allDay:bool}>> In-request cache */
|
||||||
|
protected static $runtimeCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return events for one day (YYYY-MM-DD) from configured local ICS files.
|
||||||
|
*
|
||||||
|
* @param string $icsConfig Multiline list of local ICS file paths
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return array<int,array{summary:string,time:string,startIso:string,allDay:bool}>
|
||||||
|
*/
|
||||||
|
public static function eventsForDate(string $icsConfig, string $dateIso): array
|
||||||
|
{
|
||||||
|
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||||
|
|
||||||
|
$files = self::parseConfiguredFiles($icsConfig);
|
||||||
|
if ($files === []) return [];
|
||||||
|
|
||||||
|
$signature = self::buildSignature($files);
|
||||||
|
if ($signature === '') return [];
|
||||||
|
|
||||||
|
$cacheKey = $signature . '|' . $dateIso;
|
||||||
|
|
||||||
|
if (isset(self::$runtimeCache[$cacheKey])) {
|
||||||
|
return self::$runtimeCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$utc = new DateTimeZone('UTC');
|
||||||
|
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
|
||||||
|
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
|
||||||
|
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
foreach (self::readEventsFromFile($file, $dateIso, $rangeStart, $rangeEnd) as $entry) {
|
||||||
|
$dedupeKey = implode('|', [
|
||||||
|
(string)($entry['summary'] ?? ''),
|
||||||
|
(string)($entry['time'] ?? ''),
|
||||||
|
((bool)($entry['allDay'] ?? false)) ? '1' : '0',
|
||||||
|
]);
|
||||||
|
if (isset($seen[$dedupeKey])) continue;
|
||||||
|
$seen[$dedupeKey] = true;
|
||||||
|
$events[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($events, static function (array $a, array $b): int {
|
||||||
|
$aAllDay = (bool)($a['allDay'] ?? false);
|
||||||
|
$bAllDay = (bool)($b['allDay'] ?? false);
|
||||||
|
if ($aAllDay !== $bAllDay) {
|
||||||
|
return $aAllDay ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeCmp = strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? ''));
|
||||||
|
if ($timeCmp !== 0) return $timeCmp;
|
||||||
|
|
||||||
|
return strcmp((string)($a['summary'] ?? ''), (string)($b['summary'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
self::$runtimeCache[$cacheKey] = $events;
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $icsConfig
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
protected static function parseConfiguredFiles(string $icsConfig): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $icsConfig) ?: [];
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim((string)$line);
|
||||||
|
if ($line === '') continue;
|
||||||
|
if (str_starts_with($line, '#')) continue;
|
||||||
|
|
||||||
|
$path = Path::cleanPath($line, false);
|
||||||
|
if (!is_file($path) || !is_readable($path)) continue;
|
||||||
|
$files[] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_values(array_unique($files));
|
||||||
|
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build signature from file path + mtime + size.
|
||||||
|
*
|
||||||
|
* @param string[] $files
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function buildSignature(array $files): string
|
||||||
|
{
|
||||||
|
if ($files === []) return '';
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$mtime = @filemtime($file) ?: 0;
|
||||||
|
$size = @filesize($file) ?: 0;
|
||||||
|
$parts[] = $file . '|' . $mtime . '|' . $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sha1(implode("\n", $parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one ICS file and return normalized events for the target day.
|
||||||
|
*
|
||||||
|
* @param string $file
|
||||||
|
* @param string $dateIso
|
||||||
|
* @param DateTimeImmutable $rangeStart
|
||||||
|
* @param DateTimeImmutable $rangeEnd
|
||||||
|
* @return array<int,array{summary:string,time:string,startIso:string,allDay:bool}>
|
||||||
|
*/
|
||||||
|
protected static function readEventsFromFile(
|
||||||
|
string $file,
|
||||||
|
string $dateIso,
|
||||||
|
DateTimeImmutable $rangeStart,
|
||||||
|
DateTimeImmutable $rangeEnd
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
$raw = @file_get_contents($file);
|
||||||
|
if (!is_string($raw) || trim($raw) === '') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($component instanceof VCalendar)) return [];
|
||||||
|
|
||||||
|
$expanded = $component->expand($rangeStart, $rangeEnd);
|
||||||
|
if (!($expanded instanceof VCalendar)) return [];
|
||||||
|
|
||||||
|
return self::collectEventsFromCalendar($expanded, $dateIso);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return array<int,array{summary:string,time:string,startIso:string,allDay:bool}>
|
||||||
|
*/
|
||||||
|
protected static function collectEventsFromCalendar(
|
||||||
|
VCalendar $calendar,
|
||||||
|
string $dateIso
|
||||||
|
): array {
|
||||||
|
$result = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($calendar->select('VEVENT') as $vevent) {
|
||||||
|
if (!($vevent instanceof VEvent)) continue;
|
||||||
|
|
||||||
|
$normalized = self::normalizeEventForDay($vevent, $dateIso);
|
||||||
|
if ($normalized === null) continue;
|
||||||
|
|
||||||
|
$dedupeKey = implode('|', [
|
||||||
|
(string)($normalized['uid'] ?? ''),
|
||||||
|
(string)($normalized['rid'] ?? ''),
|
||||||
|
(string)($normalized['start'] ?? ''),
|
||||||
|
(string)($normalized['summary'] ?? ''),
|
||||||
|
(string)($normalized['time'] ?? ''),
|
||||||
|
((bool)($normalized['allDay'] ?? false)) ? '1' : '0',
|
||||||
|
]);
|
||||||
|
if (isset($seen[$dedupeKey])) continue;
|
||||||
|
$seen[$dedupeKey] = true;
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'summary' => (string)$normalized['summary'],
|
||||||
|
'time' => (string)$normalized['time'],
|
||||||
|
'startIso' => (string)$normalized['start'],
|
||||||
|
'allDay' => (bool)$normalized['allDay'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert VEVENT to output item when it intersects the target day.
|
||||||
|
*
|
||||||
|
* @param VEvent $vevent
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
protected static function normalizeEventForDay(
|
||||||
|
VEvent $vevent,
|
||||||
|
string $dateIso
|
||||||
|
): ?array
|
||||||
|
{
|
||||||
|
if (!isset($vevent->DTSTART)) return null;
|
||||||
|
if (!ChronoID::isIsoDate($dateIso)) return null;
|
||||||
|
|
||||||
|
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||||
|
|
||||||
|
$start = self::toImmutableDateTime($vevent->DTSTART->getDateTime());
|
||||||
|
if ($start === null) return null;
|
||||||
|
|
||||||
|
$end = null;
|
||||||
|
if (isset($vevent->DTEND)) {
|
||||||
|
$end = self::toImmutableDateTime($vevent->DTEND->getDateTime());
|
||||||
|
} elseif (isset($vevent->DURATION)) {
|
||||||
|
try {
|
||||||
|
$duration = $vevent->DURATION->getDateInterval();
|
||||||
|
if ($duration instanceof DateInterval) {
|
||||||
|
$end = $start->add($duration);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$end = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($end === null) {
|
||||||
|
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($end <= $start) {
|
||||||
|
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventTimezone = $start->getTimezone();
|
||||||
|
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
|
||||||
|
$dayEnd = $dayStart->add(new DateInterval('P1D'));
|
||||||
|
|
||||||
|
$intersects = ($start < $dayEnd) && ($end > $dayStart);
|
||||||
|
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
|
||||||
|
$intersects = true;
|
||||||
|
}
|
||||||
|
if (!$intersects) return null;
|
||||||
|
|
||||||
|
$summary = trim((string)($vevent->SUMMARY ?? ''));
|
||||||
|
if ($summary === '') $summary = '(ohne Titel)';
|
||||||
|
|
||||||
|
$uid = trim((string)($vevent->UID ?? ''));
|
||||||
|
$rid = '';
|
||||||
|
if (isset($vevent->{'RECURRENCE-ID'})) {
|
||||||
|
$rid = trim((string)$vevent->{'RECURRENCE-ID'});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'rid' => $rid,
|
||||||
|
'start' => $start->format(DateTimeInterface::ATOM),
|
||||||
|
'summary' => $summary,
|
||||||
|
'time' => $isAllDay ? '' : $start->format('H:i'),
|
||||||
|
'allDay' => $isAllDay,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param DateTimeInterface $dateTime
|
||||||
|
* @return DateTimeImmutable|null
|
||||||
|
*/
|
||||||
|
protected static function toImmutableDateTime(DateTimeInterface $dateTime): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
if ($dateTime instanceof DateTimeImmutable) return $dateTime;
|
||||||
|
|
||||||
|
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dateTime->getTimestamp());
|
||||||
|
if (!($immutable instanceof DateTimeImmutable)) return null;
|
||||||
|
|
||||||
|
return $immutable->setTimezone($dateTime->getTimezone());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace dokuwiki\plugin\filelist;
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
class Crawler
|
class Crawler
|
||||||
{
|
{
|
||||||
@@ -13,6 +13,9 @@ class Crawler
|
|||||||
/** @var bool */
|
/** @var bool */
|
||||||
protected $sortreverse = false;
|
protected $sortreverse = false;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $foldersFirst = false;
|
||||||
|
|
||||||
/** @var string[] patterns to ignore */
|
/** @var string[] patterns to ignore */
|
||||||
protected $ignore = [];
|
protected $ignore = [];
|
||||||
|
|
||||||
@@ -41,6 +44,11 @@ class Crawler
|
|||||||
$this->sortreverse = $sortreverse;
|
$this->sortreverse = $sortreverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setFoldersFirst($foldersFirst)
|
||||||
|
{
|
||||||
|
$this->foldersFirst = (bool)$foldersFirst;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does a (recursive) crawl for finding files based on a given pattern.
|
* Does a (recursive) crawl for finding files based on a given pattern.
|
||||||
* Based on a safe glob reimplementation using fnmatch and opendir.
|
* Based on a safe glob reimplementation using fnmatch and opendir.
|
||||||
@@ -58,7 +66,7 @@ class Crawler
|
|||||||
$path = $root . $local;
|
$path = $root . $local;
|
||||||
|
|
||||||
// do not descent into wiki or data directories
|
// do not descent into wiki or data directories
|
||||||
if(Path::isWikiControlled($path)) return [];
|
if (Path::isWikiControlled($path)) return [];
|
||||||
|
|
||||||
if (($dir = opendir($path)) === false) return [];
|
if (($dir = opendir($path)) === false) return [];
|
||||||
$result = [];
|
$result = [];
|
||||||
@@ -122,6 +130,104 @@ class Crawler
|
|||||||
return $this->sortItems($result);
|
return $this->sortItems($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the direct children (files and directories) of a given local path.
|
||||||
|
*
|
||||||
|
* Unlike crawl(), this includes directories even when not recursing.
|
||||||
|
*
|
||||||
|
* @param string $root
|
||||||
|
* @param string $local
|
||||||
|
* @param string $titlefile
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function listDirectory($root, $local, $titlefile)
|
||||||
|
{
|
||||||
|
$path = $root . $local;
|
||||||
|
$path = rtrim($path, '/');
|
||||||
|
|
||||||
|
// do not list wiki or data directories
|
||||||
|
if (Path::isWikiControlled($path)) return [];
|
||||||
|
|
||||||
|
if (($dir = opendir($path)) === false) return [];
|
||||||
|
$result = [];
|
||||||
|
while (($file = readdir($dir)) !== false) {
|
||||||
|
if ($file[0] == '.' || $file == $titlefile) {
|
||||||
|
// ignore hidden, system and title files
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filepath = $path . '/' . $file;
|
||||||
|
if (!is_readable($filepath)) continue;
|
||||||
|
|
||||||
|
$isDir = is_dir($filepath);
|
||||||
|
if (!$isDir && !$this->isExtensionAllowed($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->isFileIgnored($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get title file (directories only)
|
||||||
|
$filename = $file;
|
||||||
|
if ($isDir) {
|
||||||
|
$title = $filepath . '/' . $titlefile;
|
||||||
|
if (is_readable($title)) {
|
||||||
|
$filename = io_readFile($title, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build a local path consistent with crawl() (leading slash for root)
|
||||||
|
$self = rtrim($local, '/') . '/' . $file;
|
||||||
|
if ($self === '/' . $file) {
|
||||||
|
// keep the original behaviour when local is empty
|
||||||
|
$self = '/' . $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'name' => $filename,
|
||||||
|
'local' => $self,
|
||||||
|
'path' => $filepath,
|
||||||
|
'mtime' => filemtime($filepath),
|
||||||
|
'ctime' => filectime($filepath),
|
||||||
|
'size' => $isDir ? 0 : filesize($filepath),
|
||||||
|
'children' => false,
|
||||||
|
'treesize' => 1,
|
||||||
|
'isdir' => $isDir,
|
||||||
|
'childcount' => $isDir ? $this->countDirectChildren($filepath) : 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$result[] = $entry;
|
||||||
|
}
|
||||||
|
closedir($dir);
|
||||||
|
return $this->sortItems($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the direct children (files and subdirectories) of a directory.
|
||||||
|
*
|
||||||
|
* Hidden files (starting with '.') are excluded.
|
||||||
|
*
|
||||||
|
* @param string $dirPath
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
protected function countDirectChildren(string $dirPath): int
|
||||||
|
{
|
||||||
|
if (($dir = @opendir($dirPath)) === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
while (($file = readdir($dir)) !== false) {
|
||||||
|
// Skip hidden files and special entries
|
||||||
|
if ($file[0] === '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
closedir($dir);
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort the given items by the current sortby and sortreverse settings
|
* Sort the given items by the current sortby and sortreverse settings
|
||||||
*
|
*
|
||||||
@@ -133,13 +239,41 @@ class Crawler
|
|||||||
$callback = [$this, 'compare' . ucfirst($this->sortby)];
|
$callback = [$this, 'compare' . ucfirst($this->sortby)];
|
||||||
if (!is_callable($callback)) return $items;
|
if (!is_callable($callback)) return $items;
|
||||||
|
|
||||||
|
// Optional grouping: keep directories before files.
|
||||||
|
// Implement reverse ordering by inverting comparisons instead of array_reverse(),
|
||||||
|
// so the directory-first grouping stays intact.
|
||||||
|
if ($this->foldersFirst) {
|
||||||
|
usort($items, function ($a, $b) use ($callback) {
|
||||||
|
$aIsDir = $this->isDirectoryItem($a);
|
||||||
|
$bIsDir = $this->isDirectoryItem($b);
|
||||||
|
if ($aIsDir !== $bIsDir) {
|
||||||
|
return $aIsDir ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmp = call_user_func($callback, $a, $b);
|
||||||
|
if ($this->sortreverse) $cmp = -$cmp;
|
||||||
|
return $cmp;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
usort($items, $callback);
|
usort($items, $callback);
|
||||||
if ($this->sortreverse) {
|
if ($this->sortreverse) {
|
||||||
$items = array_reverse($items);
|
$items = array_reverse($items);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether an item represents a directory.
|
||||||
|
* Supports both crawl() results (children tree) and listDirectory() results (isdir).
|
||||||
|
*/
|
||||||
|
protected function isDirectoryItem($item)
|
||||||
|
{
|
||||||
|
if (!is_array($item)) return false;
|
||||||
|
if (!empty($item['isdir'])) return true;
|
||||||
|
return array_key_exists('children', $item) && $item['children'] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file is allowed by the configured extensions
|
* Check if a file is allowed by the configured extensions
|
||||||
*
|
*
|
||||||
@@ -173,7 +307,7 @@ class Crawler
|
|||||||
*/
|
*/
|
||||||
protected function loadIgnores()
|
protected function loadIgnores()
|
||||||
{
|
{
|
||||||
$file = __DIR__ . '/conf/ignore.txt';
|
$file = __DIR__ . '/../conf/ignore.txt';
|
||||||
$ignore = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$ignore = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$ignore = array_map(static fn($line) => trim(preg_replace('/\s*#.*$/', '', $line)), $ignore);
|
$ignore = array_map(static fn($line) => trim(preg_replace('/\s*#.*$/', '', $line)), $ignore);
|
||||||
$ignore = array_filter($ignore);
|
$ignore = array_filter($ignore);
|
||||||
556
src/Output.php
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
class Output
|
||||||
|
{
|
||||||
|
/** @var \Doku_Renderer */
|
||||||
|
protected $renderer;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $basedir;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $webdir;
|
||||||
|
|
||||||
|
/** @var array */
|
||||||
|
protected $files;
|
||||||
|
|
||||||
|
/** @var \DokuWiki_Plugin|null */
|
||||||
|
protected $plugin;
|
||||||
|
|
||||||
|
/** @var Path|false|null */
|
||||||
|
protected $openPathMapper = null;
|
||||||
|
|
||||||
|
|
||||||
|
public function __construct(\Doku_Renderer $renderer, $basedir, $webdir, $files, $plugin = null)
|
||||||
|
{
|
||||||
|
$this->renderer = $renderer;
|
||||||
|
$this->basedir = $basedir;
|
||||||
|
$this->webdir = $webdir;
|
||||||
|
$this->files = $files;
|
||||||
|
$this->plugin = $plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a thumbnail gallery (XHTML only).
|
||||||
|
*
|
||||||
|
* Expects a flat list of file items in $this->files.
|
||||||
|
* Clicking a thumbnail opens the original image.
|
||||||
|
*
|
||||||
|
* @param array $params
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function renderAsGallery($params)
|
||||||
|
{
|
||||||
|
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$thumbW = 150;
|
||||||
|
$thumbH = 150;
|
||||||
|
$thumbQ = 80;
|
||||||
|
|
||||||
|
// Allow generating larger thumbnails (e.g. 2x) while still displaying them
|
||||||
|
// at 150x150 for sharper results on HiDPI screens.
|
||||||
|
$thumbScale = 1.0;
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
|
if ($syntax) {
|
||||||
|
$rawScale = (string)$syntax->getConf('gallery_thumb_scale');
|
||||||
|
if ($rawScale !== '') {
|
||||||
|
$thumbScale = (float)$rawScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_finite($thumbScale) || $thumbScale < 1.0) $thumbScale = 1.0;
|
||||||
|
if ($thumbScale > 4.0) $thumbScale = 4.0;
|
||||||
|
|
||||||
|
$genThumbW = (int)max(1, (int)round($thumbW * $thumbScale));
|
||||||
|
$genThumbH = (int)max(1, (int)round($thumbH * $thumbScale));
|
||||||
|
|
||||||
|
// Build placeholder URL from config (DokuWiki media ID)
|
||||||
|
$placeholderStyle = '';
|
||||||
|
$placeholderId = $syntax ? trim((string)$syntax->getConf('thumb_placeholder')) : '';
|
||||||
|
if ($placeholderId !== '' && function_exists('ml')) {
|
||||||
|
$placeholderUrl = ml($placeholderId, ['w' => $thumbW, 'h' => $thumbH], true, '&');
|
||||||
|
$placeholderStyle = ' style="--luxtools-placeholder: url(' . hsc($placeholderUrl) . ')"';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \Doku_Renderer_xhtml $renderer */
|
||||||
|
$renderer = $this->renderer;
|
||||||
|
$renderer->doc .= '<div class="luxtools-plugin luxtools-gallery" data-luxtools-gallery="1"' . $placeholderStyle . '>';
|
||||||
|
|
||||||
|
foreach ($this->files as $item) {
|
||||||
|
$url = $this->itemWebUrl($item, !empty($params['randlinks']));
|
||||||
|
$safeUrl = hsc($url);
|
||||||
|
$label = hsc($item['name']);
|
||||||
|
$caption = hsc(basename((string)($item['name'] ?? '')));
|
||||||
|
if ($caption === '') {
|
||||||
|
$caption = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build thumbnail URL - JavaScript will load via fetch() for cancellability
|
||||||
|
$thumbUrl = $this->withQueryParams($url, [
|
||||||
|
'thumb' => 1,
|
||||||
|
'w' => $genThumbW,
|
||||||
|
'h' => $genThumbH,
|
||||||
|
'q' => $thumbQ,
|
||||||
|
]);
|
||||||
|
$thumbSrc = hsc($thumbUrl);
|
||||||
|
|
||||||
|
$renderer->doc .= '<a'
|
||||||
|
. ' href="' . $safeUrl . '"'
|
||||||
|
. ' class="media luxtools-gallery-item"'
|
||||||
|
. ' title="' . $label . '"'
|
||||||
|
. ' aria-label="' . $label . '"'
|
||||||
|
. ' data-luxtools-full="' . $safeUrl . '"'
|
||||||
|
. ' data-luxtools-name="' . $caption . '"'
|
||||||
|
. '>';
|
||||||
|
$renderer->doc .= '<img'
|
||||||
|
. ' class="luxtools-thumb"'
|
||||||
|
. ' data-src="' . $thumbSrc . '"'
|
||||||
|
. ' alt=""'
|
||||||
|
. ' width="' . $thumbW . '"'
|
||||||
|
. ' height="' . $thumbH . '"'
|
||||||
|
. ' />';
|
||||||
|
$renderer->doc .= '<span class="luxtools-gallery-caption">' . $caption . '</span>';
|
||||||
|
$renderer->doc .= '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append query parameters to a URL.
|
||||||
|
*
|
||||||
|
* Preserves existing query and fragment and uses RFC3986 encoding.
|
||||||
|
*
|
||||||
|
* @param string $url
|
||||||
|
* @param array $params
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function withQueryParams(string $url, array $params): string
|
||||||
|
{
|
||||||
|
if ($params === []) return $url;
|
||||||
|
|
||||||
|
$fragment = '';
|
||||||
|
$hashPos = strpos($url, '#');
|
||||||
|
if ($hashPos !== false) {
|
||||||
|
$fragment = substr($url, $hashPos);
|
||||||
|
$url = substr($url, 0, $hashPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
$glue = (strpos($url, '?') === false) ? '?' : '&';
|
||||||
|
return $url . $glue . http_build_query($params, '', '&', PHP_QUERY_RFC3986) . $fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the files as a table, including details if configured that way.
|
||||||
|
*
|
||||||
|
* @param array $params the parameters of the filelist call
|
||||||
|
*/
|
||||||
|
public function renderAsTable($params)
|
||||||
|
{
|
||||||
|
$this->openContainer($params);
|
||||||
|
|
||||||
|
$items = $this->flattenResultTree($this->files);
|
||||||
|
$this->renderTableItems($items, $params);
|
||||||
|
|
||||||
|
$this->closeContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a flat list (files and/or directories) as a table.
|
||||||
|
*
|
||||||
|
* @param array $params
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function renderAsFlatTable($params)
|
||||||
|
{
|
||||||
|
$this->openContainer($params);
|
||||||
|
|
||||||
|
$this->renderTableItems($this->files, $params);
|
||||||
|
|
||||||
|
$this->closeContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the wrapping container with an optional max-height and scroll behaviour.
|
||||||
|
*/
|
||||||
|
protected function openContainer($params): void
|
||||||
|
{
|
||||||
|
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$style = $this->containerStyle($params);
|
||||||
|
$this->renderer->doc .= '<div class="luxtools-plugin"' . $style . '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the wrapping container if XHTML renderer is in use.
|
||||||
|
*/
|
||||||
|
protected function closeContainer(): void
|
||||||
|
{
|
||||||
|
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->renderer->doc .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inline style attribute for the container based on the maxheight param.
|
||||||
|
*/
|
||||||
|
protected function containerStyle($params): string
|
||||||
|
{
|
||||||
|
if (!isset($params['maxheight'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxHeight = (int)$params['maxheight'];
|
||||||
|
if ($maxHeight < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ' style="max-height: ' . $maxHeight . 'px; overflow-y: auto;"';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the files as a table, including details if configured that way.
|
||||||
|
*
|
||||||
|
* @param array $params the parameters of the filelist call
|
||||||
|
*/
|
||||||
|
protected function renderTableItems($items, $params)
|
||||||
|
{
|
||||||
|
|
||||||
|
$renderer = $this->renderer;
|
||||||
|
|
||||||
|
|
||||||
|
// count the columns
|
||||||
|
$columns = 1;
|
||||||
|
if ($params['showsize']) {
|
||||||
|
$columns++;
|
||||||
|
}
|
||||||
|
if ($params['showdate']) {
|
||||||
|
$columns++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->table_open($columns);
|
||||||
|
|
||||||
|
$hasOpenLocation = isset($params['openlocation']) && is_string($params['openlocation']) && trim($params['openlocation']) !== '';
|
||||||
|
$hasHeader = !empty($params['tableheader']);
|
||||||
|
if ($hasOpenLocation || $hasHeader) {
|
||||||
|
$renderer->tablethead_open();
|
||||||
|
|
||||||
|
// Small row above the header with an "Open Location" link.
|
||||||
|
if ($hasOpenLocation && ($renderer instanceof \Doku_Renderer_xhtml)) {
|
||||||
|
$openItem = [
|
||||||
|
'name' => $this->getLang('openlocation'),
|
||||||
|
'path' => $params['openlocation'],
|
||||||
|
'isdir' => true,
|
||||||
|
// Render without an icon (no mf_* class).
|
||||||
|
'noicon' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var \Doku_Renderer_xhtml $renderer */
|
||||||
|
$renderer->doc .= '<tr class="luxtools-openlocation-row"><td colspan="' . (int)$columns . '">';
|
||||||
|
$this->renderDirectoryLink($openItem);
|
||||||
|
$renderer->doc .= '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasHeader) {
|
||||||
|
$renderer->tablerow_open();
|
||||||
|
|
||||||
|
$renderer->tableheader_open();
|
||||||
|
$renderer->cdata($this->getLang('filename'));
|
||||||
|
$renderer->tableheader_close();
|
||||||
|
|
||||||
|
if ($params['showsize']) {
|
||||||
|
$renderer->tableheader_open();
|
||||||
|
$renderer->cdata($this->getLang('filesize'));
|
||||||
|
$renderer->tableheader_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($params['showdate']) {
|
||||||
|
$renderer->tableheader_open();
|
||||||
|
$renderer->cdata($this->getLang('lastmodified'));
|
||||||
|
$renderer->tableheader_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->tablerow_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->tablethead_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->tabletbody_open();
|
||||||
|
|
||||||
|
if ($items === []) {
|
||||||
|
// Render a single row with an empty state message.
|
||||||
|
$renderer->tablerow_open();
|
||||||
|
$renderer->tablecell_open($columns);
|
||||||
|
if ($renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<span class="luxtools-empty">' . hsc($this->getLang('empty_files')) . '</span>';
|
||||||
|
} else {
|
||||||
|
$renderer->cdata($this->getLang('empty_files'));
|
||||||
|
}
|
||||||
|
$renderer->tablecell_close();
|
||||||
|
$renderer->tablerow_close();
|
||||||
|
} else {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$renderer->tablerow_open();
|
||||||
|
$renderer->tablecell_open();
|
||||||
|
$this->renderItemLink($item, $params['randlinks']);
|
||||||
|
$renderer->tablecell_close();
|
||||||
|
|
||||||
|
if ($params['showsize']) {
|
||||||
|
$renderer->tablecell_open(1, 'right');
|
||||||
|
if (!empty($item['isdir'])) {
|
||||||
|
// Show item count for directories
|
||||||
|
$childCount = $item['childcount'] ?? 0;
|
||||||
|
$renderer->cdata($childCount . ' ' . ($childCount === 1 ? 'item' : 'items'));
|
||||||
|
} else {
|
||||||
|
$renderer->cdata(filesize_h($item['size']));
|
||||||
|
}
|
||||||
|
$renderer->tablecell_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($params['showdate']) {
|
||||||
|
$renderer->tablecell_open();
|
||||||
|
$renderer->cdata(dformat($item['mtime']));
|
||||||
|
$renderer->tablecell_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->tablerow_close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$renderer->tabletbody_close();
|
||||||
|
$renderer->table_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected function renderItemLink($item, $cachebuster = false)
|
||||||
|
{
|
||||||
|
if (!empty($item['isdir'])) {
|
||||||
|
$this->renderDirectoryLink($item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$this->renderItemLinkXHTML($item, $cachebuster);
|
||||||
|
} else {
|
||||||
|
$this->renderItemLinkAny($item, $cachebuster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a directory like a normal media link, but with open behaviour.
|
||||||
|
*
|
||||||
|
* @param array $item
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function renderDirectoryLink($item)
|
||||||
|
{
|
||||||
|
$caption = $item['name'] ?? '';
|
||||||
|
$path = $item['path'] ?? '';
|
||||||
|
|
||||||
|
if ($caption === '') {
|
||||||
|
$caption = '[n/a]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
|
||||||
|
$this->renderer->cdata($caption);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($path) || $path === '') {
|
||||||
|
$this->renderer->cdata('[n/a]');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->mapOpenPath($path);
|
||||||
|
|
||||||
|
global $conf;
|
||||||
|
/** @var \Doku_Renderer_xhtml $renderer */
|
||||||
|
$renderer = $this->renderer;
|
||||||
|
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
|
$serviceUrl = $syntax ? trim((string)$syntax->getConf('open_service_url')) : '';
|
||||||
|
|
||||||
|
// Prepare a DokuWiki-style link.
|
||||||
|
// Use the same icon mechanism as normal media links (via $link['class']).
|
||||||
|
$link = [
|
||||||
|
'target' => $conf['target']['extern'],
|
||||||
|
'style' => '',
|
||||||
|
'pre' => '',
|
||||||
|
'suf' => '',
|
||||||
|
'name' => $caption,
|
||||||
|
'url' => '#',
|
||||||
|
'title' => $renderer->_xmlEntities($path),
|
||||||
|
'more' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$noIcon = !empty($item['noicon']);
|
||||||
|
$link['class'] = $noIcon
|
||||||
|
? 'luxtools-open'
|
||||||
|
: 'luxtools-open media mediafile mf_folder';
|
||||||
|
|
||||||
|
$link['more'] .= ' data-path="' . hsc($path) . '"';
|
||||||
|
if ($conf['relnofollow']) $link['more'] .= ' rel="nofollow"';
|
||||||
|
if ($serviceUrl !== '') $link['more'] .= ' data-service-url="' . hsc($serviceUrl) . '"';
|
||||||
|
$renderer->doc .= $renderer->_formatLink($link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a filesystem path to an alias path (if configured).
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function mapOpenPath($path)
|
||||||
|
{
|
||||||
|
if ($this->openPathMapper === false) return $path;
|
||||||
|
|
||||||
|
if ($this->openPathMapper === null) {
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools');
|
||||||
|
$pathConfig = $syntax ? (string)$syntax->getConf('paths') : '';
|
||||||
|
|
||||||
|
if (trim($pathConfig) === '') {
|
||||||
|
$this->openPathMapper = false;
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->openPathMapper = new Path($pathConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->openPathMapper->mapToAliasPath($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a file link on the XHTML renderer
|
||||||
|
*/
|
||||||
|
protected function renderItemLinkXHTML($item, $cachebuster = false)
|
||||||
|
{
|
||||||
|
global $conf;
|
||||||
|
/** @var \Doku_Renderer_xhtml $renderer */
|
||||||
|
$renderer = $this->renderer;
|
||||||
|
|
||||||
|
//prepare for formating
|
||||||
|
$link['target'] = $conf['target']['extern'];
|
||||||
|
$link['style'] = '';
|
||||||
|
$link['pre'] = '';
|
||||||
|
$link['suf'] = '';
|
||||||
|
$link['more'] = '';
|
||||||
|
$link['url'] = $this->itemWebUrl($item, $cachebuster);
|
||||||
|
$link['name'] = $item['name'];
|
||||||
|
$link['title'] = $renderer->_xmlEntities($link['url']);
|
||||||
|
if ($conf['relnofollow']) $link['more'] .= ' rel="nofollow"';
|
||||||
|
[$ext,] = mimetype(basename($item['local']));
|
||||||
|
$link['class'] = 'media mediafile mf_' . $ext;
|
||||||
|
$renderer->doc .= $renderer->_formatLink($link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a file link on any Renderer
|
||||||
|
* @param array $item
|
||||||
|
* @param bool $cachebuster
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function renderItemLinkAny($item, $cachebuster = false)
|
||||||
|
{
|
||||||
|
$this->renderer->externalmedialink($this->itemWebUrl($item, $cachebuster), $item['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct the Web URL for a given item
|
||||||
|
*
|
||||||
|
* @param array $item The item data as returned by the Crawler
|
||||||
|
* @param bool $cachbuster add a cachebuster to the URL?
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function itemWebUrl($item, $cachbuster = false)
|
||||||
|
{
|
||||||
|
$webdir = $this->webdir;
|
||||||
|
|
||||||
|
// When using the built-in file-serving endpoint, include the current page id
|
||||||
|
// so file.php can enforce DokuWiki ACLs for that page.
|
||||||
|
if (
|
||||||
|
is_string($webdir)
|
||||||
|
&& $webdir !== ''
|
||||||
|
&& strpos($webdir, 'lib/plugins/luxtools/file.php') !== false
|
||||||
|
&& strpos($webdir, 'id=') === false
|
||||||
|
) {
|
||||||
|
global $ID;
|
||||||
|
$pageId = isset($ID) ? (string)$ID : '';
|
||||||
|
if ($pageId !== '') {
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId !== '') {
|
||||||
|
$encoded = rawurlencode($pageId);
|
||||||
|
if (strpos($webdir, '&file=') !== false) {
|
||||||
|
$webdir = str_replace('&file=', '&id=' . $encoded . '&file=', $webdir);
|
||||||
|
} elseif (strpos($webdir, '?file=') !== false) {
|
||||||
|
$webdir = str_replace('?file=', '?id=' . $encoded . '&file=', $webdir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($webdir, '=')) {
|
||||||
|
$url = $webdir . rawurlencode($item['local']);
|
||||||
|
} else {
|
||||||
|
$url = $webdir . $item['local'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cachbuster) {
|
||||||
|
if (strpos($url, '?') === false) {
|
||||||
|
$url .= '?t=' . $item['mtime'];
|
||||||
|
} else {
|
||||||
|
$url .= '&t=' . $item['mtime'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the filelist by recursively walking through all subtrees and
|
||||||
|
* merging them with a prefix attached to the filenames.
|
||||||
|
*
|
||||||
|
* @param array $items the tree to flatten
|
||||||
|
* @param string $prefix the prefix to attach to all processed nodes
|
||||||
|
* @return array a flattened representation of the tree
|
||||||
|
*/
|
||||||
|
protected function flattenResultTree($items, $prefix = '')
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($items as $file) {
|
||||||
|
if ($file['children'] !== false) {
|
||||||
|
$result = array_merge(
|
||||||
|
$result,
|
||||||
|
$this->flattenResultTree($file['children'], $prefix . $file['name'] . '/')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$file['name'] = $prefix . $file['name'];
|
||||||
|
$result[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLang($key)
|
||||||
|
{
|
||||||
|
if ($this->plugin && method_exists($this->plugin, 'getLang')) {
|
||||||
|
return $this->plugin->getLang($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try loading any luxtools syntax component
|
||||||
|
$syntax = plugin_load('syntax', 'luxtools_directory');
|
||||||
|
if ($syntax) {
|
||||||
|
return $syntax->getLang($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key; // Return key if we can't load language strings
|
||||||
|
}
|
||||||
|
}
|
||||||
351
src/PageLink.php
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
class PageLink
|
||||||
|
{
|
||||||
|
public const META_KEY = 'pagelink';
|
||||||
|
public const CACHE_FILE = 'pagelink_cache.json';
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $pathConfig;
|
||||||
|
|
||||||
|
/** @var int */
|
||||||
|
protected $maxDepth;
|
||||||
|
|
||||||
|
/** @var array|null */
|
||||||
|
protected $cache = null;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $cacheDirty = false;
|
||||||
|
|
||||||
|
public function __construct(string $pathConfig, int $maxDepth)
|
||||||
|
{
|
||||||
|
$this->pathConfig = $pathConfig;
|
||||||
|
$this->maxDepth = max(0, $maxDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a v4 UUID (lowercase).
|
||||||
|
*/
|
||||||
|
public static function createUuidV4(): string
|
||||||
|
{
|
||||||
|
$bytes = random_bytes(16);
|
||||||
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||||
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||||
|
$hex = bin2hex($bytes);
|
||||||
|
return sprintf(
|
||||||
|
'%s-%s-%s-%s-%s',
|
||||||
|
substr($hex, 0, 8),
|
||||||
|
substr($hex, 8, 4),
|
||||||
|
substr($hex, 12, 4),
|
||||||
|
substr($hex, 16, 4),
|
||||||
|
substr($hex, 20, 12)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and validate a UUID v4 string.
|
||||||
|
*
|
||||||
|
* @param string $uuid
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public static function normalizeUuid(string $uuid): ?string
|
||||||
|
{
|
||||||
|
$uuid = strtolower(trim($uuid));
|
||||||
|
if ($uuid === '') return null;
|
||||||
|
|
||||||
|
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the page's pagelink UUID from metadata (if valid).
|
||||||
|
*/
|
||||||
|
public function getPageUuid(string $pageId): ?string
|
||||||
|
{
|
||||||
|
if ($pageId === '') return null;
|
||||||
|
if (!function_exists('p_get_metadata')) return null;
|
||||||
|
|
||||||
|
$value = p_get_metadata($pageId, self::META_KEY, METADATA_DONT_RENDER);
|
||||||
|
if (!is_string($value)) return null;
|
||||||
|
return self::normalizeUuid($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a pagelink UUID in page metadata.
|
||||||
|
*/
|
||||||
|
public function setPageUuid(string $pageId, string $uuid): bool
|
||||||
|
{
|
||||||
|
if ($pageId === '') return false;
|
||||||
|
if (!function_exists('p_set_metadata')) return false;
|
||||||
|
$uuid = self::normalizeUuid($uuid);
|
||||||
|
if ($uuid === null) return false;
|
||||||
|
|
||||||
|
return (bool)p_set_metadata($pageId, [self::META_KEY => $uuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the pagelink UUID from page metadata.
|
||||||
|
*/
|
||||||
|
public function removePageUuid(string $pageId): bool
|
||||||
|
{
|
||||||
|
if ($pageId === '') return false;
|
||||||
|
if (!function_exists('p_set_metadata')) return false;
|
||||||
|
|
||||||
|
return (bool)p_set_metadata($pageId, [self::META_KEY => '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a page: remove UUID, delete linked .pagelink file if present, and clear cache.
|
||||||
|
*
|
||||||
|
* @param string $pageId
|
||||||
|
* @return array{ok: bool, uuid: string|null, folder: string|null}
|
||||||
|
*/
|
||||||
|
public function unlinkPage(string $pageId): array
|
||||||
|
{
|
||||||
|
$uuid = $this->getPageUuid($pageId);
|
||||||
|
if ($uuid === null) {
|
||||||
|
return ['ok' => true, 'uuid' => null, 'folder' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkInfo = $this->resolveUuid($uuid);
|
||||||
|
$folder = $linkInfo['folder'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($folder) && $folder !== '') {
|
||||||
|
$file = rtrim($folder, '/\\') . '/.pagelink';
|
||||||
|
if (is_file($file) && !is_link($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeCacheEntry($uuid);
|
||||||
|
$this->removePageUuid($pageId);
|
||||||
|
|
||||||
|
return ['ok' => true, 'uuid' => $uuid, 'folder' => is_string($folder) ? $folder : null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a pagelink UUID to a linked folder (if any).
|
||||||
|
*
|
||||||
|
* @param string $uuid
|
||||||
|
* @return array{folder: string|null, multiple: bool}
|
||||||
|
*/
|
||||||
|
public function resolveUuid(string $uuid): array
|
||||||
|
{
|
||||||
|
$uuid = self::normalizeUuid($uuid);
|
||||||
|
if ($uuid === null) {
|
||||||
|
return ['folder' => null, 'multiple' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = $this->loadCache();
|
||||||
|
if (isset($cache[$uuid]) && is_string($cache[$uuid]) && $cache[$uuid] !== '') {
|
||||||
|
$cachedPath = $cache[$uuid];
|
||||||
|
if ($this->isValidLink($cachedPath, $uuid)) {
|
||||||
|
return ['folder' => $cachedPath, 'multiple' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($cache[$uuid]);
|
||||||
|
$this->cacheDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches = $this->scanRootsForUuid($uuid, 2);
|
||||||
|
if ($matches !== []) {
|
||||||
|
$cache[$uuid] = $matches[0];
|
||||||
|
$this->cacheDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->cacheDirty) {
|
||||||
|
$this->writeCache($cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'folder' => $matches[0] ?? null,
|
||||||
|
'multiple' => count($matches) > 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the cache file into memory.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function loadCache(): array
|
||||||
|
{
|
||||||
|
if ($this->cache !== null) return $this->cache;
|
||||||
|
|
||||||
|
$this->cache = [];
|
||||||
|
$file = $this->getCacheFile();
|
||||||
|
if (!is_file($file) || !is_readable($file)) return $this->cache;
|
||||||
|
|
||||||
|
$raw = @file_get_contents($file);
|
||||||
|
if (!is_string($raw) || $raw === '') return $this->cache;
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) return $this->cache;
|
||||||
|
|
||||||
|
$this->cache = $decoded;
|
||||||
|
return $this->cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write cache to disk atomically.
|
||||||
|
*/
|
||||||
|
protected function writeCache(array $cache): void
|
||||||
|
{
|
||||||
|
$file = $this->getCacheFile();
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (function_exists('io_mkdir_p')) {
|
||||||
|
io_mkdir_p($dir);
|
||||||
|
} elseif (!@is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmp = $file . '.tmp.' . getmypid();
|
||||||
|
$data = json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($data === false) return;
|
||||||
|
|
||||||
|
@file_put_contents($tmp, $data, LOCK_EX);
|
||||||
|
if (!@rename($tmp, $file)) {
|
||||||
|
@copy($tmp, $file);
|
||||||
|
@unlink($tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->cacheDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific UUID from cache.
|
||||||
|
*/
|
||||||
|
public function removeCacheEntry(string $uuid): void
|
||||||
|
{
|
||||||
|
$uuid = self::normalizeUuid($uuid);
|
||||||
|
if ($uuid === null) return;
|
||||||
|
|
||||||
|
$cache = $this->loadCache();
|
||||||
|
if (!isset($cache[$uuid])) return;
|
||||||
|
|
||||||
|
unset($cache[$uuid]);
|
||||||
|
$this->writeCache($cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache file path for pagelink mappings.
|
||||||
|
*/
|
||||||
|
protected function getCacheFile(): string
|
||||||
|
{
|
||||||
|
global $conf;
|
||||||
|
$cacheDir = rtrim((string)$conf['cachedir'], '/');
|
||||||
|
return $cacheDir . '/luxtools/' . self::CACHE_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the cached path still points to a valid .pagelink file.
|
||||||
|
*/
|
||||||
|
protected function isValidLink(string $folder, string $uuid): bool
|
||||||
|
{
|
||||||
|
if ($folder === '') return false;
|
||||||
|
if (!is_dir($folder)) return false;
|
||||||
|
if (is_link($folder)) return false;
|
||||||
|
|
||||||
|
$file = rtrim($folder, '/\\') . '/.pagelink';
|
||||||
|
if (!is_file($file) || is_link($file) || !is_readable($file)) return false;
|
||||||
|
|
||||||
|
$content = @file_get_contents($file);
|
||||||
|
if (!is_string($content)) return false;
|
||||||
|
|
||||||
|
$content = self::normalizeUuid($content);
|
||||||
|
return $content !== null && $content === $uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan configured roots for matching .pagelink files.
|
||||||
|
*
|
||||||
|
* @param string $uuid
|
||||||
|
* @param int $limit Maximum number of matches to collect.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
protected function scanRootsForUuid(string $uuid, int $limit = 2): array
|
||||||
|
{
|
||||||
|
$roots = $this->getConfiguredRoots();
|
||||||
|
if ($roots === []) return [];
|
||||||
|
|
||||||
|
$matches = [];
|
||||||
|
foreach ($roots as $root) {
|
||||||
|
$this->scanDirectory($root, 0, $uuid, $limit, $matches);
|
||||||
|
if (count($matches) >= $limit) break;
|
||||||
|
}
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan a directory for .pagelink files.
|
||||||
|
*
|
||||||
|
* @param string $dir
|
||||||
|
* @param int $depth
|
||||||
|
* @param string $uuid
|
||||||
|
* @param int $limit
|
||||||
|
* @param array $matches
|
||||||
|
*/
|
||||||
|
protected function scanDirectory(string $dir, int $depth, string $uuid, int $limit, array &$matches): void
|
||||||
|
{
|
||||||
|
if ($dir === '' || count($matches) >= $limit) return;
|
||||||
|
if (!is_dir($dir) || is_link($dir)) return;
|
||||||
|
if (!is_readable($dir)) return;
|
||||||
|
if ($depth > $this->maxDepth) return;
|
||||||
|
|
||||||
|
$file = rtrim($dir, '/\\') . '/.pagelink';
|
||||||
|
if (is_file($file) && !is_link($file) && is_readable($file)) {
|
||||||
|
$content = @file_get_contents($file);
|
||||||
|
if (is_string($content)) {
|
||||||
|
$content = self::normalizeUuid($content);
|
||||||
|
if ($content !== null && $content === $uuid) {
|
||||||
|
$matches[] = rtrim($dir, '/\\');
|
||||||
|
if (count($matches) >= $limit) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($depth >= $this->maxDepth) return;
|
||||||
|
|
||||||
|
$handle = @opendir($dir);
|
||||||
|
if ($handle === false) return;
|
||||||
|
|
||||||
|
$base = rtrim($dir, '/\\');
|
||||||
|
while (($entry = readdir($handle)) !== false) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if ($entry === '.pagelink') continue;
|
||||||
|
|
||||||
|
$path = $base . '/' . $entry;
|
||||||
|
if (!is_dir($path) || is_link($path)) continue;
|
||||||
|
|
||||||
|
$this->scanDirectory($path, $depth + 1, $uuid, $limit, $matches);
|
||||||
|
if (count($matches) >= $limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
closedir($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve configured root paths (excluding aliases).
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
protected function getConfiguredRoots(): array
|
||||||
|
{
|
||||||
|
$pathConfig = trim($this->pathConfig);
|
||||||
|
if ($pathConfig === '') return [];
|
||||||
|
|
||||||
|
$helper = new Path($pathConfig);
|
||||||
|
$paths = $helper->getPaths();
|
||||||
|
$roots = [];
|
||||||
|
foreach ($paths as $key => $info) {
|
||||||
|
if (!isset($info['root']) || $key !== $info['root']) continue;
|
||||||
|
$roots[] = $info['root'];
|
||||||
|
}
|
||||||
|
return $roots;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/PageLinkTrait.php
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for pagelink-related functionality shared across syntax handlers.
|
||||||
|
*
|
||||||
|
* Provides methods for:
|
||||||
|
* - Detecting blobs alias paths
|
||||||
|
* - Resolving the blobs root folder from page metadata
|
||||||
|
* - Rendering "page not linked" messages
|
||||||
|
* - Building path configs with blobs alias support
|
||||||
|
*
|
||||||
|
* Requirements for using classes:
|
||||||
|
* - Must have getConf() method (from SyntaxPlugin)
|
||||||
|
* - Must have getLang() method (from SyntaxPlugin)
|
||||||
|
*/
|
||||||
|
trait PageLinkTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the given path uses the blobs alias.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function isBlobsPath(string $path): bool
|
||||||
|
{
|
||||||
|
$trimmed = ltrim($path, '/');
|
||||||
|
return preg_match('/^blobs(\/|$)/', $trimmed) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Page not linked" message with copy ID affordance.
|
||||||
|
*
|
||||||
|
* @param \Doku_Renderer $renderer
|
||||||
|
*/
|
||||||
|
protected function renderPageNotLinked(\Doku_Renderer $renderer): void
|
||||||
|
{
|
||||||
|
$text = (string)$this->getLang('pagelink_unlinked');
|
||||||
|
|
||||||
|
if ($renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<span class="luxtools-pagelink-status">' . hsc($text) . '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->cdata('[n/a: ' . $text . ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current page UUID (if any).
|
||||||
|
*
|
||||||
|
* @return string The UUID or empty string
|
||||||
|
*/
|
||||||
|
protected function getPageUuidSafe(): string
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
$pageId = is_string($ID) ? $ID : '';
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
$depth = (int)$this->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
return $uuid ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current page's pagelink folder for the blobs alias.
|
||||||
|
*
|
||||||
|
* Results are cached per page ID within a single request.
|
||||||
|
*
|
||||||
|
* @return string The linked folder path or empty string if not linked
|
||||||
|
*/
|
||||||
|
protected function resolveBlobsRoot(): string
|
||||||
|
{
|
||||||
|
static $cached = [];
|
||||||
|
|
||||||
|
global $ID;
|
||||||
|
$pageId = is_string($ID) ? $ID : '';
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (function_exists('cleanID')) {
|
||||||
|
$pageId = (string)cleanID($pageId);
|
||||||
|
}
|
||||||
|
if ($pageId === '') return '';
|
||||||
|
|
||||||
|
if (isset($cached[$pageId])) {
|
||||||
|
return (string)$cached[$pageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = (int)$this->getConf('pagelink_search_depth');
|
||||||
|
if ($depth < 0) $depth = 0;
|
||||||
|
|
||||||
|
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
|
||||||
|
$uuid = $pageLink->getPageUuid($pageId);
|
||||||
|
if ($uuid === null) {
|
||||||
|
$cached[$pageId] = '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkInfo = $pageLink->resolveUuid($uuid);
|
||||||
|
$folder = $linkInfo['folder'] ?? '';
|
||||||
|
if (!is_string($folder) || $folder === '') {
|
||||||
|
$cached[$pageId] = '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cached[$pageId] = $folder;
|
||||||
|
return $folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a path config string with the blobs alias appended (if available).
|
||||||
|
*
|
||||||
|
* @param string|null $blobsRoot The blobs root folder (or null to auto-resolve)
|
||||||
|
* @return string The path config string
|
||||||
|
*/
|
||||||
|
protected function buildPathConfigWithBlobs(?string $blobsRoot = null): string
|
||||||
|
{
|
||||||
|
$pathConfig = (string)$this->getConf('paths');
|
||||||
|
|
||||||
|
if ($blobsRoot === null) {
|
||||||
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($blobsRoot !== '') {
|
||||||
|
$pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pathConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Path helper with blobs alias support.
|
||||||
|
*
|
||||||
|
* @param string|null $blobsRoot The blobs root folder (or null to auto-resolve)
|
||||||
|
* @return Path
|
||||||
|
*/
|
||||||
|
protected function createPathHelperWithBlobs(?string $blobsRoot = null): Path
|
||||||
|
{
|
||||||
|
return new Path($this->buildPathConfigWithBlobs($blobsRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Path helper using only the base paths config (no blobs alias).
|
||||||
|
*
|
||||||
|
* @return Path
|
||||||
|
*/
|
||||||
|
protected function createPathHelper(): Path
|
||||||
|
{
|
||||||
|
return new Path((string)$this->getConf('paths'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace dokuwiki\plugin\filelist;
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
class Path
|
class Path
|
||||||
{
|
{
|
||||||
@@ -49,18 +49,13 @@ class Path
|
|||||||
$alias = static::cleanPath($line);
|
$alias = static::cleanPath($line);
|
||||||
$paths[$lastRoot]['alias'] = $alias;
|
$paths[$lastRoot]['alias'] = $alias;
|
||||||
$paths[$alias] = &$paths[$lastRoot]; // alias references the original
|
$paths[$alias] = &$paths[$lastRoot]; // alias references the original
|
||||||
} elseif (str_starts_with($line, 'W>')) {
|
|
||||||
// this is a web path for the last read root
|
|
||||||
$line = trim(substr($line, 2));
|
|
||||||
if (!isset($paths[$lastRoot])) continue; // no last path, no web path
|
|
||||||
$paths[$lastRoot]['web'] = $line;
|
|
||||||
} else {
|
} else {
|
||||||
// this is a new path
|
// this is a new path
|
||||||
$line = static::cleanPath($line);
|
$line = static::cleanPath($line);
|
||||||
$lastRoot = $line;
|
$lastRoot = $line;
|
||||||
$paths[$line] = [
|
$paths[$line] = [
|
||||||
'root' => $line,
|
'root' => $line,
|
||||||
'web' => DOKU_BASE . 'lib/plugins/filelist/file.php?root=' . rawurlencode($line) . '&file=',
|
'web' => DOKU_BASE . 'lib/plugins/luxtools/file.php?root=' . rawurlencode($line) . '&file=',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +76,10 @@ class Path
|
|||||||
$path = static::cleanPath($path, $addTrailingSlash);
|
$path = static::cleanPath($path, $addTrailingSlash);
|
||||||
|
|
||||||
$paths = $this->paths;
|
$paths = $this->paths;
|
||||||
|
if ($paths === []) {
|
||||||
|
throw new \Exception('No paths configured');
|
||||||
|
}
|
||||||
|
|
||||||
$allowed = array_keys($paths);
|
$allowed = array_keys($paths);
|
||||||
usort($allowed, static fn($a, $b) => strlen($a) - strlen($b));
|
usort($allowed, static fn($a, $b) => strlen($a) - strlen($b));
|
||||||
$allowed = array_map('preg_quote_cb', $allowed);
|
$allowed = array_map('preg_quote_cb', $allowed);
|
||||||
@@ -99,6 +98,72 @@ class Path
|
|||||||
return $pathInfo;
|
return $pathInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a real filesystem path back to an open-service alias, if available.
|
||||||
|
*
|
||||||
|
* Example: root "/share/Datascape/" with alias "/Scape/" maps
|
||||||
|
* "/share/Datascape/some/folder" -> "Scape>some/folder".
|
||||||
|
*
|
||||||
|
* If no alias matches, the input path is returned unchanged (except for
|
||||||
|
* normalization of slashes and dot-segments).
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function mapToAliasPath($path)
|
||||||
|
{
|
||||||
|
if (!is_string($path) || $path === '') return $path;
|
||||||
|
|
||||||
|
// normalize input for matching, but do not force a trailing slash
|
||||||
|
$normalized = static::cleanPath($path, false);
|
||||||
|
|
||||||
|
// collect root->alias mappings for open-service links
|
||||||
|
// (avoid alias keys that reference the same config)
|
||||||
|
$mappings = [];
|
||||||
|
foreach ($this->paths as $key => $info) {
|
||||||
|
if (!isset($info['root']) || $key !== $info['root']) continue;
|
||||||
|
if (empty($info['alias'])) continue;
|
||||||
|
|
||||||
|
$alias = $this->normalizeOpenAlias((string)$info['alias']);
|
||||||
|
if ($alias === '') continue;
|
||||||
|
|
||||||
|
$mappings[$info['root']] = $alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mappings === []) return $normalized;
|
||||||
|
|
||||||
|
// Prefer the longest matching root (handles nested/overlapping roots)
|
||||||
|
uksort($mappings, static fn($a, $b) => strlen($b) - strlen($a));
|
||||||
|
|
||||||
|
foreach ($mappings as $root => $alias) {
|
||||||
|
$rootNoTrailingSlash = rtrim($root, '/');
|
||||||
|
if (!str_starts_with($normalized, $root) && $normalized !== $rootNoTrailingSlash) continue;
|
||||||
|
|
||||||
|
$suffix = '';
|
||||||
|
if (str_starts_with($normalized, $root)) {
|
||||||
|
$suffix = (string)substr($normalized, strlen($root));
|
||||||
|
}
|
||||||
|
$suffix = ltrim($suffix, '/');
|
||||||
|
|
||||||
|
return $alias . '>' . $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert legacy path-like aliases (e.g. /Scape/) to open aliases (Scape).
|
||||||
|
*
|
||||||
|
* @param string $alias
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function normalizeOpenAlias($alias)
|
||||||
|
{
|
||||||
|
$alias = trim($alias);
|
||||||
|
$alias = trim($alias, '/\\');
|
||||||
|
return $alias;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean a path for better comparison
|
* Clean a path for better comparison
|
||||||
*
|
*
|
||||||
91
src/ScratchpadMap.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps scratchpad aliases (used in wiki pages) to full filesystem file paths.
|
||||||
|
*
|
||||||
|
* Config format (one per line):
|
||||||
|
* /full/path/to/pad-file.txt
|
||||||
|
* A> padname
|
||||||
|
*
|
||||||
|
* The next A> line assigns an alias for the previously listed file.
|
||||||
|
*/
|
||||||
|
class ScratchpadMap
|
||||||
|
{
|
||||||
|
/** @var array<string, array{alias:string, path:string}> */
|
||||||
|
protected $map = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $config
|
||||||
|
*/
|
||||||
|
public function __construct($config)
|
||||||
|
{
|
||||||
|
$this->map = $this->parseConfig((string)$config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the given alias to a full file path.
|
||||||
|
*
|
||||||
|
* @param string $alias
|
||||||
|
* @return string
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function resolve($alias)
|
||||||
|
{
|
||||||
|
$alias = trim((string)$alias);
|
||||||
|
if ($alias === '') throw new \Exception('Empty alias');
|
||||||
|
if (!isset($this->map[$alias])) {
|
||||||
|
throw new \Exception('Unknown scratchpad alias');
|
||||||
|
}
|
||||||
|
return (string)$this->map[$alias]['path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the parsed mapping.
|
||||||
|
*
|
||||||
|
* @return array<string, array{alias:string, path:string}>
|
||||||
|
*/
|
||||||
|
public function getMap()
|
||||||
|
{
|
||||||
|
return $this->map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $config
|
||||||
|
* @return array<string, array{alias:string, path:string}>
|
||||||
|
*/
|
||||||
|
protected function parseConfig($config)
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
$lines = explode("\n", (string)$config);
|
||||||
|
|
||||||
|
$lastFile = '';
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') continue;
|
||||||
|
|
||||||
|
if (str_starts_with($line, 'A>')) {
|
||||||
|
$alias = trim(substr($line, 2));
|
||||||
|
if ($alias === '' || $lastFile === '') continue;
|
||||||
|
$map[$alias] = [
|
||||||
|
'alias' => $alias,
|
||||||
|
'path' => $lastFile,
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as file path (no trailing slash enforced)
|
||||||
|
$filePath = Path::cleanPath($line, false);
|
||||||
|
if ($filePath === '' || str_ends_with($filePath, '/')) {
|
||||||
|
// Ignore invalid entries; they will not be resolvable
|
||||||
|
$lastFile = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastFile = $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/ThumbnailHelper.php
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utility for thumbnail cache management and display logic.
|
||||||
|
*
|
||||||
|
* Used by both the image syntax and the gallery rendering to avoid code duplication.
|
||||||
|
*/
|
||||||
|
class ThumbnailHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if a string is a HTTP/HTTPS URL.
|
||||||
|
*
|
||||||
|
* @param string $url
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isRemoteUrl(string $url): bool
|
||||||
|
{
|
||||||
|
if ($url === '') return false;
|
||||||
|
$parts = @parse_url($url);
|
||||||
|
if (!is_array($parts)) return false;
|
||||||
|
if (empty($parts['scheme']) || empty($parts['host'])) return false;
|
||||||
|
$scheme = strtolower((string)$parts['scheme']);
|
||||||
|
return in_array($scheme, ['http', 'https'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get thumbnail URL and metadata for rendering.
|
||||||
|
*
|
||||||
|
* This is the main entry point for getting thumbnails. The helper handles
|
||||||
|
* all URL construction, cache checking, and provides ready-to-use URLs.
|
||||||
|
*
|
||||||
|
* @param string $rootPath Root filesystem path (e.g., '/data/images/')
|
||||||
|
* @param string $localPath Local path relative to root (e.g., 'photo.jpg')
|
||||||
|
* @param string $pageId Page ID for ACL check
|
||||||
|
* @param int $width Desired width
|
||||||
|
* @param int $height Desired height
|
||||||
|
* @param int $quality JPEG quality (0-100)
|
||||||
|
* @param string|null $placeholderId Optional MediaManager ID for custom placeholder
|
||||||
|
* @return array [
|
||||||
|
* 'url' => string, // Always usable URL (thumbnail or placeholder)
|
||||||
|
* 'isFinal' => bool, // true if thumbnail ready, false if placeholder
|
||||||
|
* 'thumbUrl' => string // Real thumbnail URL (for lazy loading)
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
public static function getThumbnail(
|
||||||
|
string $rootPath,
|
||||||
|
string $localPath,
|
||||||
|
string $pageId,
|
||||||
|
int $width,
|
||||||
|
int $height,
|
||||||
|
int $quality = 80,
|
||||||
|
?string $placeholderId = null
|
||||||
|
): array {
|
||||||
|
$fullPath = $rootPath . $localPath;
|
||||||
|
$thumbUrl = self::buildThumbnailUrl($rootPath, $localPath, $pageId, $width, $height, $quality);
|
||||||
|
|
||||||
|
// Check if cached
|
||||||
|
$cachePath = self::getCachePath($fullPath, $width, $height, $quality);
|
||||||
|
$isCached = $cachePath !== null && @is_file($cachePath);
|
||||||
|
|
||||||
|
if ($isCached) {
|
||||||
|
return [
|
||||||
|
'url' => $thumbUrl,
|
||||||
|
'isFinal' => true,
|
||||||
|
'thumbUrl' => $thumbUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached: return placeholder
|
||||||
|
return [
|
||||||
|
'url' => self::getPlaceholderUrl($width, $height, $placeholderId),
|
||||||
|
'isFinal' => false,
|
||||||
|
'thumbUrl' => $thumbUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the file.php URL for a thumbnail.
|
||||||
|
*
|
||||||
|
* @param string $rootPath Root filesystem path
|
||||||
|
* @param string $localPath Local path relative to root
|
||||||
|
* @param string $pageId Page ID for ACL check
|
||||||
|
* @param int $width Width
|
||||||
|
* @param int $height Height
|
||||||
|
* @param int $quality JPEG quality
|
||||||
|
* @return string Complete URL to file.php with thumbnail parameters
|
||||||
|
*/
|
||||||
|
protected static function buildThumbnailUrl(
|
||||||
|
string $rootPath,
|
||||||
|
string $localPath,
|
||||||
|
string $pageId,
|
||||||
|
int $width,
|
||||||
|
int $height,
|
||||||
|
int $quality
|
||||||
|
): string {
|
||||||
|
$params = [
|
||||||
|
'root' => $rootPath,
|
||||||
|
'file' => $localPath,
|
||||||
|
'id' => $pageId,
|
||||||
|
'thumb' => 1,
|
||||||
|
'w' => $width,
|
||||||
|
'h' => $height,
|
||||||
|
'q' => $quality,
|
||||||
|
];
|
||||||
|
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the expected thumbnail cache path.
|
||||||
|
*
|
||||||
|
* Mirrors the hashing scheme in file.php so we can detect whether a thumb
|
||||||
|
* already exists and can be used immediately.
|
||||||
|
*
|
||||||
|
* @param string $path Full filesystem path to the image
|
||||||
|
* @param int $w Width
|
||||||
|
* @param int $h Height
|
||||||
|
* @param int $q Quality (JPEG)
|
||||||
|
* @return string|null Path to cached thumbnail, or null if unavailable
|
||||||
|
*/
|
||||||
|
public static function getCachePath(string $path, int $w, int $h, int $q = 80): ?string
|
||||||
|
{
|
||||||
|
if ($path === '' || !is_file($path)) return null;
|
||||||
|
|
||||||
|
$mtime = @filemtime($path);
|
||||||
|
if ($mtime === false) return null;
|
||||||
|
|
||||||
|
// Decide output format the same way file.php does
|
||||||
|
try {
|
||||||
|
[, $mime,] = mimetype($path, false);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!is_string($mime) || !str_starts_with($mime, 'image/')) return null;
|
||||||
|
$dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg';
|
||||||
|
|
||||||
|
global $conf;
|
||||||
|
if (!isset($conf['cachedir']) || !is_string($conf['cachedir']) || trim($conf['cachedir']) === '') return null;
|
||||||
|
|
||||||
|
$hash = sha1($path . '|' . $mtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat);
|
||||||
|
$sub = substr($hash, 0, 2);
|
||||||
|
$cacheDir = rtrim($conf['cachedir'], '/');
|
||||||
|
return $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get placeholder image URL.
|
||||||
|
*
|
||||||
|
* @param int $width Desired width
|
||||||
|
* @param int $height Desired height
|
||||||
|
* @param string|null $placeholderId Optional MediaManager ID for custom placeholder
|
||||||
|
* @return string Placeholder URL
|
||||||
|
*/
|
||||||
|
public static function getPlaceholderUrl(int $width, int $height, ?string $placeholderId = null): string
|
||||||
|
{
|
||||||
|
$placeholderUrl = DOKU_BASE . 'lib/images/blank.gif';
|
||||||
|
|
||||||
|
if ($placeholderId !== null && $placeholderId !== '' && function_exists('ml')) {
|
||||||
|
$placeholderUrl = ml($placeholderId, ['w' => $width, 'h' => $height], true, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $placeholderUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the initial image source and data attributes for lazy thumbnail loading.
|
||||||
|
*
|
||||||
|
* Returns placeholder info if thumbnail needs to be loaded, or the actual
|
||||||
|
* thumbnail URL if it's already cached.
|
||||||
|
*
|
||||||
|
* @param string $imagePath Full filesystem path to the image
|
||||||
|
* @param string $thumbUrl URL to the thumbnail
|
||||||
|
* @param int $width Width
|
||||||
|
* @param int $height Height
|
||||||
|
* @param int $quality Quality (JPEG)
|
||||||
|
* @param string|null $placeholderId Optional MediaManager ID for custom placeholder
|
||||||
|
* @return array ['src' => string, 'dataThumbAttr' => string]
|
||||||
|
*/
|
||||||
|
public static function getDisplayInfo(
|
||||||
|
string $imagePath,
|
||||||
|
string $thumbUrl,
|
||||||
|
int $width,
|
||||||
|
int $height,
|
||||||
|
int $quality = 80,
|
||||||
|
?string $placeholderId = null
|
||||||
|
): array {
|
||||||
|
$thumbCachePath = self::getCachePath($imagePath, $width, $height, $quality);
|
||||||
|
|
||||||
|
if ($thumbCachePath !== null && @is_file($thumbCachePath)) {
|
||||||
|
// Thumbnail exists: display it immediately
|
||||||
|
return [
|
||||||
|
'src' => $thumbUrl,
|
||||||
|
'dataThumbAttr' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail doesn't exist: show placeholder and lazy-load
|
||||||
|
$placeholderUrl = self::getPlaceholderUrl($width, $height, $placeholderId);
|
||||||
|
return [
|
||||||
|
'src' => $placeholderUrl,
|
||||||
|
'dataThumbAttr' => ' data-thumb-src="' . hsc($thumbUrl) . '"',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
621
style.css
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
/* luxtools plugin styles
|
||||||
|
* Keep this minimal and scoped to the plugin container.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* DokuWiki often highlights rows on hover. Avoid highlighting header rows. */
|
||||||
|
div.luxtools-plugin table thead tr:hover > * {
|
||||||
|
background-color: @ini_background_alt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* "Open Location" row above the header should be visually smaller. */
|
||||||
|
div.luxtools-plugin table thead tr.luxtools-openlocation-row td {
|
||||||
|
font-size: 80%;
|
||||||
|
padding-top: 0.2em;
|
||||||
|
padding-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
div.luxtools-plugin table thead tr.luxtools-openlocation-row:hover td {
|
||||||
|
background-color: @ini_background !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure directories use a dedicated folder icon.
|
||||||
|
* DokuWiki's icon CSS is generated primarily for file extensions; a custom
|
||||||
|
* mf_folder class may otherwise fall back to the generic file icon.
|
||||||
|
*/
|
||||||
|
div.luxtools-plugin a.media.mediafile.mf_folder,
|
||||||
|
div.luxtools-plugin a.mediafile.mf_folder {
|
||||||
|
background-image: url(images/folder.svg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DokuWiki templates often style .media links with higher specificity.
|
||||||
|
* Ensure our custom color always wins.
|
||||||
|
*/
|
||||||
|
div.luxtools-plugin a.luxtools-open,
|
||||||
|
div.luxtools-plugin a.luxtools-open:visited,
|
||||||
|
a.luxtools-open,
|
||||||
|
a.luxtools-open:visited {
|
||||||
|
color: @ini_luxtools_locationlink !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standalone {{open>...}} links are not wrapped in div.luxtools-plugin. */
|
||||||
|
a.luxtools-open.media.mediafile.mf_folder {
|
||||||
|
background-image: url(images/open-folder.svg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Muted empty-state message when a listing has no results. */
|
||||||
|
div.luxtools-plugin .luxtools-empty {
|
||||||
|
opacity: 0.65;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page link status (unlinked blobs alias) */
|
||||||
|
span.luxtools-pagelink-status {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0.25em 0;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image gallery spacing. */
|
||||||
|
div.luxtools-gallery {
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-gallery a.media.luxtools-gallery-item {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-gallery a.media.luxtools-gallery-item:hover,
|
||||||
|
div.luxtools-gallery a.media.luxtools-gallery-item:focus {
|
||||||
|
border-color: @ini_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-gallery img.luxtools-thumb {
|
||||||
|
display: block;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
/* Placeholder while lazy-loaded image is pending.
|
||||||
|
* Uses custom property from inline style if thumb_placeholder is configured,
|
||||||
|
* otherwise falls back to built-in SVG icon. */
|
||||||
|
background-color: @ini_background;
|
||||||
|
background-image: var(--luxtools-placeholder, url(images/placeholder.svg));
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
/* contain works well for both: configured placeholder fills the area,
|
||||||
|
* built-in SVG icon stays small and centered */
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filename overlay (single line, muted). */
|
||||||
|
div.luxtools-gallery .luxtools-gallery-caption {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding: 0.25em 0.4em;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scratchpad */
|
||||||
|
div.luxtools-scratchpad {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Template hook: stable wrapper around scratchpad content + controls. */
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-frame {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.4em;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-name {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0.65;
|
||||||
|
font-size: 90%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad a.luxtools-scratchpad-edit {
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2em;
|
||||||
|
min-height: 2em;
|
||||||
|
line-height: 1;
|
||||||
|
font-style: italic;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
div.luxtools-scratchpad a.luxtools-scratchpad-edit:hover,
|
||||||
|
div.luxtools-scratchpad a.luxtools-scratchpad-edit:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit mode: replace rendered view with editor. */
|
||||||
|
div.luxtools-scratchpad.is-editing .luxtools-scratchpad-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad.is-editing .luxtools-scratchpad-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-editor {
|
||||||
|
margin-top: 0.4em;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad textarea.luxtools-scratchpad-text {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 8em;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
margin-top: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-status {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-scratchpad .luxtools-scratchpad-error {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin: settings page form layout */
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block > span {
|
||||||
|
flex: 0 0 35%;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The admin page markup uses <br/> to create a new line; in this layout we keep
|
||||||
|
* label + control in one row.
|
||||||
|
*/
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block > br {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit,
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="text"].edit,
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="number"].edit,
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: 65%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdowns: don't stretch; size to content, but keep them on the right. */
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox controls: keep them in the control column, left-aligned. */
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block input[type="checkbox"] {
|
||||||
|
margin-left: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On narrow screens, stack label and control to keep things readable. */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block > br {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit,
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="text"].edit,
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="number"].edit,
|
||||||
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable background scrolling while the lightbox is open. */
|
||||||
|
html.luxtools-noscroll,
|
||||||
|
html.luxtools-noscroll body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen lightbox viewer (client-side). */
|
||||||
|
.luxtools-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox.is-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox .luxtools-lightbox-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox .luxtools-lightbox-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox .luxtools-lightbox-media {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox img.luxtools-lightbox-img {
|
||||||
|
display: block;
|
||||||
|
/* Use viewport constraints so tall images always fit.
|
||||||
|
* Percent max-height is unreliable here because the wrapper doesn't have a fixed height.
|
||||||
|
*/
|
||||||
|
max-width: calc(100vw - 1.6em);
|
||||||
|
max-height: calc(100vh - 1.6em);
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox .luxtools-lightbox-caption {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 1.2em;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: block;
|
||||||
|
max-width: 60rem;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border-radius: 0.4em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image-anchored prev/next zones.
|
||||||
|
* These are sized relative to the displayed image (not the viewport) and show
|
||||||
|
* a subtle indicator only on hover/focus.
|
||||||
|
*/
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 25%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone-prev {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone-next {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 3em;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
text-shadow: 0 0 0.4em rgba(0, 0, 0, 0.9);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone-prev::after {
|
||||||
|
content: '‹';
|
||||||
|
left: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone-next::after {
|
||||||
|
content: '›';
|
||||||
|
right: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone:hover::after,
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-right close button. */
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.8em;
|
||||||
|
right: 0.8em;
|
||||||
|
z-index: 3;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
text-shadow: 0 0 0.4em rgba(0, 0, 0, 0.9);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 2.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-close:hover,
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-close:focus-visible {
|
||||||
|
background: rgba(0, 0, 0, 0.60);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.luxtools-lightbox button.luxtools-lightbox-zone::after {
|
||||||
|
font-size: 2.4em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
* Grouping wrapper (compact image layout container)
|
||||||
|
* ======================================================================== */
|
||||||
|
|
||||||
|
.luxtools-grouping {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--luxtools-grouping-cols, 2), minmax(0, 1fr));
|
||||||
|
gap: var(--luxtools-grouping-gap, 0);
|
||||||
|
justify-content: var(--luxtools-grouping-justify, start);
|
||||||
|
align-items: var(--luxtools-grouping-align, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-grouping.luxtools-grouping--flex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--luxtools-grouping-gap, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Let the grouping layout fully control item placement. */
|
||||||
|
.luxtools-grouping .luxtools-imagebox {
|
||||||
|
float: none;
|
||||||
|
clear: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
* Imagebox (Wikipedia-style image with caption)
|
||||||
|
* ======================================================================== */
|
||||||
|
|
||||||
|
.luxtools-imagebox {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox.tleft {
|
||||||
|
clear: left;
|
||||||
|
float: left;
|
||||||
|
margin-right: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox.tright {
|
||||||
|
clear: right;
|
||||||
|
float: right;
|
||||||
|
margin-left: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox.tcenter {
|
||||||
|
clear: both;
|
||||||
|
text-align: center;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox.tcenter .luxtools-imagebox-inner {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox .luxtools-imagebox-inner {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
padding: 3px;
|
||||||
|
font-size: 94%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox .luxtools-imagebox-inner > a {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
background-color: @ini_background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox .luxtools-imagebox-inner img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-imagebox .luxtools-imagebox-caption {
|
||||||
|
border: none;
|
||||||
|
font-size: 94%;
|
||||||
|
line-height: 1.4em;
|
||||||
|
padding: 3px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
* Calendar widget
|
||||||
|
* ======================================================================== */
|
||||||
|
|
||||||
|
div.luxtools-calendar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 88%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
font-size: 95%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-prev {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-next {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-prev a,
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-next a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-button {
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
color: @ini_text;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.2em 0.45em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-button:hover,
|
||||||
|
div.luxtools-calendar .luxtools-calendar-nav-button:focus {
|
||||||
|
background-color: @ini_highlight;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar table.luxtools-calendar-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar table.luxtools-calendar-table th,
|
||||||
|
div.luxtools-calendar table.luxtools-calendar-table td {
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar table.luxtools-calendar-table th {
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
font-size: 85%;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day-empty {
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day-today {
|
||||||
|
background-color: @ini_highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 1.9em;
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day a:hover,
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day a:focus,
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day a:active,
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day a:visited {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day:hover {
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
|
||||||
|
background-color: @ini_highlight;
|
||||||
|
}
|
||||||
136
syntax.php
@@ -1,140 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use dokuwiki\Extension\SyntaxPlugin;
|
require_once(__DIR__ . '/syntax/AbstractSyntax.php');
|
||||||
use dokuwiki\plugin\filelist\Crawler;
|
require_once(__DIR__ . '/syntax/scratchpad.php');
|
||||||
use dokuwiki\plugin\filelist\Output;
|
|
||||||
use dokuwiki\plugin\filelist\Path;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filelist Plugin: Lists files matching a given glob pattern.
|
* luxtools plugin bootstrap.
|
||||||
*
|
*
|
||||||
* @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
|
* The actual syntax implementation lives in the syntax classes.
|
||||||
* @author Gina Haeussge <osd@foosel.net>
|
* This class exists to register the syntax with DokuWiki and for other classes to have a common namespace.
|
||||||
*/
|
*/
|
||||||
class syntax_plugin_filelist extends SyntaxPlugin
|
class syntax_plugin_luxtools extends syntax_plugin_luxtools_directory
|
||||||
{
|
{
|
||||||
/** @inheritdoc */
|
|
||||||
public function getType()
|
|
||||||
{
|
|
||||||
return 'substition';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @inheritdoc */
|
|
||||||
public function getPType()
|
|
||||||
{
|
|
||||||
return 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @inheritdoc */
|
|
||||||
public function getSort()
|
|
||||||
{
|
|
||||||
return 222;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
public function connectTo($mode)
|
public function connectTo($mode)
|
||||||
{
|
{
|
||||||
$this->Lexer->addSpecialPattern('\{\{filelist>.+?\}\}', $mode, 'plugin_filelist');
|
// Intentionally empty: syntax is registered by syntax_plugin_luxtools_files.
|
||||||
}
|
|
||||||
|
|
||||||
/** @inheritdoc */
|
|
||||||
public function handle($match, $state, $pos, Doku_Handler $handler)
|
|
||||||
{
|
|
||||||
global $INPUT;
|
|
||||||
|
|
||||||
// do not allow the syntax in discussion plugin comments
|
|
||||||
if (!$this->getConf('allow_in_comments') && $INPUT->has('comment')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$match = substr($match, strlen('{{filelist>'), -2);
|
|
||||||
[$path, $flags] = explode('&', $match, 2);
|
|
||||||
|
|
||||||
// load default config options
|
|
||||||
$flags = $this->getConf('defaults') . '&' . $flags;
|
|
||||||
$flags = explode('&', $flags);
|
|
||||||
|
|
||||||
$params = [
|
|
||||||
'sort' => 'name',
|
|
||||||
'order' => 'asc',
|
|
||||||
'style' => 'list',
|
|
||||||
'tableheader' => 0,
|
|
||||||
'recursive' => 0,
|
|
||||||
'titlefile' => '_title.txt',
|
|
||||||
'cache' => 0,
|
|
||||||
'randlinks' => 0,
|
|
||||||
'showsize' => 0,
|
|
||||||
'showdate' => 0,
|
|
||||||
'listsep' => ', ',
|
|
||||||
];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
[$name, $value] = sexplode('=', $flag, 2, '');
|
|
||||||
$params[trim($name)] = trim(trim($value), '"'); // quotes can be use to keep whitespace
|
|
||||||
}
|
|
||||||
|
|
||||||
// separate path and pattern
|
|
||||||
$path = Path::cleanPath($path, false);
|
|
||||||
$parts = explode('/', $path);
|
|
||||||
$pattern = array_pop($parts);
|
|
||||||
$base = implode('/', $parts) . '/';
|
|
||||||
|
|
||||||
return [$base, $pattern, $params];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create output
|
|
||||||
*/
|
|
||||||
public function render($format, Doku_Renderer $renderer, $data)
|
|
||||||
{
|
|
||||||
[$base, $pattern, $params] = $data;
|
|
||||||
|
|
||||||
if ($format != 'xhtml' && $format != 'odt') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable caching
|
|
||||||
if ($params['cache'] === 0) {
|
|
||||||
$renderer->nocache();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pathHelper = new Path($this->getConf('paths'));
|
|
||||||
$pathInfo = $pathHelper->getPathInfo($base);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$crawler = new Crawler($this->getConf('extensions'));
|
|
||||||
$crawler->setSortBy($params['sort']);
|
|
||||||
$crawler->setSortReverse($params['order'] === 'desc');
|
|
||||||
|
|
||||||
$result = $crawler->crawl(
|
|
||||||
$pathInfo['root'],
|
|
||||||
$pathInfo['local'],
|
|
||||||
$pattern,
|
|
||||||
$params['recursive'],
|
|
||||||
$params['titlefile']
|
|
||||||
);
|
|
||||||
|
|
||||||
// if we got nothing back, display a message
|
|
||||||
if ($result == []) {
|
|
||||||
$renderer->cdata('[n/a: ' . $this->getLang('error_nomatch') . ']');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $result);
|
|
||||||
|
|
||||||
switch ($params['style']) {
|
|
||||||
case 'list':
|
|
||||||
case 'olist':
|
|
||||||
$output->renderAsList($params);
|
|
||||||
break;
|
|
||||||
case 'table':
|
|
||||||
$output->renderAsTable($params);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
287
syntax/AbstractSyntax.php
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\Crawler;
|
||||||
|
use dokuwiki\plugin\luxtools\Output;
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
use dokuwiki\plugin\luxtools\PageLinkTrait;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Abstract base class for file-listing syntax handlers.
|
||||||
|
*
|
||||||
|
* Provides shared functionality for directory, files, and images syntax.
|
||||||
|
*/
|
||||||
|
if (!class_exists('syntax_plugin_luxtools_abstract', false)) {
|
||||||
|
abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
use PageLinkTrait;
|
||||||
|
/**
|
||||||
|
* Returns the syntax keyword (e.g., 'files', 'directory', 'images').
|
||||||
|
* Used for pattern matching and plugin registration.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
abstract protected function getSyntaxKeyword(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default parameters for this syntax handler.
|
||||||
|
* Will be merged with the base defaults.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function getDefaultParams(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the parsed path/pattern. Override to customize path handling.
|
||||||
|
*
|
||||||
|
* @param string $path The cleaned path from the syntax
|
||||||
|
* @return array Data to be passed to render()
|
||||||
|
*/
|
||||||
|
abstract protected function processPath(string $path): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the actual rendering. Override in each handler.
|
||||||
|
*
|
||||||
|
* @param string $format Output format (xhtml, odt, etc.)
|
||||||
|
* @param \Doku_Renderer $renderer The renderer instance
|
||||||
|
* @param array $pathData Path data from processPath()
|
||||||
|
* @param array $params Parsed parameters
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
abstract protected function doRender(string $format, \Doku_Renderer $renderer, array $pathData, array $params): bool;
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
return 222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$keyword = $this->getSyntaxKeyword();
|
||||||
|
$pattern = '\{\{' . $keyword . '>.+?\}\}';
|
||||||
|
$this->Lexer->addSpecialPattern($pattern, $mode, 'plugin_luxtools_' . $keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
$keyword = $this->getSyntaxKeyword();
|
||||||
|
$match = substr($match, strlen('{{' . $keyword . '>'), -2);
|
||||||
|
[$path, $flags] = array_pad(explode('&', $match, 2), 2, '');
|
||||||
|
|
||||||
|
$params = $this->parseFlags($flags);
|
||||||
|
$pathData = $this->processPath($path);
|
||||||
|
|
||||||
|
return ['pathData' => $pathData, 'params' => $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
if ($data === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DokuWiki caches the $data returned by handle(). When the shape changes
|
||||||
|
// between plugin versions, old cached instructions can feed unexpected
|
||||||
|
// structures (or even null) into render(). Be defensive to avoid fatals.
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($format !== 'xhtml' && $format !== 'odt') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format: ['pathData' => array, 'params' => array]
|
||||||
|
// Back-compat: numeric list or older associative keys.
|
||||||
|
$pathData = $data['pathData'] ?? ($data[0] ?? null);
|
||||||
|
$params = $data['params'] ?? ($data[1] ?? null);
|
||||||
|
|
||||||
|
// Some older cached instructions may pass the raw path as string.
|
||||||
|
if (is_string($pathData)) {
|
||||||
|
$pathData = $this->processPath($pathData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If params are missing (older cache), fall back to defaults.
|
||||||
|
if (!is_array($params)) {
|
||||||
|
$params = $this->parseFlags('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pathData was inlined (e.g. ['base'=>..,'pattern'=>..,'params'=>..]), extract it.
|
||||||
|
if (!is_array($pathData)) {
|
||||||
|
$candidate = $data;
|
||||||
|
unset($candidate['params']);
|
||||||
|
if (isset($candidate['base']) || isset($candidate['pattern']) || isset($candidate['path'])) {
|
||||||
|
$pathData = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($pathData)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable caching if requested
|
||||||
|
if (($params['cache'] ?? 0) == 0) {
|
||||||
|
$renderer->nocache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->doRender($format, $renderer, $pathData, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse flags string into parameters array.
|
||||||
|
*
|
||||||
|
* @param string $flags The flags string from the syntax
|
||||||
|
* @return array Parsed parameters
|
||||||
|
*/
|
||||||
|
protected function parseFlags(string $flags): array
|
||||||
|
{
|
||||||
|
// Parse default table columns setting.
|
||||||
|
// Format: comma-separated list of column names (name, size, date).
|
||||||
|
$tableColumns = strtolower(trim((string)$this->getConf('default_tablecolumns')));
|
||||||
|
$defaultShowSize = str_contains($tableColumns, 'size') ? 1 : (int)$this->getConf('default_showsize');
|
||||||
|
$defaultShowDate = str_contains($tableColumns, 'date') ? 1 : (int)$this->getConf('default_showdate');
|
||||||
|
|
||||||
|
// Base defaults shared by all handlers
|
||||||
|
$baseDefaults = [
|
||||||
|
'sort' => (string)$this->getConf('default_sort'),
|
||||||
|
'order' => (string)$this->getConf('default_order'),
|
||||||
|
'tableheader' => (int)$this->getConf('default_tableheader'),
|
||||||
|
'foldersfirst' => (int)$this->getConf('default_foldersfirst'),
|
||||||
|
'recursive' => (int)$this->getConf('default_recursive'),
|
||||||
|
'titlefile' => (string)$this->getConf('default_titlefile'),
|
||||||
|
'cache' => (int)$this->getConf('default_cache'),
|
||||||
|
'randlinks' => (int)$this->getConf('default_randlinks'),
|
||||||
|
'showsize' => $defaultShowSize,
|
||||||
|
'showdate' => $defaultShowDate,
|
||||||
|
'maxheight' => (int)$this->getConf('default_maxheight'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Merge with handler-specific defaults
|
||||||
|
$params = array_merge($baseDefaults, $this->getDefaultParams());
|
||||||
|
|
||||||
|
// Legacy (advanced): allow additional default flags in the old combined string.
|
||||||
|
// This is not exposed in the admin UI anymore, but existing installs may
|
||||||
|
// still have it in conf/local.php.
|
||||||
|
$legacyDefaults = trim((string)$this->getConf('defaults'));
|
||||||
|
|
||||||
|
// Combine default flags and provided flags.
|
||||||
|
$flags = ($legacyDefaults !== '' ? ($legacyDefaults . '&') : '') . $flags;
|
||||||
|
$flagList = explode('&', $flags);
|
||||||
|
|
||||||
|
foreach ($flagList as $flag) {
|
||||||
|
if (empty(trim($flag))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$name, $value] = sexplode('=', $flag, 2, '');
|
||||||
|
$params[trim($name)] = trim(trim($value), '"'); // quotes can be used to keep whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path info with error handling.
|
||||||
|
*
|
||||||
|
* @param string $basePath The base path to resolve
|
||||||
|
* @param \Doku_Renderer $renderer The renderer for error output
|
||||||
|
* @return array|false Path info array or false on error
|
||||||
|
*/
|
||||||
|
protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
if ($blobsRoot === '' && $this->isBlobsPath($basePath)) {
|
||||||
|
$this->renderPageNotLinked($renderer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$pathHelper = $this->createPathHelperWithBlobs($blobsRoot);
|
||||||
|
return $pathHelper->getPathInfo($basePath);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->renderError($renderer, 'error_outsidejail');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and configure a Crawler instance.
|
||||||
|
*
|
||||||
|
* @param array $params The parameters array
|
||||||
|
* @return Crawler
|
||||||
|
*/
|
||||||
|
protected function createCrawler(array $params): Crawler
|
||||||
|
{
|
||||||
|
$crawler = new Crawler($this->getConf('extensions'));
|
||||||
|
$crawler->setSortBy($params['sort']);
|
||||||
|
$crawler->setSortReverse($params['order'] === 'desc');
|
||||||
|
$crawler->setFoldersFirst(($params['foldersfirst'] ?? 0) != 0);
|
||||||
|
return $crawler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an error message.
|
||||||
|
*
|
||||||
|
* @param \Doku_Renderer $renderer The renderer
|
||||||
|
* @param string $langKey The language key for the error message
|
||||||
|
*/
|
||||||
|
protected function renderError(\Doku_Renderer $renderer, string $langKey): void
|
||||||
|
{
|
||||||
|
$renderer->cdata('[n/a: ' . $this->getLang($langKey) . ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a muted empty-state message (used when a listing has no results).
|
||||||
|
*
|
||||||
|
* @param \Doku_Renderer $renderer
|
||||||
|
* @param string $langKey
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function renderEmptyState(\Doku_Renderer $renderer, string $langKey): void
|
||||||
|
{
|
||||||
|
$text = (string)$this->getLang($langKey);
|
||||||
|
|
||||||
|
if ($renderer instanceof \Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<div class="luxtools-plugin"><div class="luxtools-empty">' . hsc($text) . '</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->cdata($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate a path into base directory and pattern.
|
||||||
|
*
|
||||||
|
* @param string $path The full path with pattern
|
||||||
|
* @return array [base, pattern]
|
||||||
|
*/
|
||||||
|
protected function separatePathAndPattern(string $path): array
|
||||||
|
{
|
||||||
|
$path = Path::cleanPath($path, false);
|
||||||
|
$parts = explode('/', $path);
|
||||||
|
$pattern = array_pop($parts);
|
||||||
|
$base = implode('/', $parts) . '/';
|
||||||
|
return [$base, $pattern];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
147
syntax/calendar.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Calendar widget syntax.
|
||||||
|
*
|
||||||
|
* Syntax:
|
||||||
|
* - {{calendar>}} current month
|
||||||
|
* - {{calendar>YYYY-MM}} specific month
|
||||||
|
* - {{calendar>YYYY-MM&base=chronological}} custom base namespace (optional)
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_calendar extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
return 224;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{calendar>.*?\}\}', $mode, 'plugin_luxtools_calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
$match = substr($match, strlen('{{calendar>'), -2);
|
||||||
|
[$target, $flags] = array_pad(explode('&', $match, 2), 2, '');
|
||||||
|
|
||||||
|
$target = trim((string)$target);
|
||||||
|
$params = $this->parseFlags($flags);
|
||||||
|
$baseNs = $params['base'] ?? 'chronological';
|
||||||
|
|
||||||
|
$resolved = $this->resolveTargetMonth($target);
|
||||||
|
if ($resolved === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'calendar_err_badmonth',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'year' => $resolved['year'],
|
||||||
|
'month' => $resolved['month'],
|
||||||
|
'base' => $baseNs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
if ($data === false || !is_array($data)) return false;
|
||||||
|
if ($format !== 'xhtml') return false;
|
||||||
|
if (!($renderer instanceof Doku_Renderer_xhtml)) return false;
|
||||||
|
|
||||||
|
if (!($data['ok'] ?? false)) {
|
||||||
|
$message = (string)$this->getLang((string)($data['error'] ?? 'calendar_err_badmonth'));
|
||||||
|
if ($message === '') $message = 'Invalid calendar month. Use YYYY-MM.';
|
||||||
|
$renderer->doc .= '<div class="luxtools-plugin luxtools-calendar"><div class="luxtools-empty">' . hsc($message) . '</div></div>';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int)$data['year'];
|
||||||
|
$month = (int)$data['month'];
|
||||||
|
$baseNs = (string)$data['base'];
|
||||||
|
|
||||||
|
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $flags
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
protected function parseFlags(string $flags): array
|
||||||
|
{
|
||||||
|
$params = [];
|
||||||
|
foreach (explode('&', $flags) as $flag) {
|
||||||
|
if (trim($flag) === '') continue;
|
||||||
|
[$name, $value] = array_pad(explode('=', $flag, 2), 2, '');
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
$value = trim($value);
|
||||||
|
if ($name === '') continue;
|
||||||
|
$params[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($params['base']) || trim($params['base']) === '') {
|
||||||
|
$params['base'] = 'chronological';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve target string to year/month.
|
||||||
|
*
|
||||||
|
* Accepted formats:
|
||||||
|
* - '' (current month)
|
||||||
|
* - YYYY-MM
|
||||||
|
*
|
||||||
|
* @param string $target
|
||||||
|
* @return array{year:int,month:int}|null
|
||||||
|
*/
|
||||||
|
protected function resolveTargetMonth(string $target): ?array
|
||||||
|
{
|
||||||
|
if ($target === '') {
|
||||||
|
return [
|
||||||
|
'year' => (int)date('Y'),
|
||||||
|
'month' => (int)date('m'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^(\d{4})-(\d{2})$/', $target, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int)$matches[1];
|
||||||
|
$month = (int)$matches[2];
|
||||||
|
if ($year < 1) return null;
|
||||||
|
if ($month < 1 || $month > 12) return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
129
syntax/directory.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\Output;
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/AbstractSyntax.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Directory syntax.
|
||||||
|
*
|
||||||
|
* Lists the direct children (folders and files) of a given path.
|
||||||
|
* Always renders as a table.
|
||||||
|
* Also accepts the 'files' keyword for backwards compatibility with glob patterns.
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_directory extends syntax_plugin_luxtools_abstract
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function getDefaultParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Directory listings should group folders before files by default.
|
||||||
|
'foldersfirst' => 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function getSyntaxKeyword(): string
|
||||||
|
{
|
||||||
|
return 'directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
// Accept both {{directory>...}} and {{files>...}} for backwards compatibility
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{directory>.+?\}\}', $mode, 'plugin_luxtools_directory');
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{files>.+?\}\}', $mode, 'plugin_luxtools_directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
// Detect which keyword was used
|
||||||
|
$keyword = 'directory';
|
||||||
|
if (str_starts_with($match, '{{files>')) {
|
||||||
|
$keyword = 'files';
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = substr($match, strlen('{{' . $keyword . '>'), -2);
|
||||||
|
[$path, $flags] = array_pad(explode('&', $match, 2), 2, '');
|
||||||
|
|
||||||
|
$params = $this->parseFlags($flags);
|
||||||
|
$pathData = $this->processPath($path);
|
||||||
|
|
||||||
|
// Store the original keyword to determine processing mode
|
||||||
|
$pathData['isGlobPattern'] = ($keyword === 'files');
|
||||||
|
|
||||||
|
return ['pathData' => $pathData, 'params' => $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function processPath(string $path): array
|
||||||
|
{
|
||||||
|
// Check if path contains glob characters (*, ?, [, ])
|
||||||
|
$hasGlob = (str_contains($path, '*') || str_contains($path, '?') ||
|
||||||
|
str_contains($path, '[') || str_contains($path, ']'));
|
||||||
|
|
||||||
|
if ($hasGlob) {
|
||||||
|
// Process as glob pattern (old files syntax)
|
||||||
|
[$base, $pattern] = $this->separatePathAndPattern($path);
|
||||||
|
return ['base' => $base, 'pattern' => $pattern, 'isGlobPattern' => true];
|
||||||
|
} else {
|
||||||
|
// Process as directory path
|
||||||
|
$path = Path::cleanPath($path, true);
|
||||||
|
return ['path' => $path, 'isGlobPattern' => false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function doRender(string $format, \Doku_Renderer $renderer, array $pathData, array $params): bool
|
||||||
|
{
|
||||||
|
$isGlobPattern = $pathData['isGlobPattern'] ?? false;
|
||||||
|
|
||||||
|
if ($isGlobPattern && isset($pathData['base'], $pathData['pattern'])) {
|
||||||
|
// Old files syntax behavior: crawl with glob pattern
|
||||||
|
$pathInfo = $this->getPathInfoSafe($pathData['base'], $renderer);
|
||||||
|
if ($pathInfo === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$crawler = $this->createCrawler($params);
|
||||||
|
$result = $crawler->crawl(
|
||||||
|
$pathInfo['root'],
|
||||||
|
$pathInfo['local'],
|
||||||
|
$pathData['pattern'],
|
||||||
|
$params['recursive'],
|
||||||
|
$params['titlefile']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass the base directory as openlocation so the "Open Location" link is displayed.
|
||||||
|
$params['openlocation'] = $pathInfo['root'] . $pathInfo['local'];
|
||||||
|
|
||||||
|
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $result, $this);
|
||||||
|
$output->renderAsTable($params);
|
||||||
|
} else {
|
||||||
|
// Normal directory listing behavior
|
||||||
|
$pathInfo = $this->getPathInfoSafe($pathData['path'], $renderer);
|
||||||
|
if ($pathInfo === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide the current directory path so Output can render the "Open Location" link.
|
||||||
|
$params['openlocation'] = $pathInfo['root'] . $pathInfo['local'];
|
||||||
|
|
||||||
|
$crawler = $this->createCrawler($params);
|
||||||
|
$items = $crawler->listDirectory(
|
||||||
|
$pathInfo['root'],
|
||||||
|
$pathInfo['local'],
|
||||||
|
$params['titlefile']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render the table even if empty so the "Open Location" link is displayed.
|
||||||
|
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items, $this);
|
||||||
|
$output->renderAsFlatTable($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
282
syntax/grouping.php
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Grouping wrapper syntax.
|
||||||
|
*
|
||||||
|
* Wraps multiple blocks (typically {{image>...}}) and applies compact layout
|
||||||
|
* without adding visual box styling of its own.
|
||||||
|
*
|
||||||
|
* Syntax:
|
||||||
|
* <grouping layout="flex" gap="0" justify="start" align="start">
|
||||||
|
* {{image>...}}
|
||||||
|
* {{image>...}}
|
||||||
|
* </grouping>
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_grouping extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'container';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
// Slightly after image syntax
|
||||||
|
return 316;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getAllowedTypes()
|
||||||
|
{
|
||||||
|
return ['container', 'substition', 'protected', 'disabled', 'formatting'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addEntryPattern('<grouping(?:\s+[^>]*)?>(?=.*</grouping>)', $mode, 'plugin_luxtools_grouping');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function postConnect()
|
||||||
|
{
|
||||||
|
$this->Lexer->addExitPattern('</grouping>', 'plugin_luxtools_grouping');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
if ($state === DOKU_LEXER_ENTER) {
|
||||||
|
$parsed = $this->parseOpeningTag($match);
|
||||||
|
return [
|
||||||
|
'state' => $state,
|
||||||
|
'params' => $parsed['params'],
|
||||||
|
'unknown' => $parsed['unknown'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === DOKU_LEXER_UNMATCHED) {
|
||||||
|
return [
|
||||||
|
'state' => $state,
|
||||||
|
'text' => $match,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => $state,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
if (!is_array($data) || !isset($data['state'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = (int)$data['state'];
|
||||||
|
|
||||||
|
if ($format !== 'xhtml') {
|
||||||
|
if ($state === DOKU_LEXER_UNMATCHED && isset($data['text'])) {
|
||||||
|
$renderer->cdata((string)$data['text']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!($renderer instanceof Doku_Renderer_xhtml)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === DOKU_LEXER_ENTER) {
|
||||||
|
$params = isset($data['params']) && is_array($data['params']) ? $data['params'] : $this->getDefaultParams();
|
||||||
|
$unknown = isset($data['unknown']) && is_array($data['unknown']) ? $data['unknown'] : [];
|
||||||
|
|
||||||
|
$layout = ($params['layout'] === 'flex') ? 'flex' : 'grid';
|
||||||
|
$cols = (int)$params['cols'];
|
||||||
|
if ($cols < 1) {
|
||||||
|
$cols = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gap = (string)$params['gap'];
|
||||||
|
if (!$this->isValidCssLength($gap)) {
|
||||||
|
$gap = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
$justify = (string)$params['justify'];
|
||||||
|
if (!$this->isValidJustify($justify)) {
|
||||||
|
$justify = 'start';
|
||||||
|
}
|
||||||
|
|
||||||
|
$align = (string)$params['align'];
|
||||||
|
if (!$this->isValidAlign($align)) {
|
||||||
|
$align = 'start';
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->doc .= '<div class="luxtools-grouping luxtools-grouping--' . hsc($layout) . '"'
|
||||||
|
. ' style="--luxtools-grouping-cols: ' . $cols
|
||||||
|
. '; --luxtools-grouping-gap: ' . hsc($gap)
|
||||||
|
. '; --luxtools-grouping-justify: ' . hsc($justify)
|
||||||
|
. '; --luxtools-grouping-align: ' . hsc($align)
|
||||||
|
. ';">';
|
||||||
|
|
||||||
|
if ($unknown !== []) {
|
||||||
|
$renderer->doc .= '<span class="luxtools-grouping-warning">'
|
||||||
|
. hsc('[grouping: unknown option(s): ' . implode(', ', $unknown) . ']')
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === DOKU_LEXER_UNMATCHED) {
|
||||||
|
if (isset($data['text'])) {
|
||||||
|
$renderer->cdata((string)$data['text']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === DOKU_LEXER_EXIT) {
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse opening <grouping ...> tag attributes.
|
||||||
|
*
|
||||||
|
* Supports attribute style only, e.g.
|
||||||
|
* layout="grid" cols="3" gap="8px" justify="center" align="stretch".
|
||||||
|
* Unknown or invalid values are ignored and defaults are used.
|
||||||
|
*
|
||||||
|
* @param string $match
|
||||||
|
* @return array{params:array{layout:string,cols:int,gap:string,justify:string,align:string},unknown:array<int,string>}
|
||||||
|
*/
|
||||||
|
protected function parseOpeningTag(string $match): array
|
||||||
|
{
|
||||||
|
$params = $this->getDefaultParams();
|
||||||
|
$unknown = [];
|
||||||
|
|
||||||
|
if (!preg_match('/^<grouping\b(.*?)>$/is', $match, $tagMatch)) {
|
||||||
|
return ['params' => $params, 'unknown' => $unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrPart = (string)$tagMatch[1];
|
||||||
|
if ($attrPart === '') {
|
||||||
|
return ['params' => $params, 'unknown' => $unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all('/([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*(["\'])(.*?)\2/s', $attrPart, $attrMatches, PREG_SET_ORDER)) {
|
||||||
|
foreach ($attrMatches as $item) {
|
||||||
|
$name = strtolower(trim((string)$item[1]));
|
||||||
|
$value = trim((string)$item[3]);
|
||||||
|
|
||||||
|
if ($name === 'layout') {
|
||||||
|
$value = strtolower($value);
|
||||||
|
if (in_array($value, ['grid', 'flex'], true)) {
|
||||||
|
$params['layout'] = $value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'cols') {
|
||||||
|
if (preg_match('/^\d+$/', $value)) {
|
||||||
|
$cols = (int)$value;
|
||||||
|
if ($cols > 0) {
|
||||||
|
$params['cols'] = min($cols, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'gap') {
|
||||||
|
if ($this->isValidCssLength($value)) {
|
||||||
|
$params['gap'] = $value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'justify') {
|
||||||
|
$value = strtolower($value);
|
||||||
|
if ($this->isValidJustify($value)) {
|
||||||
|
$params['justify'] = $value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'align') {
|
||||||
|
$value = strtolower($value);
|
||||||
|
if ($this->isValidAlign($value)) {
|
||||||
|
$params['align'] = $value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unknown[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($unknown !== []) {
|
||||||
|
$unknown = array_values(array_unique($unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['params' => $params, 'unknown' => $unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{layout:string,cols:int,gap:string,justify:string,align:string}
|
||||||
|
*/
|
||||||
|
protected function getDefaultParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'layout' => 'flex',
|
||||||
|
'cols' => 2,
|
||||||
|
'gap' => '0',
|
||||||
|
'justify' => 'start',
|
||||||
|
'align' => 'start',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a simple CSS length token.
|
||||||
|
*
|
||||||
|
* Allows "0" and common explicit units used in docs/examples.
|
||||||
|
*/
|
||||||
|
protected function isValidCssLength(string $value): bool
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '0') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool)preg_match('/^(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|%|vw|vh)$/', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate justify-content compatible values.
|
||||||
|
*/
|
||||||
|
protected function isValidJustify(string $value): bool
|
||||||
|
{
|
||||||
|
return in_array($value, ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate align-items compatible values.
|
||||||
|
*/
|
||||||
|
protected function isValidAlign(string $value): bool
|
||||||
|
{
|
||||||
|
return in_array($value, ['start', 'center', 'end', 'stretch', 'baseline'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
syntax/image.php
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
use dokuwiki\plugin\luxtools\PageLinkTrait;
|
||||||
|
use dokuwiki\plugin\luxtools\ThumbnailHelper;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Image syntax.
|
||||||
|
*
|
||||||
|
* Renders a single image in an imagebox (similar to Wikipedia-style image boxes).
|
||||||
|
* Syntax: {{image>/path/to/image.jpg|Caption text}}
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_image extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
use PageLinkTrait;
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
return 315; // Same as imagebox plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{image>.+?\}\}', $mode, 'plugin_luxtools_image');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, \Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
// Remove the leading {{image> and trailing }}
|
||||||
|
$match = substr($match, strlen('{{image>'), -2);
|
||||||
|
|
||||||
|
// Split by | into: path, caption, options
|
||||||
|
// Format: {{image>path|caption|options}}
|
||||||
|
$parts = explode('|', $match, 3);
|
||||||
|
$pathPart = trim($parts[0]);
|
||||||
|
$caption = isset($parts[1]) ? trim($parts[1]) : '';
|
||||||
|
$optionStr = isset($parts[2]) ? trim($parts[2]) : '';
|
||||||
|
|
||||||
|
// Parse options from third part (e.g., "200x150&right")
|
||||||
|
$width = null;
|
||||||
|
$height = null;
|
||||||
|
$align = null;
|
||||||
|
|
||||||
|
if ($optionStr !== '') {
|
||||||
|
$optionParts = explode('&', $optionStr);
|
||||||
|
foreach ($optionParts as $param) {
|
||||||
|
$param = trim($param);
|
||||||
|
if ($param === '') continue;
|
||||||
|
|
||||||
|
if (in_array($param, ['left', 'right', 'center'], true)) {
|
||||||
|
$align = $param;
|
||||||
|
} elseif (preg_match('/^(\d+)(?:x(\d+))?$/', $param, $m)) {
|
||||||
|
$width = (int)$m[1];
|
||||||
|
if (isset($m[2]) && $m[2] !== '') {
|
||||||
|
$height = (int)$m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$isRemote = ThumbnailHelper::isRemoteUrl($pathPart);
|
||||||
|
$path = $isRemote ? $pathPart : Path::cleanPath($pathPart, false);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'is_remote' => $isRemote,
|
||||||
|
'caption' => $caption,
|
||||||
|
'align' => $align,
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, \Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
if ($data === false || !is_array($data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($format !== 'xhtml') {
|
||||||
|
// For non-XHTML formats, render caption as text if available.
|
||||||
|
if (!empty($data['caption'])) {
|
||||||
|
$renderer->cdata('[Image: ' . $data['caption'] . ']');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply default settings if not explicitly specified
|
||||||
|
if ($data['width'] === null) {
|
||||||
|
$data['width'] = (int)$this->getConf('default_image_width');
|
||||||
|
if ($data['width'] <= 0) $data['width'] = 250;
|
||||||
|
}
|
||||||
|
if ($data['align'] === null) {
|
||||||
|
$data['align'] = (string)$this->getConf('default_image_align');
|
||||||
|
if (!in_array($data['align'], ['left', 'right', 'center'], true)) {
|
||||||
|
$data['align'] = 'right';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['is_remote'])) {
|
||||||
|
if (empty($data['path']) || !ThumbnailHelper::isRemoteUrl($data['path'])) {
|
||||||
|
$renderer->cdata('[n/a: Invalid URL]');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote images: link directly, no proxying or thumbnailing
|
||||||
|
$thumb = [
|
||||||
|
'url' => $data['path'],
|
||||||
|
'isFinal' => true,
|
||||||
|
'thumbUrl' => $data['path'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->renderImageBox($renderer, $thumb, $data['path'], $data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
if ($blobsRoot === '' && $this->isBlobsPath($data['path'] ?? '')) {
|
||||||
|
$this->renderPageNotLinked($renderer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathHelper = $this->createPathHelperWithBlobs($blobsRoot);
|
||||||
|
// Use addTrailingSlash=false since this is a file path, not a directory
|
||||||
|
$pathInfo = $pathHelper->getPathInfo($data['path'], false);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = $pathInfo['root'] . $pathInfo['local'];
|
||||||
|
|
||||||
|
// Verify the file exists and is an image
|
||||||
|
if (!is_file($fullPath)) {
|
||||||
|
$renderer->cdata('[n/a: File not found]');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an image
|
||||||
|
try {
|
||||||
|
[, $mime,] = mimetype($fullPath, false);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$mime = null;
|
||||||
|
}
|
||||||
|
if (!is_string($mime) || !str_starts_with($mime, 'image/')) {
|
||||||
|
$renderer->cdata('[n/a: Not an image]');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get thumbnail from helper - it handles everything
|
||||||
|
global $ID;
|
||||||
|
$placeholderId = trim((string)$this->getConf('thumb_placeholder'));
|
||||||
|
$thumb = ThumbnailHelper::getThumbnail(
|
||||||
|
$pathInfo['root'],
|
||||||
|
$pathInfo['local'],
|
||||||
|
$ID,
|
||||||
|
$data['width'] ?? 250,
|
||||||
|
$data['height'] ?? ($data['width'] ?? 250),
|
||||||
|
80,
|
||||||
|
$placeholderId !== '' ? $placeholderId : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build full-size URL for linking
|
||||||
|
$fullUrl = $this->buildImageUrl($pathInfo, null, null, false);
|
||||||
|
|
||||||
|
$this->renderImageBox($renderer, $thumb, $fullUrl, $data);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the file.php URL for a local image.
|
||||||
|
*
|
||||||
|
* @param array $pathInfo Path info array from Path helper
|
||||||
|
* @param int|null $width Optional width
|
||||||
|
* @param int|null $height Optional height
|
||||||
|
* @param bool $thumbnail Whether to generate a thumbnail
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function buildImageUrl(array $pathInfo, ?int $width, ?int $height, bool $thumbnail): string
|
||||||
|
{
|
||||||
|
global $ID;
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'root' => $pathInfo['root'],
|
||||||
|
'file' => $pathInfo['local'],
|
||||||
|
'id' => $ID,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($thumbnail && ($width !== null || $height !== null)) {
|
||||||
|
$params['thumb'] = 1;
|
||||||
|
$params['q'] = 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($width !== null) {
|
||||||
|
$params['w'] = $width;
|
||||||
|
}
|
||||||
|
if ($height !== null) {
|
||||||
|
$params['h'] = $height;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the imagebox HTML.
|
||||||
|
*
|
||||||
|
* @param \Doku_Renderer $renderer
|
||||||
|
* @param array $thumb Thumbnail info from ThumbnailHelper::getThumbnail()
|
||||||
|
* @param string $fullUrl URL for the full-size image (on click)
|
||||||
|
* @param array $data Parsed data from handle()
|
||||||
|
*/
|
||||||
|
protected function renderImageBox(\Doku_Renderer $renderer, array $thumb, string $fullUrl, array $data): void
|
||||||
|
{
|
||||||
|
$align = $data['align'] ?? 'right';
|
||||||
|
$caption = $data['caption'] ?? '';
|
||||||
|
$width = $data['width'];
|
||||||
|
$height = $data['height'];
|
||||||
|
|
||||||
|
// Alignment class
|
||||||
|
$alignClass = 'tright'; // default
|
||||||
|
if ($align === 'left') {
|
||||||
|
$alignClass = 'tleft';
|
||||||
|
} elseif ($align === 'center') {
|
||||||
|
$alignClass = 'tcenter';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build width style for the outer container
|
||||||
|
$outerStyle = '';
|
||||||
|
if ($width !== null) {
|
||||||
|
// Add a few pixels for border/padding
|
||||||
|
$outerStyle = ' style="width: ' . ($width + 10) . 'px;"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use thumbnail metadata from helper.
|
||||||
|
// JS loader expects data-src (same convention as gallery thumbnails).
|
||||||
|
$dataThumbAttr = $thumb['isFinal'] ? '' : ' data-src="' . hsc($thumb['thumbUrl']) . '"';
|
||||||
|
|
||||||
|
// Build image attributes
|
||||||
|
$imgAttrs = 'class="media luxtools-thumb" loading="lazy" decoding="async"';
|
||||||
|
if ($width !== null) {
|
||||||
|
$imgAttrs .= ' width="' . (int)$width . '"';
|
||||||
|
}
|
||||||
|
if ($height !== null) {
|
||||||
|
$imgAttrs .= ' height="' . (int)$height . '"';
|
||||||
|
}
|
||||||
|
$imgAttrs .= ' alt="' . hsc($caption) . '"';
|
||||||
|
|
||||||
|
/** @var \Doku_Renderer_xhtml $renderer */
|
||||||
|
$renderer->doc .= '<div class="luxtools-imagebox ' . $alignClass . '"' . $outerStyle . '>';
|
||||||
|
$renderer->doc .= '<div class="luxtools-imagebox-inner">';
|
||||||
|
|
||||||
|
// Image with link to full size
|
||||||
|
$renderer->doc .= '<a href="' . hsc($fullUrl) . '" class="media" target="_blank">';
|
||||||
|
$renderer->doc .= '<img src="' . hsc($thumb['url']) . '" ' . $imgAttrs . $dataThumbAttr . ' />';
|
||||||
|
$renderer->doc .= '</a>';
|
||||||
|
|
||||||
|
// Caption
|
||||||
|
if ($caption !== '') {
|
||||||
|
// Calculate max caption width
|
||||||
|
$captionStyle = '';
|
||||||
|
if ($width !== null) {
|
||||||
|
$captionStyle = ' style="max-width: ' . ($width - 6) . 'px;"';
|
||||||
|
}
|
||||||
|
$renderer->doc .= '<div class="luxtools-imagebox-caption"' . $captionStyle . '>';
|
||||||
|
$renderer->doc .= hsc($caption);
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
122
syntax/images.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\Output;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/AbstractSyntax.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Image gallery syntax.
|
||||||
|
*
|
||||||
|
* Renders a thumbnail gallery of images matching a glob pattern.
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_images extends syntax_plugin_luxtools_abstract
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function getSyntaxKeyword(): string
|
||||||
|
{
|
||||||
|
return 'images';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function getDefaultParams(): array
|
||||||
|
{
|
||||||
|
// Images syntax doesn't use some of the common params
|
||||||
|
return [
|
||||||
|
'tableheader' => null,
|
||||||
|
'showsize' => null,
|
||||||
|
'showdate' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function processPath(string $path): array
|
||||||
|
{
|
||||||
|
[$base, $pattern] = $this->separatePathAndPattern($path);
|
||||||
|
return ['base' => $base, 'pattern' => $pattern];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
protected function doRender(string $format, \Doku_Renderer $renderer, array $pathData, array $params): bool
|
||||||
|
{
|
||||||
|
$pathInfo = $this->getPathInfoSafe($pathData['base'], $renderer);
|
||||||
|
if ($pathInfo === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$crawler = $this->createCrawler($params);
|
||||||
|
$result = $crawler->crawl(
|
||||||
|
$pathInfo['root'],
|
||||||
|
$pathInfo['local'],
|
||||||
|
$pathData['pattern'],
|
||||||
|
$params['recursive'],
|
||||||
|
$params['titlefile']
|
||||||
|
);
|
||||||
|
|
||||||
|
$items = $this->flattenResultTree($result);
|
||||||
|
$items = $this->filterImages($items);
|
||||||
|
|
||||||
|
if ($items == []) {
|
||||||
|
$this->renderEmptyState($renderer, 'empty_images');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images syntax only supports XHTML format (gallery rendering)
|
||||||
|
if ($format !== 'xhtml') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items, $this);
|
||||||
|
$output->renderAsGallery($params);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the crawl result tree into a list of file items.
|
||||||
|
*
|
||||||
|
* @param array $items
|
||||||
|
* @param string $prefix
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function flattenResultTree($items, $prefix = '')
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($items as $file) {
|
||||||
|
if ($file['children'] !== false) {
|
||||||
|
$result = array_merge(
|
||||||
|
$result,
|
||||||
|
$this->flattenResultTree($file['children'], $prefix . $file['name'] . '/')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$file['name'] = $prefix . $file['name'];
|
||||||
|
$result[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only image files.
|
||||||
|
*
|
||||||
|
* @param array $items
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function filterImages($items)
|
||||||
|
{
|
||||||
|
$images = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (!isset($item['path']) || !is_string($item['path'])) continue;
|
||||||
|
if (!is_file($item['path'])) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
[, $mime,] = mimetype($item['path'], false);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($mime) && str_starts_with($mime, 'image/')) {
|
||||||
|
$images[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $images;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
syntax/open.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\PageLinkTrait;
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Open local path syntax.
|
||||||
|
*
|
||||||
|
* Renders an inline link. Clicking it triggers client-side JS that attempts
|
||||||
|
* to open the configured path in the default file manager (best-effort).
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_open extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
use PageLinkTrait;
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
// inline
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
return 222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{open>.+?\}\}', $mode, 'plugin_luxtools_open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
$match = substr($match, strlen('{{open>'), -2);
|
||||||
|
[$path, $caption] = array_pad(explode('|', $match, 2), 2, '');
|
||||||
|
|
||||||
|
$path = trim($path);
|
||||||
|
$caption = trim($caption);
|
||||||
|
if ($caption === '') $caption = $path !== '' ? $path : 'Open';
|
||||||
|
|
||||||
|
// Basic scheme filtering to avoid javascript: style injections.
|
||||||
|
// Allow either file:// URLs, or plain paths (Windows/UNC/Linux style).
|
||||||
|
if (preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)) {
|
||||||
|
if (!str_starts_with(strtolower($path), 'file://')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$path, $caption];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
if ($data === false) return false;
|
||||||
|
[$path, $caption] = $data;
|
||||||
|
|
||||||
|
if ($format !== 'xhtml') {
|
||||||
|
// no meaningful representation in non-browser formats
|
||||||
|
$renderer->cdata($caption);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
$renderer->cdata('[n/a]');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve blobs alias to the linked folder (if available)
|
||||||
|
if ($this->isBlobsPath($path)) {
|
||||||
|
$blobsRoot = $this->resolveBlobsRoot();
|
||||||
|
if ($blobsRoot === '') {
|
||||||
|
$this->renderPageNotLinked($renderer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pathHelper = $this->createPathHelperWithBlobs($blobsRoot);
|
||||||
|
$resolvedPath = $path;
|
||||||
|
$isBlobsRoot = (rtrim($resolvedPath, '/') === 'blobs');
|
||||||
|
if ($isBlobsRoot) {
|
||||||
|
$resolvedPath = rtrim($resolvedPath, '/') . '/';
|
||||||
|
}
|
||||||
|
$pathInfo = $pathHelper->getPathInfo($resolvedPath, $isBlobsRoot);
|
||||||
|
$path = $pathInfo['path'];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map local paths back to their configured aliases before opening.
|
||||||
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)) {
|
||||||
|
try {
|
||||||
|
$pathHelper = $this->createPathHelper();
|
||||||
|
|
||||||
|
// If the input itself uses a configured path alias (legacy syntax
|
||||||
|
// like "alias/sub/path"), resolve it first so the emitted open
|
||||||
|
// path uses the new client-side alias format "ALIAS>relative".
|
||||||
|
$resolvedPath = $path;
|
||||||
|
try {
|
||||||
|
$pathInfo = $pathHelper->getPathInfo($path, false);
|
||||||
|
if (isset($pathInfo['path']) && is_string($pathInfo['path']) && $pathInfo['path'] !== '') {
|
||||||
|
$resolvedPath = $pathInfo['path'];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// keep original path as-is when it is not in configured roots
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $pathHelper->mapToAliasPath($resolvedPath);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// ignore mapping failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceUrl = trim((string)$this->getConf('open_service_url'));
|
||||||
|
$serviceToken = trim((string)$this->getConf('open_service_token'));
|
||||||
|
|
||||||
|
if (!($renderer instanceof \Doku_Renderer_xhtml)) {
|
||||||
|
$renderer->cdata($caption);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $conf;
|
||||||
|
/** @var \Doku_Renderer_xhtml $renderer */
|
||||||
|
|
||||||
|
// Render like a normal DokuWiki link with an icon in front.
|
||||||
|
// Use the same folder icon class as directory listings.
|
||||||
|
$link = [
|
||||||
|
'target' => $conf['target']['extern'],
|
||||||
|
'style' => '',
|
||||||
|
'pre' => '',
|
||||||
|
'suf' => '',
|
||||||
|
'name' => $caption,
|
||||||
|
'url' => '#',
|
||||||
|
'title' => $renderer->_xmlEntities($path),
|
||||||
|
'more' => '',
|
||||||
|
'class' => 'luxtools-open media mediafile mf_folder',
|
||||||
|
];
|
||||||
|
|
||||||
|
$link['more'] .= ' data-path="' . hsc($path) . '"';
|
||||||
|
if (!empty($conf['relnofollow'])) $link['more'] .= ' rel="nofollow"';
|
||||||
|
if ($serviceUrl !== '') $link['more'] .= ' data-service-url="' . hsc($serviceUrl) . '"';
|
||||||
|
if ($serviceToken !== '') $link['more'] .= ' data-service-token="' . hsc($serviceToken) . '"';
|
||||||
|
|
||||||
|
$renderer->doc .= $renderer->_formatLink($link);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
syntax/scratchpad.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\Path;
|
||||||
|
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Scratchpad syntax.
|
||||||
|
*
|
||||||
|
* Renders the contents of a configured file as wikitext and provides a minimal
|
||||||
|
* inline editor that saves directly to that file (no page revisions).
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_scratchpad extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
// After most formatting, similar to other luxtools syntaxes
|
||||||
|
return 223;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{scratchpad>.+?\}\}', $mode, 'plugin_luxtools_scratchpad');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
$match = substr($match, strlen('{{scratchpad>'), -2);
|
||||||
|
[$path,] = array_pad(explode('&', $match, 2), 2, '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => trim((string)$path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
if ($data === false) return false;
|
||||||
|
if (!is_array($data)) return false;
|
||||||
|
|
||||||
|
if ($format !== 'xhtml' && $format !== 'odt') return false;
|
||||||
|
|
||||||
|
// Always disable caching: the scratchpad is external to page revisions.
|
||||||
|
$renderer->nocache();
|
||||||
|
|
||||||
|
$rawPad = (string)($data['path'] ?? '');
|
||||||
|
if ($rawPad === '') {
|
||||||
|
$this->renderError($renderer, 'scratchpad_err_nopath');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathInfo = $this->getScratchpadPathInfoSafe($rawPad, $renderer);
|
||||||
|
if ($pathInfo === false) return true;
|
||||||
|
|
||||||
|
$filePath = (string)($pathInfo['path'] ?? '');
|
||||||
|
if ($filePath === '' || str_ends_with($filePath, '/')) {
|
||||||
|
$this->renderError($renderer, 'scratchpad_err_badpath');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never allow writing/reading within DokuWiki-controlled paths
|
||||||
|
if (Path::isWikiControlled(Path::cleanPath($filePath, false))) {
|
||||||
|
$this->renderError($renderer, 'error_outsidejail');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = '';
|
||||||
|
$exists = @is_file($filePath);
|
||||||
|
|
||||||
|
// If the scratchpad file is missing, render empty content. This allows
|
||||||
|
// creating the file via the inline editor without showing an error.
|
||||||
|
if ($exists) {
|
||||||
|
if (!@is_readable($filePath)) {
|
||||||
|
$this->renderError($renderer, 'scratchpad_err_unreadable');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$read = io_readFile($filePath, false);
|
||||||
|
if ($read === false) {
|
||||||
|
$this->renderError($renderer, 'scratchpad_err_unreadable');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$text = (string)$read;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($format === 'odt') {
|
||||||
|
$renderer->cdata($text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Doku_Renderer_xhtml $renderer */
|
||||||
|
$endpoint = DOKU_BASE . 'lib/plugins/luxtools/scratchpad.php';
|
||||||
|
|
||||||
|
$sectok = '';
|
||||||
|
if (function_exists('getSecurityToken')) {
|
||||||
|
$sectok = (string)getSecurityToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
global $ID;
|
||||||
|
$pageId = (string)$ID;
|
||||||
|
$canEdit = function_exists('auth_quickaclcheck') ? (auth_quickaclcheck($pageId) >= AUTH_EDIT) : false;
|
||||||
|
|
||||||
|
$renderer->doc .= '<div class="luxtools-plugin luxtools-scratchpad"'
|
||||||
|
. ' data-luxtools-scratchpad="1"'
|
||||||
|
. ' data-endpoint="' . hsc($endpoint) . '"'
|
||||||
|
. ' data-pad="' . hsc($rawPad) . '"'
|
||||||
|
. ' data-pageid="' . hsc($pageId) . '"'
|
||||||
|
. ' data-sectok="' . hsc($sectok) . '"'
|
||||||
|
. '>';
|
||||||
|
|
||||||
|
// Stable, template-friendly container around the scratchpad.
|
||||||
|
$renderer->doc .= '<div class="luxtools-scratchpad-frame">';
|
||||||
|
|
||||||
|
// Invisible container around the rendered scratchpad (templates can decorate).
|
||||||
|
$renderer->doc .= '<div class="luxtools-scratchpad-rendered">';
|
||||||
|
|
||||||
|
// Well-defined place for the edit button (templates can reposition/style).
|
||||||
|
$renderer->doc .= '<div class="luxtools-scratchpad-bar">';
|
||||||
|
|
||||||
|
// Always show the scratchpad name (alias) for context.
|
||||||
|
$renderer->doc .= '<span class="luxtools-scratchpad-name">' . hsc($rawPad) . '</span>';
|
||||||
|
|
||||||
|
if ($canEdit) {
|
||||||
|
$label = (string)$this->getLang('scratchpad_edit');
|
||||||
|
if ($label === '') $label = 'Edit';
|
||||||
|
$renderer->doc .= '<a href="#" class="luxtools-scratchpad-edit" title="' . hsc($label) . '" aria-label="' . hsc($label) . '">✎ edit</a>';
|
||||||
|
}
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
|
||||||
|
$renderer->doc .= '<div class="luxtools-scratchpad-view">';
|
||||||
|
$renderer->doc .= $this->renderWikitextFragment($text);
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
|
||||||
|
if ($canEdit) {
|
||||||
|
$renderer->doc .= '<div class="luxtools-scratchpad-editor" hidden>';
|
||||||
|
$renderer->doc .= '<textarea class="luxtools-scratchpad-text" rows="10" spellcheck="true"></textarea>';
|
||||||
|
$renderer->doc .= '<div class="luxtools-scratchpad-actions">'
|
||||||
|
. '<button type="button" class="button luxtools-scratchpad-save">' . hsc((string)$this->getLang('scratchpad_save') ?: 'Save') . '</button>'
|
||||||
|
. '<button type="button" class="button luxtools-scratchpad-cancel">' . hsc((string)$this->getLang('scratchpad_cancel') ?: 'Cancel') . '</button>'
|
||||||
|
. '<span class="luxtools-scratchpad-status" aria-live="polite"></span>'
|
||||||
|
. '</div>';
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->doc .= '</div>'; // .luxtools-scratchpad-rendered
|
||||||
|
$renderer->doc .= '</div>'; // .luxtools-scratchpad-frame
|
||||||
|
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function renderWikitextFragment(string $text): string
|
||||||
|
{
|
||||||
|
// Render wikitext to XHTML and return it as a string
|
||||||
|
$info = ['cache' => false];
|
||||||
|
$instructions = p_get_instructions($text);
|
||||||
|
return (string)p_render('xhtml', $instructions, $info);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function renderError(Doku_Renderer $renderer, string $langKey): void
|
||||||
|
{
|
||||||
|
$msg = (string)$this->getLang($langKey);
|
||||||
|
if ($msg === '') $msg = $langKey;
|
||||||
|
|
||||||
|
if ($renderer instanceof Doku_Renderer_xhtml) {
|
||||||
|
$renderer->doc .= '<div class="luxtools-plugin luxtools-scratchpad"><div class="luxtools-scratchpad-error">'
|
||||||
|
. hsc($msg)
|
||||||
|
. '</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->cdata('[n/a: ' . $msg . ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a scratchpad alias to its file path using the configured scratchpad_paths setting.
|
||||||
|
*
|
||||||
|
* @param string $pad
|
||||||
|
* @param Doku_Renderer $renderer
|
||||||
|
* @return array|false
|
||||||
|
*/
|
||||||
|
protected function getScratchpadPathInfoSafe(string $pad, Doku_Renderer $renderer)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$map = new ScratchpadMap((string)$this->getConf('scratchpad_paths'));
|
||||||
|
return [
|
||||||
|
'path' => $map->resolve($pad),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->renderError($renderer, 'scratchpad_err_unknown');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
temp-input-colors.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* TEMPORARY FIX
|
||||||
|
* Apply template color placeholders to all form controls.
|
||||||
|
* Remove this file once the template provides proper input styling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
color: @ini_text;
|
||||||
|
background-color: @ini_background;
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(.toolbutton) {
|
||||||
|
background: @ini_background;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
color: @ini_text;
|
||||||
|
background-color: @ini_background;
|
||||||
|
}
|
||||||
22
vendor/autoload.php
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload.php @generated by Composer
|
||||||
|
|
||||||
|
if (PHP_VERSION_ID < 50600) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('HTTP/1.1 500 Internal Server Error');
|
||||||
|
}
|
||||||
|
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||||
|
if (!ini_get('display_errors')) {
|
||||||
|
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||||
|
fwrite(STDERR, $err);
|
||||||
|
} elseif (!headers_sent()) {
|
||||||
|
echo $err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException($err);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/composer/autoload_real.php';
|
||||||
|
|
||||||
|
return ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc::getLoader();
|
||||||
119
vendor/bin/generate_vcards
vendored
Executable file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy PHP file generated by Composer
|
||||||
|
*
|
||||||
|
* This file includes the referenced bin path (../sabre/vobject/bin/generate_vcards)
|
||||||
|
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||||
|
*
|
||||||
|
* @generated
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer;
|
||||||
|
|
||||||
|
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||||
|
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||||
|
|
||||||
|
if (PHP_VERSION_ID < 80000) {
|
||||||
|
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class BinProxyWrapper
|
||||||
|
{
|
||||||
|
private $handle;
|
||||||
|
private $position;
|
||||||
|
private $realpath;
|
||||||
|
|
||||||
|
public function stream_open($path, $mode, $options, &$opened_path)
|
||||||
|
{
|
||||||
|
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||||
|
$opened_path = substr($path, 17);
|
||||||
|
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||||
|
$opened_path = $this->realpath;
|
||||||
|
$this->handle = fopen($this->realpath, $mode);
|
||||||
|
$this->position = 0;
|
||||||
|
|
||||||
|
return (bool) $this->handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_read($count)
|
||||||
|
{
|
||||||
|
$data = fread($this->handle, $count);
|
||||||
|
|
||||||
|
if ($this->position === 0) {
|
||||||
|
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->position += strlen($data);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_cast($castAs)
|
||||||
|
{
|
||||||
|
return $this->handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_close()
|
||||||
|
{
|
||||||
|
fclose($this->handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_lock($operation)
|
||||||
|
{
|
||||||
|
return $operation ? flock($this->handle, $operation) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_seek($offset, $whence)
|
||||||
|
{
|
||||||
|
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||||
|
$this->position = ftell($this->handle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_tell()
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_eof()
|
||||||
|
{
|
||||||
|
return feof($this->handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_stat()
|
||||||
|
{
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_set_option($option, $arg1, $arg2)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url_stat($path, $flags)
|
||||||
|
{
|
||||||
|
$path = substr($path, 17);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return stat($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||||
|
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||||
|
) {
|
||||||
|
return include("phpvfscomposer://" . __DIR__ . '/..'.'/sabre/vobject/bin/generate_vcards');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return include __DIR__ . '/..'.'/sabre/vobject/bin/generate_vcards';
|
||||||
119
vendor/bin/vobject
vendored
Executable file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy PHP file generated by Composer
|
||||||
|
*
|
||||||
|
* This file includes the referenced bin path (../sabre/vobject/bin/vobject)
|
||||||
|
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||||
|
*
|
||||||
|
* @generated
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer;
|
||||||
|
|
||||||
|
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||||
|
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||||
|
|
||||||
|
if (PHP_VERSION_ID < 80000) {
|
||||||
|
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class BinProxyWrapper
|
||||||
|
{
|
||||||
|
private $handle;
|
||||||
|
private $position;
|
||||||
|
private $realpath;
|
||||||
|
|
||||||
|
public function stream_open($path, $mode, $options, &$opened_path)
|
||||||
|
{
|
||||||
|
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||||
|
$opened_path = substr($path, 17);
|
||||||
|
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||||
|
$opened_path = $this->realpath;
|
||||||
|
$this->handle = fopen($this->realpath, $mode);
|
||||||
|
$this->position = 0;
|
||||||
|
|
||||||
|
return (bool) $this->handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_read($count)
|
||||||
|
{
|
||||||
|
$data = fread($this->handle, $count);
|
||||||
|
|
||||||
|
if ($this->position === 0) {
|
||||||
|
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->position += strlen($data);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_cast($castAs)
|
||||||
|
{
|
||||||
|
return $this->handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_close()
|
||||||
|
{
|
||||||
|
fclose($this->handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_lock($operation)
|
||||||
|
{
|
||||||
|
return $operation ? flock($this->handle, $operation) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_seek($offset, $whence)
|
||||||
|
{
|
||||||
|
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||||
|
$this->position = ftell($this->handle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_tell()
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_eof()
|
||||||
|
{
|
||||||
|
return feof($this->handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_stat()
|
||||||
|
{
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_set_option($option, $arg1, $arg2)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url_stat($path, $flags)
|
||||||
|
{
|
||||||
|
$path = substr($path, 17);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return stat($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||||
|
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||||
|
) {
|
||||||
|
return include("phpvfscomposer://" . __DIR__ . '/..'.'/sabre/vobject/bin/vobject');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return include __DIR__ . '/..'.'/sabre/vobject/bin/vobject';
|
||||||
579
vendor/composer/ClassLoader.php
vendored
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Composer.
|
||||||
|
*
|
||||||
|
* (c) Nils Adermann <naderman@naderman.de>
|
||||||
|
* Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Autoload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||||
|
*
|
||||||
|
* $loader = new \Composer\Autoload\ClassLoader();
|
||||||
|
*
|
||||||
|
* // register classes with namespaces
|
||||||
|
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||||
|
* $loader->add('Symfony', __DIR__.'/framework');
|
||||||
|
*
|
||||||
|
* // activate the autoloader
|
||||||
|
* $loader->register();
|
||||||
|
*
|
||||||
|
* // to enable searching the include path (eg. for PEAR packages)
|
||||||
|
* $loader->setUseIncludePath(true);
|
||||||
|
*
|
||||||
|
* In this example, if you try to use a class in the Symfony\Component
|
||||||
|
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||||
|
* the autoloader will first look for the class under the component/
|
||||||
|
* directory, and it will then fallback to the framework/ directory if not
|
||||||
|
* found before giving up.
|
||||||
|
*
|
||||||
|
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
* @see https://www.php-fig.org/psr/psr-0/
|
||||||
|
* @see https://www.php-fig.org/psr/psr-4/
|
||||||
|
*/
|
||||||
|
class ClassLoader
|
||||||
|
{
|
||||||
|
/** @var \Closure(string):void */
|
||||||
|
private static $includeFile;
|
||||||
|
|
||||||
|
/** @var string|null */
|
||||||
|
private $vendorDir;
|
||||||
|
|
||||||
|
// PSR-4
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string, int>>
|
||||||
|
*/
|
||||||
|
private $prefixLengthsPsr4 = array();
|
||||||
|
/**
|
||||||
|
* @var array<string, list<string>>
|
||||||
|
*/
|
||||||
|
private $prefixDirsPsr4 = array();
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private $fallbackDirsPsr4 = array();
|
||||||
|
|
||||||
|
// PSR-0
|
||||||
|
/**
|
||||||
|
* List of PSR-0 prefixes
|
||||||
|
*
|
||||||
|
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, list<string>>>
|
||||||
|
*/
|
||||||
|
private $prefixesPsr0 = array();
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private $fallbackDirsPsr0 = array();
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
private $useIncludePath = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private $classMap = array();
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
private $classMapAuthoritative = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, bool>
|
||||||
|
*/
|
||||||
|
private $missingClasses = array();
|
||||||
|
|
||||||
|
/** @var string|null */
|
||||||
|
private $apcuPrefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, self>
|
||||||
|
*/
|
||||||
|
private static $registeredLoaders = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $vendorDir
|
||||||
|
*/
|
||||||
|
public function __construct($vendorDir = null)
|
||||||
|
{
|
||||||
|
$this->vendorDir = $vendorDir;
|
||||||
|
self::initializeIncludeClosure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
public function getPrefixes()
|
||||||
|
{
|
||||||
|
if (!empty($this->prefixesPsr0)) {
|
||||||
|
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
public function getPrefixesPsr4()
|
||||||
|
{
|
||||||
|
return $this->prefixDirsPsr4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getFallbackDirs()
|
||||||
|
{
|
||||||
|
return $this->fallbackDirsPsr0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getFallbackDirsPsr4()
|
||||||
|
{
|
||||||
|
return $this->fallbackDirsPsr4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> Array of classname => path
|
||||||
|
*/
|
||||||
|
public function getClassMap()
|
||||||
|
{
|
||||||
|
return $this->classMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $classMap Class to filename map
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addClassMap(array $classMap)
|
||||||
|
{
|
||||||
|
if ($this->classMap) {
|
||||||
|
$this->classMap = array_merge($this->classMap, $classMap);
|
||||||
|
} else {
|
||||||
|
$this->classMap = $classMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-0 directories for a given prefix, either
|
||||||
|
* appending or prepending to the ones previously set for this prefix.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix
|
||||||
|
* @param list<string>|string $paths The PSR-0 root directories
|
||||||
|
* @param bool $prepend Whether to prepend the directories
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function add($prefix, $paths, $prepend = false)
|
||||||
|
{
|
||||||
|
$paths = (array) $paths;
|
||||||
|
if (!$prefix) {
|
||||||
|
if ($prepend) {
|
||||||
|
$this->fallbackDirsPsr0 = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->fallbackDirsPsr0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->fallbackDirsPsr0 = array_merge(
|
||||||
|
$this->fallbackDirsPsr0,
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $prefix[0];
|
||||||
|
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||||
|
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($prepend) {
|
||||||
|
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->prefixesPsr0[$first][$prefix]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||||
|
$this->prefixesPsr0[$first][$prefix],
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-4 directories for a given namespace, either
|
||||||
|
* appending or prepending to the ones previously set for this namespace.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||||
|
* @param list<string>|string $paths The PSR-4 base directories
|
||||||
|
* @param bool $prepend Whether to prepend the directories
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addPsr4($prefix, $paths, $prepend = false)
|
||||||
|
{
|
||||||
|
$paths = (array) $paths;
|
||||||
|
if (!$prefix) {
|
||||||
|
// Register directories for the root namespace.
|
||||||
|
if ($prepend) {
|
||||||
|
$this->fallbackDirsPsr4 = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->fallbackDirsPsr4
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->fallbackDirsPsr4 = array_merge(
|
||||||
|
$this->fallbackDirsPsr4,
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||||
|
// Register directories for a new namespace.
|
||||||
|
$length = strlen($prefix);
|
||||||
|
if ('\\' !== $prefix[$length - 1]) {
|
||||||
|
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||||
|
}
|
||||||
|
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||||
|
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||||
|
} elseif ($prepend) {
|
||||||
|
// Prepend directories for an already registered namespace.
|
||||||
|
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->prefixDirsPsr4[$prefix]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Append directories for an already registered namespace.
|
||||||
|
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||||
|
$this->prefixDirsPsr4[$prefix],
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-0 directories for a given prefix,
|
||||||
|
* replacing any others previously set for this prefix.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix
|
||||||
|
* @param list<string>|string $paths The PSR-0 base directories
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function set($prefix, $paths)
|
||||||
|
{
|
||||||
|
if (!$prefix) {
|
||||||
|
$this->fallbackDirsPsr0 = (array) $paths;
|
||||||
|
} else {
|
||||||
|
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-4 directories for a given namespace,
|
||||||
|
* replacing any others previously set for this namespace.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||||
|
* @param list<string>|string $paths The PSR-4 base directories
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setPsr4($prefix, $paths)
|
||||||
|
{
|
||||||
|
if (!$prefix) {
|
||||||
|
$this->fallbackDirsPsr4 = (array) $paths;
|
||||||
|
} else {
|
||||||
|
$length = strlen($prefix);
|
||||||
|
if ('\\' !== $prefix[$length - 1]) {
|
||||||
|
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||||
|
}
|
||||||
|
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||||
|
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns on searching the include path for class files.
|
||||||
|
*
|
||||||
|
* @param bool $useIncludePath
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setUseIncludePath($useIncludePath)
|
||||||
|
{
|
||||||
|
$this->useIncludePath = $useIncludePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be used to check if the autoloader uses the include path to check
|
||||||
|
* for classes.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function getUseIncludePath()
|
||||||
|
{
|
||||||
|
return $this->useIncludePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off searching the prefix and fallback directories for classes
|
||||||
|
* that have not been registered with the class map.
|
||||||
|
*
|
||||||
|
* @param bool $classMapAuthoritative
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||||
|
{
|
||||||
|
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should class lookup fail if not found in the current class map?
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isClassMapAuthoritative()
|
||||||
|
{
|
||||||
|
return $this->classMapAuthoritative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||||
|
*
|
||||||
|
* @param string|null $apcuPrefix
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setApcuPrefix($apcuPrefix)
|
||||||
|
{
|
||||||
|
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getApcuPrefix()
|
||||||
|
{
|
||||||
|
return $this->apcuPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this instance as an autoloader.
|
||||||
|
*
|
||||||
|
* @param bool $prepend Whether to prepend the autoloader or not
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function register($prepend = false)
|
||||||
|
{
|
||||||
|
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||||
|
|
||||||
|
if (null === $this->vendorDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prepend) {
|
||||||
|
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||||
|
} else {
|
||||||
|
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||||
|
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters this instance as an autoloader.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function unregister()
|
||||||
|
{
|
||||||
|
spl_autoload_unregister(array($this, 'loadClass'));
|
||||||
|
|
||||||
|
if (null !== $this->vendorDir) {
|
||||||
|
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the given class or interface.
|
||||||
|
*
|
||||||
|
* @param string $class The name of the class
|
||||||
|
* @return true|null True if loaded, null otherwise
|
||||||
|
*/
|
||||||
|
public function loadClass($class)
|
||||||
|
{
|
||||||
|
if ($file = $this->findFile($class)) {
|
||||||
|
$includeFile = self::$includeFile;
|
||||||
|
$includeFile($file);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the path to the file where the class is defined.
|
||||||
|
*
|
||||||
|
* @param string $class The name of the class
|
||||||
|
*
|
||||||
|
* @return string|false The path if found, false otherwise
|
||||||
|
*/
|
||||||
|
public function findFile($class)
|
||||||
|
{
|
||||||
|
// class map lookup
|
||||||
|
if (isset($this->classMap[$class])) {
|
||||||
|
return $this->classMap[$class];
|
||||||
|
}
|
||||||
|
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (null !== $this->apcuPrefix) {
|
||||||
|
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||||
|
if ($hit) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->findFileWithExtension($class, '.php');
|
||||||
|
|
||||||
|
// Search for Hack files if we are running on HHVM
|
||||||
|
if (false === $file && defined('HHVM_VERSION')) {
|
||||||
|
$file = $this->findFileWithExtension($class, '.hh');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->apcuPrefix) {
|
||||||
|
apcu_add($this->apcuPrefix.$class, $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $file) {
|
||||||
|
// Remember that this class does not exist.
|
||||||
|
$this->missingClasses[$class] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||||
|
*
|
||||||
|
* @return array<string, self>
|
||||||
|
*/
|
||||||
|
public static function getRegisteredLoaders()
|
||||||
|
{
|
||||||
|
return self::$registeredLoaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $class
|
||||||
|
* @param string $ext
|
||||||
|
* @return string|false
|
||||||
|
*/
|
||||||
|
private function findFileWithExtension($class, $ext)
|
||||||
|
{
|
||||||
|
// PSR-4 lookup
|
||||||
|
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||||
|
|
||||||
|
$first = $class[0];
|
||||||
|
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||||
|
$subPath = $class;
|
||||||
|
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||||
|
$subPath = substr($subPath, 0, $lastPos);
|
||||||
|
$search = $subPath . '\\';
|
||||||
|
if (isset($this->prefixDirsPsr4[$search])) {
|
||||||
|
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||||
|
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||||
|
if (file_exists($file = $dir . $pathEnd)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-4 fallback dirs
|
||||||
|
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||||
|
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-0 lookup
|
||||||
|
if (false !== $pos = strrpos($class, '\\')) {
|
||||||
|
// namespaced class name
|
||||||
|
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||||
|
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||||
|
} else {
|
||||||
|
// PEAR-like class name
|
||||||
|
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->prefixesPsr0[$first])) {
|
||||||
|
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||||
|
if (0 === strpos($class, $prefix)) {
|
||||||
|
foreach ($dirs as $dir) {
|
||||||
|
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-0 fallback dirs
|
||||||
|
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||||
|
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-0 include paths.
|
||||||
|
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function initializeIncludeClosure()
|
||||||
|
{
|
||||||
|
if (self::$includeFile !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope isolated include.
|
||||||
|
*
|
||||||
|
* Prevents access to $this/self from included files.
|
||||||
|
*
|
||||||
|
* @param string $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
self::$includeFile = \Closure::bind(static function($file) {
|
||||||
|
include $file;
|
||||||
|
}, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
396
vendor/composer/InstalledVersions.php
vendored
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Composer.
|
||||||
|
*
|
||||||
|
* (c) Nils Adermann <naderman@naderman.de>
|
||||||
|
* Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer;
|
||||||
|
|
||||||
|
use Composer\Autoload\ClassLoader;
|
||||||
|
use Composer\Semver\VersionParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is copied in every Composer installed project and available to all
|
||||||
|
*
|
||||||
|
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||||
|
*
|
||||||
|
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||||
|
*
|
||||||
|
* @final
|
||||||
|
*/
|
||||||
|
class InstalledVersions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
private static $selfDir = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mixed[]|null
|
||||||
|
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||||
|
*/
|
||||||
|
private static $installed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
private static $installedIsLocalDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool|null
|
||||||
|
*/
|
||||||
|
private static $canGetVendors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array[]
|
||||||
|
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||||
|
*/
|
||||||
|
private static $installedByVendor = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
* @psalm-return list<string>
|
||||||
|
*/
|
||||||
|
public static function getInstalledPackages()
|
||||||
|
{
|
||||||
|
$packages = array();
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
$packages[] = array_keys($installed['versions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 === \count($packages)) {
|
||||||
|
return $packages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of all package names with a specific type e.g. 'library'
|
||||||
|
*
|
||||||
|
* @param string $type
|
||||||
|
* @return string[]
|
||||||
|
* @psalm-return list<string>
|
||||||
|
*/
|
||||||
|
public static function getInstalledPackagesByType($type)
|
||||||
|
{
|
||||||
|
$packagesByType = array();
|
||||||
|
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
foreach ($installed['versions'] as $name => $package) {
|
||||||
|
if (isset($package['type']) && $package['type'] === $type) {
|
||||||
|
$packagesByType[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $packagesByType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the given package is installed
|
||||||
|
*
|
||||||
|
* This also returns true if the package name is provided or replaced by another package
|
||||||
|
*
|
||||||
|
* @param string $packageName
|
||||||
|
* @param bool $includeDevRequirements
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (isset($installed['versions'][$packageName])) {
|
||||||
|
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the given package satisfies a version constraint
|
||||||
|
*
|
||||||
|
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||||
|
*
|
||||||
|
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||||
|
*
|
||||||
|
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||||
|
* @param string $packageName
|
||||||
|
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||||
|
{
|
||||||
|
$constraint = $parser->parseConstraints((string) $constraint);
|
||||||
|
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||||
|
|
||||||
|
return $provided->matches($constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||||
|
*
|
||||||
|
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||||
|
* whether a given version of a package is installed, and not just whether it exists
|
||||||
|
*
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string Version constraint usable with composer/semver
|
||||||
|
*/
|
||||||
|
public static function getVersionRanges($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ranges = array();
|
||||||
|
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||||
|
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||||
|
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||||
|
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||||
|
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' || ', $ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||||
|
*/
|
||||||
|
public static function getVersion($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed['versions'][$packageName]['version'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||||
|
*/
|
||||||
|
public static function getPrettyVersion($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed['versions'][$packageName]['pretty_version'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||||
|
*/
|
||||||
|
public static function getReference($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed['versions'][$packageName]['reference'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||||
|
*/
|
||||||
|
public static function getInstallPath($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||||
|
*/
|
||||||
|
public static function getRootPackage()
|
||||||
|
{
|
||||||
|
$installed = self::getInstalled();
|
||||||
|
|
||||||
|
return $installed[0]['root'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw installed.php data for custom implementations
|
||||||
|
*
|
||||||
|
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||||
|
* @return array[]
|
||||||
|
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||||
|
*/
|
||||||
|
public static function getRawData()
|
||||||
|
{
|
||||||
|
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||||
|
|
||||||
|
if (null === self::$installed) {
|
||||||
|
// only require the installed.php file if this file is loaded from its dumped location,
|
||||||
|
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||||
|
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||||
|
self::$installed = include __DIR__ . '/installed.php';
|
||||||
|
} else {
|
||||||
|
self::$installed = array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||||
|
*
|
||||||
|
* @return array[]
|
||||||
|
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||||
|
*/
|
||||||
|
public static function getAllRawData()
|
||||||
|
{
|
||||||
|
return self::getInstalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets you reload the static array from another file
|
||||||
|
*
|
||||||
|
* This is only useful for complex integrations in which a project needs to use
|
||||||
|
* this class but then also needs to execute another project's autoloader in process,
|
||||||
|
* and wants to ensure both projects have access to their version of installed.php.
|
||||||
|
*
|
||||||
|
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||||
|
* the data it needs from this class, then call reload() with
|
||||||
|
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||||
|
* the project in which it runs can then also use this class safely, without
|
||||||
|
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||||
|
*
|
||||||
|
* @param array[] $data A vendor/composer/installed.php data set
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||||
|
*/
|
||||||
|
public static function reload($data)
|
||||||
|
{
|
||||||
|
self::$installed = $data;
|
||||||
|
self::$installedByVendor = array();
|
||||||
|
|
||||||
|
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||||
|
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||||
|
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||||
|
// all installed packages for example
|
||||||
|
self::$installedIsLocalDir = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function getSelfDir()
|
||||||
|
{
|
||||||
|
if (self::$selfDir === null) {
|
||||||
|
self::$selfDir = strtr(__DIR__, '\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$selfDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array[]
|
||||||
|
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||||
|
*/
|
||||||
|
private static function getInstalled()
|
||||||
|
{
|
||||||
|
if (null === self::$canGetVendors) {
|
||||||
|
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||||
|
}
|
||||||
|
|
||||||
|
$installed = array();
|
||||||
|
$copiedLocalDir = false;
|
||||||
|
|
||||||
|
if (self::$canGetVendors) {
|
||||||
|
$selfDir = self::getSelfDir();
|
||||||
|
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||||
|
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||||
|
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||||
|
$installed[] = self::$installedByVendor[$vendorDir];
|
||||||
|
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||||
|
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||||
|
$required = require $vendorDir.'/composer/installed.php';
|
||||||
|
self::$installedByVendor[$vendorDir] = $required;
|
||||||
|
$installed[] = $required;
|
||||||
|
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||||
|
self::$installed = $required;
|
||||||
|
self::$installedIsLocalDir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||||
|
$copiedLocalDir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === self::$installed) {
|
||||||
|
// only require the installed.php file if this file is loaded from its dumped location,
|
||||||
|
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||||
|
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||||
|
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||||
|
$required = require __DIR__ . '/installed.php';
|
||||||
|
self::$installed = $required;
|
||||||
|
} else {
|
||||||
|
self::$installed = array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$installed !== array() && !$copiedLocalDir) {
|
||||||
|
$installed[] = self::$installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
vendor/composer/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
10
vendor/composer/autoload_classmap.php
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_classmap.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||||
|
);
|
||||||
12
vendor/composer/autoload_files.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_files.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'383eaff206634a77a1be54e64e6459c7' => $vendorDir . '/sabre/uri/lib/functions.php',
|
||||||
|
'3569eecfeed3bcf0bad3c998a494ecb8' => $vendorDir . '/sabre/xml/lib/Deserializer/functions.php',
|
||||||
|
'93aa591bc4ca510c520999e34229ee79' => $vendorDir . '/sabre/xml/lib/Serializer/functions.php',
|
||||||
|
);
|
||||||
9
vendor/composer/autoload_namespaces.php
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_namespaces.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
);
|
||||||
12
vendor/composer/autoload_psr4.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_psr4.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'Sabre\\Xml\\' => array($vendorDir . '/sabre/xml/lib'),
|
||||||
|
'Sabre\\VObject\\' => array($vendorDir . '/sabre/vobject/lib'),
|
||||||
|
'Sabre\\Uri\\' => array($vendorDir . '/sabre/uri/lib'),
|
||||||
|
);
|
||||||
50
vendor/composer/autoload_real.php
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_real.php @generated by Composer
|
||||||
|
|
||||||
|
class ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc
|
||||||
|
{
|
||||||
|
private static $loader;
|
||||||
|
|
||||||
|
public static function loadClassLoader($class)
|
||||||
|
{
|
||||||
|
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||||
|
require __DIR__ . '/ClassLoader.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Composer\Autoload\ClassLoader
|
||||||
|
*/
|
||||||
|
public static function getLoader()
|
||||||
|
{
|
||||||
|
if (null !== self::$loader) {
|
||||||
|
return self::$loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/platform_check.php';
|
||||||
|
|
||||||
|
spl_autoload_register(array('ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc', 'loadClassLoader'), true, true);
|
||||||
|
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||||
|
spl_autoload_unregister(array('ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc', 'loadClassLoader'));
|
||||||
|
|
||||||
|
require __DIR__ . '/autoload_static.php';
|
||||||
|
call_user_func(\Composer\Autoload\ComposerStaticInit440454aa6bd2975652e94f60998e9adc::getInitializer($loader));
|
||||||
|
|
||||||
|
$loader->register(true);
|
||||||
|
|
||||||
|
$filesToLoad = \Composer\Autoload\ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$files;
|
||||||
|
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
|
||||||
|
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
|
||||||
|
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
|
||||||
|
|
||||||
|
require $file;
|
||||||
|
}
|
||||||
|
}, null, null);
|
||||||
|
foreach ($filesToLoad as $fileIdentifier => $file) {
|
||||||
|
$requireFile($fileIdentifier, $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
vendor/composer/autoload_static.php
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_static.php @generated by Composer
|
||||||
|
|
||||||
|
namespace Composer\Autoload;
|
||||||
|
|
||||||
|
class ComposerStaticInit440454aa6bd2975652e94f60998e9adc
|
||||||
|
{
|
||||||
|
public static $files = array (
|
||||||
|
'383eaff206634a77a1be54e64e6459c7' => __DIR__ . '/..' . '/sabre/uri/lib/functions.php',
|
||||||
|
'3569eecfeed3bcf0bad3c998a494ecb8' => __DIR__ . '/..' . '/sabre/xml/lib/Deserializer/functions.php',
|
||||||
|
'93aa591bc4ca510c520999e34229ee79' => __DIR__ . '/..' . '/sabre/xml/lib/Serializer/functions.php',
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $prefixLengthsPsr4 = array (
|
||||||
|
'S' =>
|
||||||
|
array (
|
||||||
|
'Sabre\\Xml\\' => 10,
|
||||||
|
'Sabre\\VObject\\' => 14,
|
||||||
|
'Sabre\\Uri\\' => 10,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $prefixDirsPsr4 = array (
|
||||||
|
'Sabre\\Xml\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/sabre/xml/lib',
|
||||||
|
),
|
||||||
|
'Sabre\\VObject\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/sabre/vobject/lib',
|
||||||
|
),
|
||||||
|
'Sabre\\Uri\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/sabre/uri/lib',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $classMap = array (
|
||||||
|
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||||
|
);
|
||||||
|
|
||||||
|
public static function getInitializer(ClassLoader $loader)
|
||||||
|
{
|
||||||
|
return \Closure::bind(function () use ($loader) {
|
||||||
|
$loader->prefixLengthsPsr4 = ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$prefixLengthsPsr4;
|
||||||
|
$loader->prefixDirsPsr4 = ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$prefixDirsPsr4;
|
||||||
|
$loader->classMap = ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$classMap;
|
||||||
|
|
||||||
|
}, null, ClassLoader::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
248
vendor/composer/installed.json
vendored
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
{
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "sabre/uri",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"version_normalized": "3.0.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/uri.git",
|
||||||
|
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||||
|
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.4",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.6",
|
||||||
|
"phpunit/phpunit": "^9.6"
|
||||||
|
},
|
||||||
|
"time": "2024-09-04T15:30:08+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Uri\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Functions for making sense out of URIs.",
|
||||||
|
"homepage": "http://sabre.io/uri/",
|
||||||
|
"keywords": [
|
||||||
|
"rfc3986",
|
||||||
|
"uri",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/uri/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-uri"
|
||||||
|
},
|
||||||
|
"install-path": "../sabre/uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/vobject",
|
||||||
|
"version": "4.5.8",
|
||||||
|
"version_normalized": "4.5.8.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/vobject.git",
|
||||||
|
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||||
|
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "~2.17.1",
|
||||||
|
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
|
||||||
|
"phpunit/php-invoker": "^2.0 || ^3.1",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"hoa/bench": "If you would like to run the benchmark scripts"
|
||||||
|
},
|
||||||
|
"time": "2026-01-12T10:45:19+00:00",
|
||||||
|
"bin": [
|
||||||
|
"bin/vobject",
|
||||||
|
"bin/generate_vcards"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "4.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\VObject\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dominik Tobschall",
|
||||||
|
"email": "dominik@fruux.com",
|
||||||
|
"homepage": "http://tobschall.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ivan Enderlin",
|
||||||
|
"email": "ivan.enderlin@hoa-project.net",
|
||||||
|
"homepage": "http://mnt.io/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
|
||||||
|
"homepage": "http://sabre.io/vobject/",
|
||||||
|
"keywords": [
|
||||||
|
"availability",
|
||||||
|
"freebusy",
|
||||||
|
"iCalendar",
|
||||||
|
"ical",
|
||||||
|
"ics",
|
||||||
|
"jCal",
|
||||||
|
"jCard",
|
||||||
|
"recurrence",
|
||||||
|
"rfc2425",
|
||||||
|
"rfc2426",
|
||||||
|
"rfc2739",
|
||||||
|
"rfc4770",
|
||||||
|
"rfc5545",
|
||||||
|
"rfc5546",
|
||||||
|
"rfc6321",
|
||||||
|
"rfc6350",
|
||||||
|
"rfc6351",
|
||||||
|
"rfc6474",
|
||||||
|
"rfc6638",
|
||||||
|
"rfc6715",
|
||||||
|
"rfc6868",
|
||||||
|
"vCalendar",
|
||||||
|
"vCard",
|
||||||
|
"vcf",
|
||||||
|
"xCal",
|
||||||
|
"xCard"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/vobject/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-vobject"
|
||||||
|
},
|
||||||
|
"install-path": "../sabre/vobject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sabre/xml",
|
||||||
|
"version": "4.0.6",
|
||||||
|
"version_normalized": "4.0.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sabre-io/xml.git",
|
||||||
|
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||||
|
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"lib-libxml": ">=2.6.20",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"sabre/uri": ">=2.0,<4.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpunit/phpunit": "^9.6"
|
||||||
|
},
|
||||||
|
"time": "2024-09-06T08:00:55+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/Deserializer/functions.php",
|
||||||
|
"lib/Serializer/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Xml\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Markus Staab",
|
||||||
|
"email": "markus.staab@redaxo.de",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "sabre/xml is an XML library that you may not hate.",
|
||||||
|
"homepage": "https://sabre.io/xml/",
|
||||||
|
"keywords": [
|
||||||
|
"XMLReader",
|
||||||
|
"XMLWriter",
|
||||||
|
"dom",
|
||||||
|
"xml"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"issues": "https://github.com/sabre-io/xml/issues",
|
||||||
|
"source": "https://github.com/fruux/sabre-xml"
|
||||||
|
},
|
||||||
|
"install-path": "../sabre/xml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"dev-package-names": []
|
||||||
|
}
|
||||||
50
vendor/composer/installed.php
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php return array(
|
||||||
|
'root' => array(
|
||||||
|
'name' => '__root__',
|
||||||
|
'pretty_version' => 'dev-main',
|
||||||
|
'version' => 'dev-main',
|
||||||
|
'reference' => 'b70aae837708d2d4458a68e4dbc5801ca173048d',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../../',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev' => true,
|
||||||
|
),
|
||||||
|
'versions' => array(
|
||||||
|
'__root__' => array(
|
||||||
|
'pretty_version' => 'dev-main',
|
||||||
|
'version' => 'dev-main',
|
||||||
|
'reference' => 'b70aae837708d2d4458a68e4dbc5801ca173048d',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../../',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'sabre/uri' => array(
|
||||||
|
'pretty_version' => '3.0.2',
|
||||||
|
'version' => '3.0.2.0',
|
||||||
|
'reference' => '38eeab6ed9eec435a2188db489d4649c56272c51',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../sabre/uri',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'sabre/vobject' => array(
|
||||||
|
'pretty_version' => '4.5.8',
|
||||||
|
'version' => '4.5.8.0',
|
||||||
|
'reference' => 'd554eb24d64232922e1eab5896cc2f84b3b9ffb1',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../sabre/vobject',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'sabre/xml' => array(
|
||||||
|
'pretty_version' => '4.0.6',
|
||||||
|
'version' => '4.0.6.0',
|
||||||
|
'reference' => 'a89257fd188ce30e456b841b6915f27905dfdbe3',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../sabre/xml',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
25
vendor/composer/platform_check.php
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// platform_check.php @generated by Composer
|
||||||
|
|
||||||
|
$issues = array();
|
||||||
|
|
||||||
|
if (!(PHP_VERSION_ID >= 70400)) {
|
||||||
|
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.4.0". You are running ' . PHP_VERSION . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issues) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('HTTP/1.1 500 Internal Server Error');
|
||||||
|
}
|
||||||
|
if (!ini_get('display_errors')) {
|
||||||
|
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||||
|
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||||
|
} elseif (!headers_sent()) {
|
||||||
|
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||||
|
);
|
||||||
|
}
|
||||||
27
vendor/sabre/uri/LICENSE
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Copyright (C) 2014-2019 fruux GmbH (https://fruux.com/)
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
* Neither the name Sabre nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
68
vendor/sabre/uri/composer.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "sabre/uri",
|
||||||
|
"description": "Functions for making sense out of URIs.",
|
||||||
|
"keywords": [
|
||||||
|
"URI",
|
||||||
|
"URL",
|
||||||
|
"rfc3986"
|
||||||
|
],
|
||||||
|
"homepage": "http://sabre.io/uri/",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Evert Pot",
|
||||||
|
"email": "me@evertpot.com",
|
||||||
|
"homepage": "http://evertpot.com/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||||
|
"source": "https://github.com/fruux/sabre-uri"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files" : [
|
||||||
|
"lib/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4" : {
|
||||||
|
"Sabre\\Uri\\" : "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Sabre\\Uri\\": "tests/Uri"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.4",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.6",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpunit/phpunit" : "^9.6"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"phpstan": [
|
||||||
|
"phpstan analyse lib tests"
|
||||||
|
],
|
||||||
|
"cs-fixer": [
|
||||||
|
"php-cs-fixer fix"
|
||||||
|
],
|
||||||
|
"phpunit": [
|
||||||
|
"phpunit --configuration tests/phpunit.xml"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"composer phpstan",
|
||||||
|
"composer cs-fixer",
|
||||||
|
"composer phpunit"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"phpstan/extension-installer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
vendor/sabre/uri/lib/InvalidUriException.php
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Sabre\Uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid Uri.
|
||||||
|
*
|
||||||
|
* This is thrown when an attempt was made to use Sabre\Uri parse a uri that
|
||||||
|
* it could not.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
|
||||||
|
* @author Evert Pot (https://evertpot.com/)
|
||||||
|
* @license http://sabre.io/license/
|
||||||
|
*/
|
||||||
|
class InvalidUriException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
20
vendor/sabre/uri/lib/Version.php
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Sabre\Uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class contains the version number for this package.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
|
||||||
|
* @author Evert Pot (http://evertpot.com/)
|
||||||
|
* @license http://sabre.io/license/
|
||||||
|
*/
|
||||||
|
class Version
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Full version number.
|
||||||
|
*/
|
||||||
|
public const VERSION = '3.0.2';
|
||||||
|
}
|
||||||
425
vendor/sabre/uri/lib/functions.php
vendored
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Sabre\Uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file contains all the uri handling functions.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
|
||||||
|
* @author Evert Pot (http://evertpot.com/)
|
||||||
|
* @license http://sabre.io/license/
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves relative urls, like a browser would.
|
||||||
|
*
|
||||||
|
* This function takes a basePath, which itself _may_ also be relative, and
|
||||||
|
* then applies the relative path on top of it.
|
||||||
|
*
|
||||||
|
* @throws InvalidUriException
|
||||||
|
*/
|
||||||
|
function resolve(string $basePath, string $newPath): string
|
||||||
|
{
|
||||||
|
$delta = parse($newPath);
|
||||||
|
|
||||||
|
// If the new path defines a scheme, it's absolute and we can just return
|
||||||
|
// that.
|
||||||
|
if (null !== $delta['scheme']) {
|
||||||
|
return build($delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = parse($basePath);
|
||||||
|
$pick = function ($part) use ($base, $delta) {
|
||||||
|
if (null !== $delta[$part]) {
|
||||||
|
return $delta[$part];
|
||||||
|
} elseif (null !== $base[$part]) {
|
||||||
|
return $base[$part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
$newParts = [];
|
||||||
|
|
||||||
|
$newParts['scheme'] = $pick('scheme');
|
||||||
|
$newParts['host'] = $pick('host');
|
||||||
|
$newParts['port'] = $pick('port');
|
||||||
|
|
||||||
|
if (is_string($delta['path']) and strlen($delta['path']) > 0) {
|
||||||
|
// If the path starts with a slash
|
||||||
|
if ('/' === $delta['path'][0]) {
|
||||||
|
$path = $delta['path'];
|
||||||
|
} else {
|
||||||
|
// Removing last component from base path.
|
||||||
|
$path = (string) $base['path'];
|
||||||
|
$length = strrpos($path, '/');
|
||||||
|
if (false !== $length) {
|
||||||
|
$path = substr($path, 0, $length);
|
||||||
|
}
|
||||||
|
$path .= '/'.$delta['path'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$path = $base['path'] ?? '/';
|
||||||
|
if ('' === $path) {
|
||||||
|
$path = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Removing .. and .
|
||||||
|
$pathParts = explode('/', $path);
|
||||||
|
$newPathParts = [];
|
||||||
|
foreach ($pathParts as $pathPart) {
|
||||||
|
switch ($pathPart) {
|
||||||
|
// case '' :
|
||||||
|
case '.':
|
||||||
|
break;
|
||||||
|
case '..':
|
||||||
|
array_pop($newPathParts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$newPathParts[] = $pathPart;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = implode('/', $newPathParts);
|
||||||
|
|
||||||
|
// If the source url ended with a /, we want to preserve that.
|
||||||
|
$newParts['path'] = 0 === strpos($path, '/') ? $path : '/'.$path;
|
||||||
|
// From PHP 8, no "?" query at all causes 'query' to be null.
|
||||||
|
// An empty query "http://example.com/foo?" causes 'query' to be the empty string
|
||||||
|
if (null !== $delta['query'] && '' !== $delta['query']) {
|
||||||
|
$newParts['query'] = $delta['query'];
|
||||||
|
} elseif (isset($base['query']) && null === $delta['host'] && null === $delta['path']) {
|
||||||
|
// Keep the old query if host and path didn't change
|
||||||
|
$newParts['query'] = $base['query'];
|
||||||
|
}
|
||||||
|
// From PHP 8, no "#" fragment at all causes 'fragment' to be null.
|
||||||
|
// An empty fragment "http://example.com/foo#" causes 'fragment' to be the empty string
|
||||||
|
if (null !== $delta['fragment'] && '' !== $delta['fragment']) {
|
||||||
|
$newParts['fragment'] = $delta['fragment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return build($newParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a URI or partial URI as its argument, and normalizes it.
|
||||||
|
*
|
||||||
|
* After normalizing a URI, you can safely compare it to other URIs.
|
||||||
|
* This function will for instance convert a %7E into a tilde, according to
|
||||||
|
* rfc3986.
|
||||||
|
*
|
||||||
|
* It will also change a %3a into a %3A.
|
||||||
|
*
|
||||||
|
* @throws InvalidUriException
|
||||||
|
*/
|
||||||
|
function normalize(string $uri): string
|
||||||
|
{
|
||||||
|
$parts = parse($uri);
|
||||||
|
|
||||||
|
if (null !== $parts['path']) {
|
||||||
|
$pathParts = explode('/', ltrim($parts['path'], '/'));
|
||||||
|
$newPathParts = [];
|
||||||
|
foreach ($pathParts as $pathPart) {
|
||||||
|
switch ($pathPart) {
|
||||||
|
case '.':
|
||||||
|
// skip
|
||||||
|
break;
|
||||||
|
case '..':
|
||||||
|
// One level up in the hierarchy
|
||||||
|
array_pop($newPathParts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Ensuring that everything is correctly percent-encoded.
|
||||||
|
$newPathParts[] = rawurlencode(rawurldecode($pathPart));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$parts['path'] = '/'.implode('/', $newPathParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $parts['scheme']) {
|
||||||
|
$parts['scheme'] = strtolower($parts['scheme']);
|
||||||
|
$defaultPorts = [
|
||||||
|
'http' => '80',
|
||||||
|
'https' => '443',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $parts['port'] && isset($defaultPorts[$parts['scheme']]) && $defaultPorts[$parts['scheme']] == $parts['port']) {
|
||||||
|
// Removing default ports.
|
||||||
|
unset($parts['port']);
|
||||||
|
}
|
||||||
|
// A few HTTP specific rules.
|
||||||
|
switch ($parts['scheme']) {
|
||||||
|
case 'http':
|
||||||
|
case 'https':
|
||||||
|
if (null === $parts['path']) {
|
||||||
|
// An empty path is equivalent to / in http.
|
||||||
|
$parts['path'] = '/';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $parts['host']) {
|
||||||
|
$parts['host'] = strtolower($parts['host']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return build($parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a URI and returns its individual components.
|
||||||
|
*
|
||||||
|
* This method largely behaves the same as PHP's parse_url, except that it will
|
||||||
|
* return an array with all the array keys, including the ones that are not
|
||||||
|
* set by parse_url, which makes it a bit easier to work with.
|
||||||
|
*
|
||||||
|
* Unlike PHP's parse_url, it will also convert any non-ascii characters to
|
||||||
|
* percent-encoded strings. PHP's parse_url corrupts these characters on OS X.
|
||||||
|
*
|
||||||
|
* In the return array, key "port" is an int value. Other keys have a string value.
|
||||||
|
* "Unused" keys have value null.
|
||||||
|
*
|
||||||
|
* @return array{scheme: string|null, host: string|null, path: string|null, port: positive-int|null, user: string|null, query: string|null, fragment: string|null}
|
||||||
|
*
|
||||||
|
* @throws InvalidUriException
|
||||||
|
*/
|
||||||
|
function parse(string $uri): array
|
||||||
|
{
|
||||||
|
// Normally a URI must be ASCII. However, often it's not and
|
||||||
|
// parse_url might corrupt these strings.
|
||||||
|
//
|
||||||
|
// For that reason we take any non-ascii characters from the uri and
|
||||||
|
// uriencode them first.
|
||||||
|
$uri = preg_replace_callback(
|
||||||
|
'/[^[:ascii:]]/u',
|
||||||
|
function ($matches) {
|
||||||
|
return rawurlencode($matches[0]);
|
||||||
|
},
|
||||||
|
$uri
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null === $uri) {
|
||||||
|
throw new InvalidUriException('Invalid, or could not parse URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = parse_url($uri);
|
||||||
|
if (false === $result) {
|
||||||
|
$result = _parse_fallback($uri);
|
||||||
|
} else {
|
||||||
|
// Add empty host and leading slash to Windows file paths
|
||||||
|
// file:///C:/path or file:///C:\path
|
||||||
|
// Note: the regex fragment [a-zA-Z]:[\/\\\\].* end up being
|
||||||
|
// [a-zA-Z]:[\/\\].*
|
||||||
|
// The 4 backslash in a row are the way to get 2 backslash into the actual string
|
||||||
|
// that is used as the regex. The 2 backslash are then the way to get 1 backslash
|
||||||
|
// character into the character set "a forward slash or a backslash"
|
||||||
|
if (isset($result['scheme']) && 'file' === $result['scheme'] && isset($result['path'])
|
||||||
|
&& 1 === preg_match('/^(?<windows_path> [a-zA-Z]:([\/\\\\].*)?)$/x', $result['path'])) {
|
||||||
|
$result['path'] = '/'.$result['path'];
|
||||||
|
$result['host'] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* phpstan is not able to process all the things that happen while this function
|
||||||
|
* constructs the result array. It only understands the $result is
|
||||||
|
* non-empty-array<string, mixed>
|
||||||
|
*
|
||||||
|
* But the detail of the returned array is correctly specified in the PHPdoc
|
||||||
|
* above the function call.
|
||||||
|
*
|
||||||
|
* @phpstan-ignore-next-line
|
||||||
|
*/
|
||||||
|
return
|
||||||
|
$result + [
|
||||||
|
'scheme' => null,
|
||||||
|
'host' => null,
|
||||||
|
'path' => null,
|
||||||
|
'port' => null,
|
||||||
|
'user' => null,
|
||||||
|
'query' => null,
|
||||||
|
'fragment' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function takes the components returned from PHP's parse_url, and uses
|
||||||
|
* it to generate a new uri.
|
||||||
|
*
|
||||||
|
* @param array<string, int|string|null> $parts
|
||||||
|
*/
|
||||||
|
function build(array $parts): string
|
||||||
|
{
|
||||||
|
$uri = '';
|
||||||
|
|
||||||
|
$authority = '';
|
||||||
|
if (isset($parts['host'])) {
|
||||||
|
$authority = $parts['host'];
|
||||||
|
if (isset($parts['user'])) {
|
||||||
|
$authority = $parts['user'].'@'.$authority;
|
||||||
|
}
|
||||||
|
if (isset($parts['port'])) {
|
||||||
|
$authority = $authority.':'.$parts['port'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($parts['scheme'])) {
|
||||||
|
// If there's a scheme, there's also a host.
|
||||||
|
$uri = $parts['scheme'].':';
|
||||||
|
}
|
||||||
|
if ('' !== $authority || (isset($parts['scheme']) && 'file' === $parts['scheme'])) {
|
||||||
|
// No scheme, but there is a host.
|
||||||
|
$uri .= '//'.$authority;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($parts['path'])) {
|
||||||
|
$uri .= $parts['path'];
|
||||||
|
}
|
||||||
|
if (isset($parts['query'])) {
|
||||||
|
$uri .= '?'.$parts['query'];
|
||||||
|
}
|
||||||
|
if (isset($parts['fragment'])) {
|
||||||
|
$uri .= '#'.$parts['fragment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the 'dirname' and 'basename' for a path.
|
||||||
|
*
|
||||||
|
* The reason there is a custom function for this purpose, is because
|
||||||
|
* basename() is locale aware (behaviour changes if C locale or a UTF-8 locale
|
||||||
|
* is used) and we need a method that just operates on UTF-8 characters.
|
||||||
|
*
|
||||||
|
* In addition basename and dirname are platform aware, and will treat
|
||||||
|
* backslash (\) as a directory separator on Windows.
|
||||||
|
*
|
||||||
|
* This method returns the 2 components as an array.
|
||||||
|
*
|
||||||
|
* If there is no dirname, it will return an empty string. Any / appearing at
|
||||||
|
* the end of the string is stripped off.
|
||||||
|
*
|
||||||
|
* @return list<mixed>
|
||||||
|
*/
|
||||||
|
function split(string $path): array
|
||||||
|
{
|
||||||
|
$matches = [];
|
||||||
|
if (1 === preg_match('/^(?:(?:(.*)(?:\/+))?([^\/]+))(?:\/?)$/u', $path, $matches)) {
|
||||||
|
return [$matches[1], $matches[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is another implementation of parse_url, except this one is
|
||||||
|
* fully written in PHP.
|
||||||
|
*
|
||||||
|
* The reason is that the PHP bug team is not willing to admit that there are
|
||||||
|
* bugs in the parse_url implementation.
|
||||||
|
*
|
||||||
|
* This function is only called if the main parse method fails. It's pretty
|
||||||
|
* crude and probably slow, so the original parse_url is usually preferred.
|
||||||
|
*
|
||||||
|
* @return array{scheme: string|null, host: string|null, path: string|null, port: positive-int|null, user: string|null, query: string|null, fragment: string|null}
|
||||||
|
*
|
||||||
|
* @throws InvalidUriException
|
||||||
|
*/
|
||||||
|
function _parse_fallback(string $uri): array
|
||||||
|
{
|
||||||
|
// Normally a URI must be ASCII, however. However, often it's not and
|
||||||
|
// parse_url might corrupt these strings.
|
||||||
|
//
|
||||||
|
// For that reason we take any non-ascii characters from the uri and
|
||||||
|
// uriencode them first.
|
||||||
|
$uri = preg_replace_callback(
|
||||||
|
'/[^[:ascii:]]/u',
|
||||||
|
function ($matches) {
|
||||||
|
return rawurlencode($matches[0]);
|
||||||
|
},
|
||||||
|
$uri
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null === $uri) {
|
||||||
|
throw new InvalidUriException('Invalid, or could not parse URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'scheme' => null,
|
||||||
|
'host' => null,
|
||||||
|
'port' => null,
|
||||||
|
'user' => null,
|
||||||
|
'path' => null,
|
||||||
|
'fragment' => null,
|
||||||
|
'query' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (1 === preg_match('% ^([A-Za-z][A-Za-z0-9+-\.]+): %x', $uri, $matches)) {
|
||||||
|
$result['scheme'] = $matches[1];
|
||||||
|
// Take what's left.
|
||||||
|
$uri = substr($uri, strlen($result['scheme']) + 1);
|
||||||
|
if (false === $uri) {
|
||||||
|
// There was nothing left.
|
||||||
|
$uri = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking off a fragment part
|
||||||
|
if (false !== strpos($uri, '#')) {
|
||||||
|
list($uri, $result['fragment']) = explode('#', $uri, 2);
|
||||||
|
}
|
||||||
|
// Taking off the query part
|
||||||
|
if (false !== strpos($uri, '?')) {
|
||||||
|
list($uri, $result['query']) = explode('?', $uri, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('///' === substr($uri, 0, 3)) {
|
||||||
|
// The triple slash uris are a bit unusual, but we have special handling
|
||||||
|
// for them.
|
||||||
|
$path = substr($uri, 2);
|
||||||
|
if (false === $path) {
|
||||||
|
throw new \RuntimeException('The string cannot be false');
|
||||||
|
}
|
||||||
|
$result['path'] = $path;
|
||||||
|
$result['host'] = '';
|
||||||
|
} elseif ('//' === substr($uri, 0, 2)) {
|
||||||
|
// Uris that have an authority part.
|
||||||
|
$regex = '%^
|
||||||
|
//
|
||||||
|
(?: (?<user> [^:@]+) (: (?<pass> [^@]+)) @)?
|
||||||
|
(?<host> ( [^:/]* | \[ [^\]]+ \] ))
|
||||||
|
(?: : (?<port> [0-9]+))?
|
||||||
|
(?<path> / .*)?
|
||||||
|
$%x';
|
||||||
|
if (1 !== preg_match($regex, $uri, $matches)) {
|
||||||
|
throw new InvalidUriException('Invalid, or could not parse URI');
|
||||||
|
}
|
||||||
|
if (isset($matches['host']) && '' !== $matches['host']) {
|
||||||
|
$result['host'] = $matches['host'];
|
||||||
|
}
|
||||||
|
if (isset($matches['port'])) {
|
||||||
|
$port = (int) $matches['port'];
|
||||||
|
if ($port > 0) {
|
||||||
|
$result['port'] = $port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($matches['path'])) {
|
||||||
|
$result['path'] = $matches['path'];
|
||||||
|
}
|
||||||
|
if (isset($matches['user']) && '' !== $matches['user']) {
|
||||||
|
$result['user'] = $matches['user'];
|
||||||
|
}
|
||||||
|
if (isset($matches['pass']) && '' !== $matches['pass']) {
|
||||||
|
$result['pass'] = $matches['pass'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$result['path'] = $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
27
vendor/sabre/vobject/LICENSE
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Copyright (C) 2011-2016 fruux GmbH (https://fruux.com/)
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
* Neither the name Sabre nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||