Compare commits

...

69 Commits

Author SHA1 Message Date
f1ac693fe8 Add the Chronological 2026-02-16 13:39:26 +01:00
c091ed1371 Add grouping feature 2026-02-13 13:14:11 +01:00
164df2f770 fix data-src in image macro 2026-02-13 10:24:18 +01:00
232802b0ce Add explcit aliases for open links 2026-02-13 10:10:57 +01:00
4dae370deb Refine Page Linking workflow 2026-02-09 09:23:04 +01:00
a5c44e106e Allow downloading the pagelink file directly 2026-02-04 09:53:43 +01:00
70a9f30336 Extract Pagelink functionality 2026-02-03 08:00:09 +01:00
1b6df4a9e4 Improved Page lik handling 2026-02-02 22:36:38 +01:00
47a8bfa50a Unlinking 2026-02-02 21:55:35 +01:00
e1102d9f06 V1 2026-02-02 21:13:51 +01:00
af0ca29131 Refine spec 2026-02-02 20:55:35 +01:00
c203fe6397 Add spec 2026-02-02 20:39:02 +01:00
487e96b588 Add date fixer functions 2026-01-30 11:12:50 +01:00
47889c7d4c No white background for favicons 2026-01-28 20:39:25 +01:00
a5b33c1b8d Show favicons for external links 2026-01-28 16:41:08 +01:00
e6d6ad3c7b Rework lazy loading as js controlled fetches 2026-01-28 13:20:35 +01:00
80e3aa95d8 Imporve image lazy loading 2026-01-28 12:15:08 +01:00
43fc752535 Ensure CSS updates after deploy 2026-01-27 11:09:20 +01:00
7a4ce8609c Allow hotlinking images 2026-01-26 09:39:34 +01:00
16e80f81c0 Update temp-input-colors.css
remove `!important`
2026-01-22 22:01:40 +01:00
5ce4c1daef Adjust scratchpad css 2026-01-22 21:50:01 +01:00
30b85b2257 Fix settings page textarea displays 2026-01-22 21:35:36 +01:00
e306226ac8 Temp fix for edit styling 2026-01-22 21:20:09 +01:00
4eaec5c7b1 Make location link color customizable 2026-01-22 20:54:50 +01:00
912f9dcac6 Clean up unused syntax 2026-01-22 20:27:47 +01:00
351485efb1 Use thumbnail/placeholder logic for imagebox 2026-01-21 11:03:41 +01:00
e1d24c6627 Update copilot instrucitons 2026-01-21 07:51:29 +01:00
56e51b1c3c Add button for code blocks 2026-01-20 07:53:37 +01:00
d3e087ad6e Add item count for folders 2026-01-19 09:38:47 +01:00
8aa022feff Add single image display. 2026-01-19 09:16:02 +01:00
95a0e94b4a add dokuwiki sources for dev 2026-01-15 20:40:33 +01:00
dd74499339 Split js into separate files 2026-01-15 20:24:16 +01:00
8a97197f3e Refactor js 2026-01-15 20:05:41 +01:00
34ff7f1a7f image zooming 2026-01-15 19:56:08 +01:00
cf1fcd9e96 Update lightbox styles and caption handling 2026-01-15 19:27:09 +01:00
7c9e289740 Refactor admin page 2026-01-13 09:37:24 +01:00
77fcae3450 Fix caching issue on admin page 2026-01-13 08:38:35 +01:00
c1ae169335 Ajust lightbox 2026-01-09 15:11:47 +01:00
2d9e5ff8d0 improve scratchpad editing 2026-01-09 12:27:49 +01:00
c20b482616 Refactor project structure 2026-01-09 11:45:34 +01:00
c11d9bdb8c Integrate Dokuwiki ACL for file endpoint 2026-01-09 11:13:12 +01:00
23a50ce4f6 Access control for file serving
Authenticated user only for now
2026-01-09 10:32:15 +01:00
331e392fc9 Convert Readme to markdown 2026-01-09 10:10:23 +01:00
239fcc2cb2 Remove old files 2026-01-09 10:07:57 +01:00
73d0796e8d Update project documentation 2026-01-09 10:06:17 +01:00
600198ec9a Further update instruction 2026-01-09 09:46:39 +01:00
a704640ebc Fix token issue when saving scratchpad 2026-01-09 09:44:08 +01:00
6328523624 Additional error checking for scratchpads 2026-01-09 09:37:21 +01:00
15cfa01114 Update copilot instructions 2026-01-09 09:29:24 +01:00
0948f50d76 Scratchpads V1 2026-01-09 09:26:39 +01:00
16a07701ee Add lightbox for image gallery
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-07 15:26:04 +01:00
c5f4bcc1c5 Render open-location syntax as link
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-07 11:42:40 +01:00
6a396ce511 Add own admin page for the plugin
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 22:39:21 +01:00
f86dce6ec3 Remove auth token
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 21:57:55 +01:00
681eadaed0 Refactor old naming
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 14:44:51 +01:00
259fd297d3 remove ductch
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 14:34:26 +01:00
f83248d605 Optical rendering of file and image listings
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 14:34:05 +01:00
f8d5dafc62 Add setting for thumbnail scaling
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 14:23:16 +01:00
2e1e5feba9 Improve folder icon display
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 13:17:17 +01:00
a835f76f90 improve directory listing
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 11:38:30 +01:00
41580fa010 No flicker when rendering thumbnails
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 10:48:05 +01:00
23403bcc4f Thumbnails verison 1
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 10:40:19 +01:00
dbc9de37e0 Allow opening folder lisings on client
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 10:13:12 +01:00
30bb9e3bbd Fix base class
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 09:35:23 +01:00
68f678c2df Update deploy 2026-01-06 09:32:02 +01:00
43b1cc2efd add deploy script 2026-01-06 09:31:04 +01:00
9664dbb256 Add maxheight for listings
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 09:14:40 +01:00
490a483df1 Add base class for syntax handlers
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 09:02:39 +01:00
0ad43bcf9c Add directory listing syntax
Some checks failed
DokuWiki Default Tasks / all (push) Has been cancelled
2026-01-06 08:56:42 +01:00
220 changed files with 32905 additions and 1233 deletions

26
.github/copilot-instructions.md vendored Normal file
View 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

View File

@@ -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
View File

@@ -0,0 +1 @@
_agent-data/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "_dokuwiki"]
path = _dokuwiki
url = https://github.com/dokuwiki/dokuwiki.git

26
.vscode/settings.json vendored Normal file
View 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
View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,317 +0,0 @@
<?php
namespace dokuwiki\plugin\luxtools;
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="filetools-plugin">';
}
$this->renderListItems($this->files, $params);
if ($this->renderer instanceof \Doku_Renderer_xhtml) {
$this->renderer->doc .= '</div>';
}
}
/**
* 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)) {
$params['style'] = 'list';
$this->renderAsList($params);
return;
}
/** @var \Doku_Renderer_xhtml $renderer */
$renderer = $this->renderer;
$renderer->doc .= '<div class="filetools-plugin filetools-gallery">';
foreach ($this->files as $item) {
$url = $this->itemWebUrl($item, !empty($params['randlinks']));
$safeUrl = hsc($url);
$label = hsc($item['name']);
$renderer->doc .= '<a href="' . $safeUrl . '" class="media" title="' . $label . '">';
$renderer->doc .= '<img src="' . $safeUrl . '" alt="' . $label . '" width="150" loading="lazy" />';
$renderer->doc .= '</a>';
}
$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="filetools-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', 'luxtools');
return $syntax->getLang($key);
}
}

30
README
View File

@@ -1,30 +0,0 @@
LuxTools plugin for DokuWiki
Lists files matching a given glob pattern.
All documentation for this plugin can be found at
https://www.dokuwiki.org/plugin:luxtools
If you install this plugin manually, make sure it is installed in
lib/plugins/luxtools/ - if the folder is called different it
will not work!
Syntax:
{{files>...}}
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
View 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 dont 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 DokuWikis 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&center}}
{{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&center`
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

Submodule _dokuwiki added at 47e4d6995f

138
_test/ChronoIDTest.php Normal file
View 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);
}
}
}

View 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');
}
}
}

View 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');
}
}
}

View 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');
}
}
}

View File

@@ -39,47 +39,43 @@ class GeneralTest extends DokuWikiTest
}
/**
* Test to ensure that every conf['...'] entry in conf/default.php has a corresponding meta['...'] entry in
* conf/metadata.php.
* luxtools settings are managed via the plugin's admin page, not via the Configuration Manager.
* Ensure default config exists and (when present) metadata.php does not expose any settings.
*/
public function testPluginConf(): void
{
$conf_file = __DIR__ . '/../conf/default.php';
$meta_file = __DIR__ . '/../conf/metadata.php';
if (!file_exists($conf_file) && !file_exists($meta_file)) {
self::markTestSkipped('No config files exist -> skipping test');
if (!file_exists($conf_file)) {
self::markTestSkipped('No config default.php exists -> skipping test');
}
if (file_exists($conf_file)) {
include($conf_file);
}
if (file_exists($meta_file)) {
include($meta_file);
}
$conf = null;
$meta = null;
$this->assertEquals(
gettype($conf),
gettype($meta),
'Both ' . DOKU_PLUGIN . 'luxtools/conf/default.php and ' . DOKU_PLUGIN . 'luxtools/conf/metadata.php have to exist and contain the same keys.'
include($conf_file);
$this->assertIsArray(
$conf,
'The ' . DOKU_PLUGIN . 'luxtools/conf/default.php file needs to define $conf as an array.'
);
if ($conf !== null && $meta !== null) {
foreach ($conf as $key => $value) {
$this->assertArrayHasKey(
$key,
$meta,
'Key $meta[\'' . $key . '\'] missing in ' . DOKU_PLUGIN . 'luxtools/conf/metadata.php'
);
if (file_exists($meta_file)) {
include($meta_file);
if ($meta === null) {
// If the file exists but does not define $meta, treat it as empty.
$meta = [];
}
foreach ($meta as $key => $value) {
$this->assertArrayHasKey(
$key,
$conf,
'Key $conf[\'' . $key . '\'] missing in ' . DOKU_PLUGIN . 'luxtools/conf/default.php'
);
}
$this->assertIsArray(
$meta,
'The ' . DOKU_PLUGIN . 'luxtools/conf/metadata.php file needs to define $meta as an array.'
);
$this->assertEmpty(
$meta,
'luxtools should not expose settings via the Configuration Manager.'
);
}
}

View File

@@ -5,6 +5,8 @@ namespace dokuwiki\plugin\luxtools\test;
use dokuwiki\plugin\luxtools\Path;
use DokuWikiTest;
require_once(__DIR__ . '/../autoload.php');
/**
* Path related tests for the luxtools plugin
*
@@ -27,7 +29,6 @@ C:\\xampp\\htdocs\\wiki\\
/linux/file/path/
/linux/another/path/../..//another/blargh/../path
A> alias
W> webfoo
EOT
);
}
@@ -53,12 +54,12 @@ EOT
'/linux/another/path/' => [
'root' => '/linux/another/path/',
'alias' => 'alias/',
'web' => 'webfoo',
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
],
'alias/' => [
'root' => '/linux/another/path/',
'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->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);
}
}

View 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');
}
}

View File

@@ -29,7 +29,8 @@ class plugin_luxtools_test extends DokuWikiTest
parent::setUp();
// Setup config so that access to the TMP directory will be allowed
$conf ['plugin']['luxtools']['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_luxtools_test extends DokuWikiTest
/**
* 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()
{
global $conf;
// Render filelist
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1}}');
// Render filelist using files syntax (now handled by directory plugin)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&direct=1}}');
$xhtml = p_render('xhtml', $instructions, $info);
// We should find:
@@ -90,11 +92,12 @@ class plugin_luxtools_test extends DokuWikiTest
/**
* 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()
{
// Render filelist
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}');
// Render filelist using files syntax (now handled by directory plugin)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&direct=1&recursive=1}}');
$xhtml = p_render('xhtml', $instructions, $info);
// We should find:
@@ -113,54 +116,46 @@ class plugin_luxtools_test extends DokuWikiTest
}
/**
* This function checks that the unordered list mode
* generates the expected XHTML structure.
* This function checks the rendering when style=list is explicitly specified.
* 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()
{
// Render filelist
// Render filelist with explicit style=list (now ignored, renders as table)
$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);
// Now renders as a table instead of list
$structure = [
'div.filetools-plugin' => 1,
'div.filetools-plugin > ul' => 1,
'div.filetools-plugin > ul > li' => 3,
'div.filetools-plugin > ul > li:nth-child(1)' => 1,
'div.filetools-plugin > ul > li:nth-child(1) a' => 'example.txt',
'div.filetools-plugin > ul > li:nth-child(2) ul' => 1,
'div.filetools-plugin > ul > li:nth-child(2) ul > li' => 1,
'div.filetools-plugin > ul > li:nth-child(2) ul > li a' => 'example2.txt',
'div.luxtools-plugin' => 1,
'div.luxtools-plugin table' => 1,
];
$this->structureCheck($doc, $structure);
}
/**
* This function checks that the ordered list mode
* generates the expected XHTML structure.
* This function checks the rendering when style=olist is explicitly specified.
* 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()
{
// Render filelist
// Render filelist with explicit style=olist (now ignored, renders as table)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=olist&direct=1&recursive=1}}');
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
// Now renders as a table instead of ordered list
$structure = [
'div.filetools-plugin' => 1,
'div.filetools-plugin > ol' => 1,
'div.filetools-plugin > ol > li' => 3,
'div.filetools-plugin > ol > li:nth-child(1)' => 1,
'div.filetools-plugin > ol > li:nth-child(1) a' => 'example.txt',
'div.filetools-plugin > ol > li:nth-child(2) ol' => 1,
'div.filetools-plugin > ol > li:nth-child(2) ol > li' => 1,
'div.filetools-plugin > ol > li:nth-child(2) ol > li a' => 'example2.txt',
'div.luxtools-plugin' => 1,
'div.luxtools-plugin table' => 1,
];
$this->structureCheck($doc, $structure);
@@ -182,17 +177,44 @@ class plugin_luxtools_test extends DokuWikiTest
$doc->html($xhtml);
$structure = [
'div.filetools-plugin' => 1,
'div.filetools-plugin table' => 1,
'div.filetools-plugin table > tbody > tr' => 3,
'div.filetools-plugin table > tbody > tr:nth-child(1) a' => 'example.txt',
'div.filetools-plugin table > tbody > tr:nth-child(2) a' => 'exampledir/example2.txt',
'div.filetools-plugin table > tbody > tr:nth-child(3) a' => 'exampleimage.png',
'div.luxtools-plugin' => 1,
'div.luxtools-plugin table' => 1,
'div.luxtools-plugin table > tbody > tr' => 3,
'div.luxtools-plugin table > tbody > tr:nth-child(1) a' => 'example.txt',
'div.luxtools-plugin table > tbody > tr:nth-child(2) a' => 'exampledir/example2.txt',
'div.luxtools-plugin table > tbody > tr:nth-child(3) a' => 'exampleimage.png',
];
$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.
*/
@@ -205,19 +227,138 @@ class plugin_luxtools_test extends DokuWikiTest
$doc->html($xhtml);
$structure = [
'div.filetools-plugin.filetools-gallery' => 1,
'div.filetools-plugin.filetools-gallery a' => 1,
'div.filetools-plugin.filetools-gallery img' => 1,
'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);
}
/**
* This function checks that the open syntax renders an inline button.
* Grouping wrapper should use default flex mode with zero gap.
*/
public function test_open_button()
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);
@@ -226,11 +367,158 @@ class plugin_luxtools_test extends DokuWikiTest
$doc->html($xhtml);
$structure = [
'button.filetools-open' => 1,
'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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
{
"require": {
"sabre/vobject": "^4.5"
}
}

252
composer.lock generated Normal file
View 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"
}

View File

@@ -1,14 +1,48 @@
<?php
/**
* Options for the filetools plugin
* Options for the luxtools plugin
*/
$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['extensions'] = '';
// Local opener service used by {{open>...}}.
// 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';
$conf['open_service_token'] = '';
// 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

View File

@@ -2,15 +2,10 @@
/**
* Metadata for configuration manager plugin
* Additions for the filetools 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['allow_in_comments'] = array('onoff');
$meta['defaults'] = array('string');
$meta['extensions'] = array('string');
$meta['open_service_url'] = array('string');
$meta['open_service_token'] = array('string');
$meta = [];

View File

@@ -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
View 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."

254
file.php
View File

@@ -4,11 +4,21 @@
use dokuwiki\plugin\luxtools\Path;
require_once(__DIR__ . '/autoload.php');
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
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;
$syntax = plugin_load('syntax', 'luxtools');
@@ -17,6 +27,200 @@ if (!$syntax) die('plugin disabled?');
$pathUtil = new Path($syntax->getConf('paths'));
$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 {
$pathInfo = $pathUtil->getPathInfo($path, false);
if ($pathUtil::isWikiControlled($pathInfo['path'])) {
@@ -29,14 +233,48 @@ try {
echo 'Path not readable: ' . $pathInfo['path'];
exit;
}
[$ext, $mime, $download] = mimetype($pathInfo['path'], false);
$basename = basename($pathInfo['path']);
header('Content-Type: ' . $mime);
if ($download) {
header('Content-Disposition: attachment; filename="' . $basename . '"');
[, $mime, $download] = mimetype($pathInfo['path'], false);
// Optional thumbnail mode: ?thumb=1&w=150&h=150
$thumb = (int)$INPUT->int('thumb');
$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);
}
}
// Cached thumbs are immutable because filename includes mtime
luxtools_sendfile($thumbPath, $dstMime, false, null, $hash, @filemtime($thumbPath) ?: $srcMtime, 31536000);
}
http_sendfile($pathInfo['path']);
readfile($pathInfo['path']);
// Default: serve original file
$basename = basename($pathInfo['path']);
luxtools_sendfile($pathInfo['path'], $mime, $download, $basename, null, @filemtime($pathInfo['path']) ?: null, 3600);
} catch (Exception $e) {
header('Content-Type: text/plain');
http_status(403);

BIN
images/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

3
images/code.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
};
})();
})();

View File

@@ -7,8 +7,81 @@
* @author Gina Haeussge <osd@foosel.net>
*/
$lang['filename'] = 'Dateiname';
$lang['filesize'] = 'Dateigröße';
$lang['lastmodified'] = 'Letzte Änderung';
$lang['error_nomatch'] = 'Keine Treffer';
$lang['error_outsidejail'] = 'Zugriff verweigert';
$lang["filename"] = "Dateiname";
$lang["filesize"] = "Dateigröße";
$lang["lastmodified"] = "Letzte Änderung";
$lang["openlocation"] = "Ort öffnen";
$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";

View File

@@ -1,6 +1,30 @@
<?php
$lang['allow_in_comments'] = 'Files-Syntax 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['open_service_url'] = 'URL des lokalen Öffner-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).';
$lang['open_service_token'] = 'Token für den lokalen Öffner-Dienst (X-Filetools-Token).';
$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).";

View File

@@ -7,8 +7,82 @@
* @author Gina Haeussge <osd@foosel.net>
*/
$lang['filename'] = 'Filename';
$lang['filesize'] = 'Filesize';
$lang['lastmodified'] = 'Last modified';
$lang['error_nomatch'] = 'No match';
$lang['error_outsidejail'] = 'Access denied';
$lang["filename"] = "Filename";
$lang["filesize"] = "Filesize";
$lang["lastmodified"] = "Last modified";
$lang["openlocation"] = "Open Location";
$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";

View File

@@ -1,8 +1,30 @@
<?php
$lang['allow_in_comments'] = 'Whether to allow the files syntax to be used in comments.';
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration';
$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['open_service_url'] = 'Local opener service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).';
$lang['open_service_token'] = 'Token sent to the local opener service (X-Filetools-Token).';
$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).';

View File

@@ -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';

View File

@@ -1,12 +0,0 @@
<?php
/**
* Dutch language file for settings.
*
* @author Mark C. Prins <mprins@users.sf.net>
*/
$lang['allow_in_comments'] = 'Of de files 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.';
$lang['open_service_url'] = 'Lokale opener service-URL voor de {{open>...}} knop (bijv. http://127.0.0.1:8765).';
$lang['open_service_token'] = 'Token dat naar de lokale opener service wordt gestuurd (X-Filetools-Token).';

168
pagelink.php Normal file
View 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']);

View File

@@ -1,7 +1,7 @@
base luxtools
author Gina Häußge, Dokufreaks, luxick
author luxick
email dokuwiki@luxick.de
date 2026-01-05
name LuxTools
desc Lists files matching a given glob pattern.
name luxtools
desc Integrates host filesystem with DokuWiki (files, directories, images, open links, scratchpads).
url https://www.dokuwiki.org/plugin:luxtools

128
scratchpad.php Normal file
View 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']);

123
script.js
View File

@@ -1,123 +0,0 @@
/* global window, document */
(function () {
'use strict';
function getServiceUrl(el) {
var url = el.getAttribute('data-service-url') || '';
url = (url || '').trim();
if (!url) return '';
// strip trailing slashes
return url.replace(/\/+$/, '');
}
function getServiceToken(el) {
var token = el.getAttribute('data-service-token') || '';
return (token || '').trim();
}
function pingOpenViaImage(el, rawPath) {
var baseUrl = getServiceUrl(el);
if (!baseUrl) return;
var token = getServiceToken(el);
var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath);
if (token) url += '&token=' + encodeURIComponent(token);
// 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 opener service configured'));
var headers = {
'Content-Type': 'application/json'
};
var token = getServiceToken(el);
if (token) headers['X-Filetools-Token'] = token;
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;
}
function onClick(event) {
var el = event.target;
if (!el || !el.classList || !el.classList.contains('filetools-open')) return;
var raw = el.getAttribute('data-path') || '';
if (!raw) return;
// Prefer local opener service.
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.
pingOpenViaImage(el, raw);
// Fallback to old behavior (often blocked in modern browsers).
var url = normalizeToFileUrl(raw);
if (!url) return;
console.warn('Local opener 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);
}
}
});
}
document.addEventListener('click', onClick, false);
})();

241
src/ChronoID.php Normal file
View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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());
}
}

View File

@@ -13,6 +13,9 @@ class Crawler
/** @var bool */
protected $sortreverse = false;
/** @var bool */
protected $foldersFirst = false;
/** @var string[] patterns to ignore */
protected $ignore = [];
@@ -41,6 +44,11 @@ class Crawler
$this->sortreverse = $sortreverse;
}
public function setFoldersFirst($foldersFirst)
{
$this->foldersFirst = (bool)$foldersFirst;
}
/**
* Does a (recursive) crawl for finding files based on a given pattern.
* Based on a safe glob reimplementation using fnmatch and opendir.
@@ -122,6 +130,104 @@ class Crawler
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
*
@@ -133,13 +239,41 @@ class Crawler
$callback = [$this, 'compare' . ucfirst($this->sortby)];
if (!is_callable($callback)) return $items;
usort($items, $callback);
if ($this->sortreverse) {
$items = array_reverse($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);
if ($this->sortreverse) {
$items = array_reverse($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
*
@@ -173,7 +307,7 @@ class Crawler
*/
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 = array_map(static fn($line) => trim(preg_replace('/\s*#.*$/', '', $line)), $ignore);
$ignore = array_filter($ignore);

556
src/Output.php Normal file
View 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
View 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
View 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'));
}
}

View File

@@ -49,11 +49,6 @@ class Path
$alias = static::cleanPath($line);
$paths[$lastRoot]['alias'] = $alias;
$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 {
// this is a new path
$line = static::cleanPath($line);
@@ -103,6 +98,72 @@ class Path
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
*

91
src/ScratchpadMap.php Normal file
View 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
View 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
View 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;
}

View File

@@ -1,13 +1,15 @@
<?php
require_once(__DIR__ . '/syntax/files.php');
require_once(__DIR__ . '/syntax/AbstractSyntax.php');
require_once(__DIR__ . '/syntax/scratchpad.php');
/**
* LuxTools plugin bootstrap.
* luxtools plugin bootstrap.
*
* The actual {{files>...}} syntax implementation lives in syntax/files.php.
* The actual syntax implementation lives in the syntax classes.
* This class exists to register the syntax with DokuWiki and for other classes to have a common namespace.
*/
class syntax_plugin_luxtools extends syntax_plugin_luxtools_files
class syntax_plugin_luxtools extends syntax_plugin_luxtools_directory
{
/** @inheritdoc */
public function connectTo($mode)
@@ -16,16 +18,3 @@ class syntax_plugin_luxtools extends syntax_plugin_luxtools_files
}
}
/**
* Compatibility alias for older codebases that referenced the legacy class name.
*
* Note: plugin id/base is now `luxtools`.
*/
class syntax_plugin_filetools extends syntax_plugin_luxtools_files
{
/** @inheritdoc */
public function connectTo($mode)
{
// Intentionally empty: syntax is registered by syntax_plugin_luxtools_files.
}
}

287
syntax/AbstractSyntax.php Normal file
View 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
View 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
View 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;
}
}

View File

@@ -1,138 +0,0 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Crawler;
use dokuwiki\plugin\luxtools\Output;
use dokuwiki\plugin\luxtools\Path;
/**
* LuxTools Plugin: Files syntax.
*
* Lists files matching a given glob pattern.
*/
class syntax_plugin_luxtools_files extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 222;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern('\{\{files>.+?\}\}', $mode, '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('{{files>'), -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;
}
}

282
syntax/grouping.php Normal file
View 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
View 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>';
}
}

View File

@@ -1,113 +1,53 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Crawler;
use dokuwiki\plugin\luxtools\Output;
use dokuwiki\plugin\luxtools\Path;
require_once(__DIR__ . '/AbstractSyntax.php');
/**
* LuxTools Plugin: Image gallery syntax.
* luxtools Plugin: Image gallery syntax.
*
* Renders a thumbnail gallery of images matching a glob pattern.
*/
class syntax_plugin_luxtools_images extends SyntaxPlugin
class syntax_plugin_luxtools_images extends syntax_plugin_luxtools_abstract
{
/** @inheritdoc */
public function getType()
protected function getSyntaxKeyword(): string
{
return 'substition';
return 'images';
}
/** @inheritdoc */
public function getPType()
protected function getDefaultParams(): array
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 222;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern('\{\{images>.+?\}\}', $mode, 'plugin_luxtools_images');
}
/** @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('{{images>'), -2);
[$path, $flags] = explode('&', $match, 2);
// load default config options
$flags = $this->getConf('defaults') . '&' . $flags;
$flags = explode('&', $flags);
$params = [
'sort' => 'name',
'order' => 'asc',
'recursive' => 0,
'titlefile' => '_title.txt',
'cache' => 0,
'randlinks' => 0,
// Images syntax doesn't use some of the common params
return [
'tableheader' => null,
'showsize' => null,
'showdate' => null,
];
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)
/** @inheritdoc */
protected function processPath(string $path): array
{
[$base, $pattern, $params] = $data;
[$base, $pattern] = $this->separatePathAndPattern($path);
return ['base' => $base, 'pattern' => $pattern];
}
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') . ']');
/** @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 = new Crawler($this->getConf('extensions'));
$crawler->setSortBy($params['sort']);
$crawler->setSortReverse($params['order'] === 'desc');
$crawler = $this->createCrawler($params);
$result = $crawler->crawl(
$pathInfo['root'],
$pathInfo['local'],
$pattern,
$pathData['pattern'],
$params['recursive'],
$params['titlefile']
);
@@ -115,25 +55,18 @@ class syntax_plugin_luxtools_images extends SyntaxPlugin
$items = $this->flattenResultTree($result);
$items = $this->filterImages($items);
// if we got nothing back, display a message
if ($items == []) {
$renderer->cdata('[n/a: ' . $this->getLang('error_nomatch') . ']');
$this->renderEmptyState($renderer, 'empty_images');
return true;
}
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items);
if ($format == 'xhtml') {
$output->renderAsGallery($params);
return true;
// Images syntax only supports XHTML format (gallery rendering)
if ($format !== 'xhtml') {
return false;
}
// Fallback for non-XHTML formats: render as a list of links
$params['style'] = 'list';
$params['showsize'] = 0;
$params['showdate'] = 0;
$params['listsep'] = ', ';
$output->renderAsList($params);
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items, $this);
$output->renderAsGallery($params);
return true;
}

View File

@@ -1,15 +1,20 @@
<?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.
* luxtools Plugin: Open local path syntax.
*
* Renders an inline button. Clicking it triggers client-side JS that attempts
* 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()
{
@@ -73,19 +78,84 @@ class syntax_plugin_luxtools_open extends SyntaxPlugin
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'));
$attrs = ' type="button" class="filetools-open"'
. ' data-path="' . hsc($path) . '"';
if ($serviceUrl !== '') {
$attrs .= ' data-service-url="' . hsc($serviceUrl) . '"';
}
if ($serviceToken !== '') {
$attrs .= ' data-service-token="' . hsc($serviceToken) . '"';
if (!($renderer instanceof \Doku_Renderer_xhtml)) {
$renderer->cdata($caption);
return true;
}
$renderer->doc .= '<button' . $attrs . '>' . hsc($caption) . '</button>';
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
);

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}
}

View 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
View 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';
}

Some files were not shown because too many files have changed in this diff Show More