Compare commits
28 Commits
4103275ec1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e98f9ad2d9 | |||
| c1ffbc3f3a | |||
| 5c74c2e667 | |||
| 1cfd935794 | |||
| 8a36333883 | |||
| e32d69dcc3 | |||
| 5c747aaa78 | |||
| abe805926a | |||
| d33c7a748b | |||
| 946c269d42 | |||
| a3f021e5e1 | |||
| 96cc82db9e | |||
| 975e195ae3 | |||
| 14d4a2895a | |||
| 59a430938b | |||
| 211418c6c4 | |||
| e750736a1c | |||
| 5434ce5f53 | |||
| cc5101fffd | |||
| 97d9647ea2 | |||
| 6162ff595f | |||
| 94215fdd65 | |||
| a4815fc672 | |||
| 2d5e9541c2 | |||
| 87f6839b0d | |||
| e6920d3710 | |||
| acd427b2ce | |||
| 328a507a9f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
_agent-data/
|
_agent-data/
|
||||||
|
.claude/
|
||||||
|
|||||||
26
.vscode/settings.json
vendored
26
.vscode/settings.json
vendored
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
.zed/settings.json
Normal file
5
.zed/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"file_types": {
|
||||||
|
"LESS": ["style.css"],
|
||||||
|
},
|
||||||
|
}
|
||||||
339
LICENSE
339
LICENSE
@@ -1,339 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 2, June 1991
|
|
||||||
|
|
||||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
|
||||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The licenses for most software are designed to take away your
|
|
||||||
freedom to share and change it. By contrast, the GNU General Public
|
|
||||||
License is intended to guarantee your freedom to share and change free
|
|
||||||
software--to make sure the software is free for all its users. This
|
|
||||||
General Public License applies to most of the Free Software
|
|
||||||
Foundation's software and to any other program whose authors commit to
|
|
||||||
using it. (Some other Free Software Foundation software is covered by
|
|
||||||
the GNU Lesser General Public License instead.) You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
this service if you wish), that you receive source code or can get it
|
|
||||||
if you want it, that you can change the software or use pieces of it
|
|
||||||
in new free programs; and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to make restrictions that forbid
|
|
||||||
anyone to deny you these rights or to ask you to surrender the rights.
|
|
||||||
These restrictions translate to certain responsibilities for you if you
|
|
||||||
distribute copies of the software, or if you modify it.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
|
||||||
gratis or for a fee, you must give the recipients all the rights that
|
|
||||||
you have. You must make sure that they, too, receive or can get the
|
|
||||||
source code. And you must show them these terms so they know their
|
|
||||||
rights.
|
|
||||||
|
|
||||||
We protect your rights with two steps: (1) copyright the software, and
|
|
||||||
(2) offer you this license which gives you legal permission to copy,
|
|
||||||
distribute and/or modify the software.
|
|
||||||
|
|
||||||
Also, for each author's protection and ours, we want to make certain
|
|
||||||
that everyone understands that there is no warranty for this free
|
|
||||||
software. If the software is modified by someone else and passed on, we
|
|
||||||
want its recipients to know that what they have is not the original, so
|
|
||||||
that any problems introduced by others will not reflect on the original
|
|
||||||
authors' reputations.
|
|
||||||
|
|
||||||
Finally, any free program is threatened constantly by software
|
|
||||||
patents. We wish to avoid the danger that redistributors of a free
|
|
||||||
program will individually obtain patent licenses, in effect making the
|
|
||||||
program proprietary. To prevent this, we have made it clear that any
|
|
||||||
patent must be licensed for everyone's free use or not licensed at all.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
||||||
|
|
||||||
0. This License applies to any program or other work which contains
|
|
||||||
a notice placed by the copyright holder saying it may be distributed
|
|
||||||
under the terms of this General Public License. The "Program", below,
|
|
||||||
refers to any such program or work, and a "work based on the Program"
|
|
||||||
means either the Program or any derivative work under copyright law:
|
|
||||||
that is to say, a work containing the Program or a portion of it,
|
|
||||||
either verbatim or with modifications and/or translated into another
|
|
||||||
language. (Hereinafter, translation is included without limitation in
|
|
||||||
the term "modification".) Each licensee is addressed as "you".
|
|
||||||
|
|
||||||
Activities other than copying, distribution and modification are not
|
|
||||||
covered by this License; they are outside its scope. The act of
|
|
||||||
running the Program is not restricted, and the output from the Program
|
|
||||||
is covered only if its contents constitute a work based on the
|
|
||||||
Program (independent of having been made by running the Program).
|
|
||||||
Whether that is true depends on what the Program does.
|
|
||||||
|
|
||||||
1. You may copy and distribute verbatim copies of the Program's
|
|
||||||
source code as you receive it, in any medium, provided that you
|
|
||||||
conspicuously and appropriately publish on each copy an appropriate
|
|
||||||
copyright notice and disclaimer of warranty; keep intact all the
|
|
||||||
notices that refer to this License and to the absence of any warranty;
|
|
||||||
and give any other recipients of the Program a copy of this License
|
|
||||||
along with the Program.
|
|
||||||
|
|
||||||
You may charge a fee for the physical act of transferring a copy, and
|
|
||||||
you may at your option offer warranty protection in exchange for a fee.
|
|
||||||
|
|
||||||
2. You may modify your copy or copies of the Program or any portion
|
|
||||||
of it, thus forming a work based on the Program, and copy and
|
|
||||||
distribute such modifications or work under the terms of Section 1
|
|
||||||
above, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) You must cause the modified files to carry prominent notices
|
|
||||||
stating that you changed the files and the date of any change.
|
|
||||||
|
|
||||||
b) You must cause any work that you distribute or publish, that in
|
|
||||||
whole or in part contains or is derived from the Program or any
|
|
||||||
part thereof, to be licensed as a whole at no charge to all third
|
|
||||||
parties under the terms of this License.
|
|
||||||
|
|
||||||
c) If the modified program normally reads commands interactively
|
|
||||||
when run, you must cause it, when started running for such
|
|
||||||
interactive use in the most ordinary way, to print or display an
|
|
||||||
announcement including an appropriate copyright notice and a
|
|
||||||
notice that there is no warranty (or else, saying that you provide
|
|
||||||
a warranty) and that users may redistribute the program under
|
|
||||||
these conditions, and telling the user how to view a copy of this
|
|
||||||
License. (Exception: if the Program itself is interactive but
|
|
||||||
does not normally print such an announcement, your work based on
|
|
||||||
the Program is not required to print an announcement.)
|
|
||||||
|
|
||||||
These requirements apply to the modified work as a whole. If
|
|
||||||
identifiable sections of that work are not derived from the Program,
|
|
||||||
and can be reasonably considered independent and separate works in
|
|
||||||
themselves, then this License, and its terms, do not apply to those
|
|
||||||
sections when you distribute them as separate works. But when you
|
|
||||||
distribute the same sections as part of a whole which is a work based
|
|
||||||
on the Program, the distribution of the whole must be on the terms of
|
|
||||||
this License, whose permissions for other licensees extend to the
|
|
||||||
entire whole, and thus to each and every part regardless of who wrote it.
|
|
||||||
|
|
||||||
Thus, it is not the intent of this section to claim rights or contest
|
|
||||||
your rights to work written entirely by you; rather, the intent is to
|
|
||||||
exercise the right to control the distribution of derivative or
|
|
||||||
collective works based on the Program.
|
|
||||||
|
|
||||||
In addition, mere aggregation of another work not based on the Program
|
|
||||||
with the Program (or with a work based on the Program) on a volume of
|
|
||||||
a storage or distribution medium does not bring the other work under
|
|
||||||
the scope of this License.
|
|
||||||
|
|
||||||
3. You may copy and distribute the Program (or a work based on it,
|
|
||||||
under Section 2) in object code or executable form under the terms of
|
|
||||||
Sections 1 and 2 above provided that you also do one of the following:
|
|
||||||
|
|
||||||
a) Accompany it with the complete corresponding machine-readable
|
|
||||||
source code, which must be distributed under the terms of Sections
|
|
||||||
1 and 2 above on a medium customarily used for software interchange; or,
|
|
||||||
|
|
||||||
b) Accompany it with a written offer, valid for at least three
|
|
||||||
years, to give any third party, for a charge no more than your
|
|
||||||
cost of physically performing source distribution, a complete
|
|
||||||
machine-readable copy of the corresponding source code, to be
|
|
||||||
distributed under the terms of Sections 1 and 2 above on a medium
|
|
||||||
customarily used for software interchange; or,
|
|
||||||
|
|
||||||
c) Accompany it with the information you received as to the offer
|
|
||||||
to distribute corresponding source code. (This alternative is
|
|
||||||
allowed only for noncommercial distribution and only if you
|
|
||||||
received the program in object code or executable form with such
|
|
||||||
an offer, in accord with Subsection b above.)
|
|
||||||
|
|
||||||
The source code for a work means the preferred form of the work for
|
|
||||||
making modifications to it. For an executable work, complete source
|
|
||||||
code means all the source code for all modules it contains, plus any
|
|
||||||
associated interface definition files, plus the scripts used to
|
|
||||||
control compilation and installation of the executable. However, as a
|
|
||||||
special exception, the source code distributed need not include
|
|
||||||
anything that is normally distributed (in either source or binary
|
|
||||||
form) with the major components (compiler, kernel, and so on) of the
|
|
||||||
operating system on which the executable runs, unless that component
|
|
||||||
itself accompanies the executable.
|
|
||||||
|
|
||||||
If distribution of executable or object code is made by offering
|
|
||||||
access to copy from a designated place, then offering equivalent
|
|
||||||
access to copy the source code from the same place counts as
|
|
||||||
distribution of the source code, even though third parties are not
|
|
||||||
compelled to copy the source along with the object code.
|
|
||||||
|
|
||||||
4. You may not copy, modify, sublicense, or distribute the Program
|
|
||||||
except as expressly provided under this License. Any attempt
|
|
||||||
otherwise to copy, modify, sublicense or distribute the Program is
|
|
||||||
void, and will automatically terminate your rights under this License.
|
|
||||||
However, parties who have received copies, or rights, from you under
|
|
||||||
this License will not have their licenses terminated so long as such
|
|
||||||
parties remain in full compliance.
|
|
||||||
|
|
||||||
5. You are not required to accept this License, since you have not
|
|
||||||
signed it. However, nothing else grants you permission to modify or
|
|
||||||
distribute the Program or its derivative works. These actions are
|
|
||||||
prohibited by law if you do not accept this License. Therefore, by
|
|
||||||
modifying or distributing the Program (or any work based on the
|
|
||||||
Program), you indicate your acceptance of this License to do so, and
|
|
||||||
all its terms and conditions for copying, distributing or modifying
|
|
||||||
the Program or works based on it.
|
|
||||||
|
|
||||||
6. Each time you redistribute the Program (or any work based on the
|
|
||||||
Program), the recipient automatically receives a license from the
|
|
||||||
original licensor to copy, distribute or modify the Program subject to
|
|
||||||
these terms and conditions. You may not impose any further
|
|
||||||
restrictions on the recipients' exercise of the rights granted herein.
|
|
||||||
You are not responsible for enforcing compliance by third parties to
|
|
||||||
this License.
|
|
||||||
|
|
||||||
7. If, as a consequence of a court judgment or allegation of patent
|
|
||||||
infringement or for any other reason (not limited to patent issues),
|
|
||||||
conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot
|
|
||||||
distribute so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you
|
|
||||||
may not distribute the Program at all. For example, if a patent
|
|
||||||
license would not permit royalty-free redistribution of the Program by
|
|
||||||
all those who receive copies directly or indirectly through you, then
|
|
||||||
the only way you could satisfy both it and this License would be to
|
|
||||||
refrain entirely from distribution of the Program.
|
|
||||||
|
|
||||||
If any portion of this section is held invalid or unenforceable under
|
|
||||||
any particular circumstance, the balance of the section is intended to
|
|
||||||
apply and the section as a whole is intended to apply in other
|
|
||||||
circumstances.
|
|
||||||
|
|
||||||
It is not the purpose of this section to induce you to infringe any
|
|
||||||
patents or other property right claims or to contest validity of any
|
|
||||||
such claims; this section has the sole purpose of protecting the
|
|
||||||
integrity of the free software distribution system, which is
|
|
||||||
implemented by public license practices. Many people have made
|
|
||||||
generous contributions to the wide range of software distributed
|
|
||||||
through that system in reliance on consistent application of that
|
|
||||||
system; it is up to the author/donor to decide if he or she is willing
|
|
||||||
to distribute software through any other system and a licensee cannot
|
|
||||||
impose that choice.
|
|
||||||
|
|
||||||
This section is intended to make thoroughly clear what is believed to
|
|
||||||
be a consequence of the rest of this License.
|
|
||||||
|
|
||||||
8. If the distribution and/or use of the Program is restricted in
|
|
||||||
certain countries either by patents or by copyrighted interfaces, the
|
|
||||||
original copyright holder who places the Program under this License
|
|
||||||
may add an explicit geographical distribution limitation excluding
|
|
||||||
those countries, so that distribution is permitted only in or among
|
|
||||||
countries not thus excluded. In such case, this License incorporates
|
|
||||||
the limitation as if written in the body of this License.
|
|
||||||
|
|
||||||
9. The Free Software Foundation may publish revised and/or new versions
|
|
||||||
of the General Public License from time to time. Such new versions will
|
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program
|
|
||||||
specifies a version number of this License which applies to it and "any
|
|
||||||
later version", you have the option of following the terms and conditions
|
|
||||||
either of that version or of any later version published by the Free
|
|
||||||
Software Foundation. If the Program does not specify a version number of
|
|
||||||
this License, you may choose any version ever published by the Free Software
|
|
||||||
Foundation.
|
|
||||||
|
|
||||||
10. If you wish to incorporate parts of the Program into other free
|
|
||||||
programs whose distribution conditions are different, write to the author
|
|
||||||
to ask for permission. For software which is copyrighted by the Free
|
|
||||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
|
||||||
make exceptions for this. Our decision will be guided by the two goals
|
|
||||||
of preserving the free status of all derivatives of our free software and
|
|
||||||
of promoting the sharing and reuse of software generally.
|
|
||||||
|
|
||||||
NO WARRANTY
|
|
||||||
|
|
||||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
|
||||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
|
||||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
|
||||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
|
||||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
|
||||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
|
||||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
|
||||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
|
||||||
REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
|
||||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
|
||||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
|
||||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
|
||||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
|
||||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
|
||||||
POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
convey the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation; either version 2 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along
|
|
||||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
|
||||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program is interactive, make it output a short notice like this
|
|
||||||
when it starts in an interactive mode:
|
|
||||||
|
|
||||||
Gnomovision version 69, Copyright (C) year name of author
|
|
||||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, the commands you use may
|
|
||||||
be called something other than `show w' and `show c'; they could even be
|
|
||||||
mouse-clicks or menu items--whatever suits your program.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or your
|
|
||||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
|
||||||
necessary. Here is a sample; alter the names:
|
|
||||||
|
|
||||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
|
||||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
|
||||||
|
|
||||||
<signature of Ty Coon>, 1 April 1989
|
|
||||||
Ty Coon, President of Vice
|
|
||||||
|
|
||||||
This General Public License does not permit incorporating your program into
|
|
||||||
proprietary programs. If your program is a subroutine library, you may
|
|
||||||
consider it more useful to permit linking proprietary applications with the
|
|
||||||
library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License.
|
|
||||||
288
README.md
288
README.md
@@ -163,18 +163,36 @@ Key settings:
|
|||||||
`YYYY-MM-DD` are listed automatically.
|
`YYYY-MM-DD` are listed automatically.
|
||||||
If a yearly subfolder exists (for example `.../2026/`), it is preferred.
|
If a yearly subfolder exists (for example `.../2026/`), it is preferred.
|
||||||
|
|
||||||
- **calendar_ics_files**
|
- **calendar_ics_files** (REMOVED — replaced by per-slot calendar configuration)
|
||||||
Local calendar `.ics` files (one absolute file path per line).
|
|
||||||
Events are parsed by `sabre/vobject` and shown on matching chronological day pages.
|
- **Calendar Slots** (configured via Admin -> luxtools)
|
||||||
Recurrence and exclusions from the ICS are respected. For timed entries, the
|
The plugin supports 4 calendar slots: `general`, `maintenance`, `slot3`, `slot4`.
|
||||||
page stores the original timestamp and renders the visible time in the
|
Each slot has its own settings:
|
||||||
browser's local timezone.
|
|
||||||
Multi-day events appear on each overlapping day.
|
- **File**: Local `.ics` file path
|
||||||
|
- **CalDAV URL**: Remote CalDAV collection URL (optional)
|
||||||
|
- **Username**: CalDAV authentication username
|
||||||
|
- **Password**: CalDAV authentication password
|
||||||
|
- **Color**: CSS color for calendar widget indicators
|
||||||
|
- **Display**: Where to show that slot's calendar indicator (`None`, `Top Left`, `Top Right`, `Bottom Left`, `Bottom Right`)
|
||||||
|
|
||||||
|
A slot is enabled if it has a local file path or a CalDAV URL configured.
|
||||||
|
The old `calendar_ics_files` setting has been replaced by the `general` slot's file path.
|
||||||
|
|
||||||
|
Default colors:
|
||||||
|
- general: `#4a90d9` (blue)
|
||||||
|
- maintenance: `#e67e22` (orange)
|
||||||
|
- slot3: `#27ae60` (green)
|
||||||
|
- slot4: `#8e44ad` (purple)
|
||||||
|
|
||||||
- **pagelink_search_depth**
|
- **pagelink_search_depth**
|
||||||
Maximum directory depth for `.pagelink` discovery under each configured root.
|
Maximum directory depth for `.pagelink` discovery under each configured root.
|
||||||
`0` means only the root directory itself is checked.
|
`0` means only the root directory itself is checked.
|
||||||
|
|
||||||
|
- **omdb_apikey**
|
||||||
|
OMDb API key used for the movie import toolbar button. The key is sent to the
|
||||||
|
browser for client-side API requests and will be visible in developer tools.
|
||||||
|
|
||||||
### Template style settings
|
### Template style settings
|
||||||
|
|
||||||
The `{{open>...}}` links and directory “open” links use a dedicated color
|
The `{{open>...}}` links and directory “open” links use a dedicated color
|
||||||
@@ -240,7 +258,43 @@ Supported input examples include:
|
|||||||
- `2026-01-30 13:45`
|
- `2026-01-30 13:45`
|
||||||
- `2026-01-30T13:45:00`
|
- `2026-01-30T13:45:00`
|
||||||
|
|
||||||
### 0.2) Page Link: link a page to a folder
|
### 0.2) Editor toolbar: Movie Import
|
||||||
|
|
||||||
|
The plugin adds a toolbar button for importing movie metadata from the [OMDb API](https://www.omdbapi.com/).
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Obtain an OMDb API key from https://www.omdbapi.com/apikey.aspx
|
||||||
|
2. Enter the key in **Admin → luxtools** under "Movie Import (OMDb)".
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
1. Open a page for editing.
|
||||||
|
2. Click the movie icon in the toolbar.
|
||||||
|
3. A prompt appears pre-filled with the first heading from the page (e.g. `Project Hail Mary (2026)`).
|
||||||
|
4. Edit the title if needed and confirm.
|
||||||
|
5. The plugin queries OMDb and inserts a movie metadata block into the editor.
|
||||||
|
|
||||||
|
**Inserted markup example:**
|
||||||
|
```
|
||||||
|
<!-- BEGIN MOVIE -->
|
||||||
|
{{image>https://...poster.jpg}}
|
||||||
|
^ Title | Project Hail Mary |
|
||||||
|
^ Year | 2026 |
|
||||||
|
^ Genre | Adventure, Drama, Sci-Fi |
|
||||||
|
^ Director | Phil Lord, Christopher Miller |
|
||||||
|
^ Actors | Ryan Gosling |
|
||||||
|
^ Plot | A lone astronaut must save Earth... |
|
||||||
|
<!-- END MOVIE -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**Re-import behavior:** Running the import again replaces the existing movie block
|
||||||
|
(delimited by `<!-- BEGIN MOVIE -->` / `<!-- END MOVIE -->` markers) instead of
|
||||||
|
appending a duplicate.
|
||||||
|
|
||||||
|
**Security note:** The OMDb API key is passed to the browser and used for
|
||||||
|
client-side requests. It is visible in browser developer tools and network
|
||||||
|
traffic. This is an intentional tradeoff for this single-user LAN deployment.
|
||||||
|
|
||||||
|
### 0.3) Page Link: link a page to a folder
|
||||||
|
|
||||||
Page linking uses a page-scoped UUID stored in page metadata. This UUID is used
|
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.
|
to link the page to a folder that contains a `.pagelink` file with the same UUID.
|
||||||
@@ -268,27 +322,36 @@ for example:
|
|||||||
{{directory>blobs/&recursive=1}}
|
{{directory>blobs/&recursive=1}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 0.3) Calendar widget
|
### 0.4) Calendar widget
|
||||||
|
|
||||||
Render a basic monthly calendar that links each day to canonical chronological pages:
|
Render a basic monthly calendar that links each day to canonical chronological pages:
|
||||||
|
|
||||||
```
|
```
|
||||||
{{calendar>}}
|
{{calendar>}}
|
||||||
{{calendar>2024-10}}
|
{{calendar>2024-10}}
|
||||||
|
{{calendar>2026-03&size=small}}
|
||||||
|
{{calendar>2026-03&size=large&show_times=0}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `{{calendar>}}` renders the current month.
|
- `{{calendar>}}` renders the current month.
|
||||||
- `{{calendar>YYYY-MM}}` renders a specific month.
|
- `{{calendar>YYYY-MM}}` renders a specific month.
|
||||||
|
- `size=large|small` controls the widget layout and defaults to `large`.
|
||||||
|
- `show_times=1|0` controls inline event times in `large` mode and defaults to `1`.
|
||||||
- Day links target `chronological:YYYY:MM:DD`.
|
- Day links target `chronological:YYYY:MM:DD`.
|
||||||
- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`.
|
- 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.
|
- 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.
|
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
|
||||||
- Calendar output is marked as non-cacheable to keep missing/existing link styling and
|
- Calendar output is marked as non-cacheable to keep missing/existing link styling and
|
||||||
current-day highlighting up to date.
|
current-day highlighting up to date.
|
||||||
|
- Small mode keeps the compact day-number-plus-indicator layout.
|
||||||
|
- Large mode renders inline day events in the month cells and suppresses the corner indicators.
|
||||||
|
- Only slots whose `Display` setting is not `None` participate in widget visibility.
|
||||||
|
- Indicator placement in small mode is configured per slot via the `Display` setting.
|
||||||
|
- Slot colors are reused for both indicators and inline event accents.
|
||||||
|
|
||||||
### 0.4) Virtual chronological day pages
|
### 0.5) Virtual chronological day pages
|
||||||
|
|
||||||
When a canonical day page (for example `chronological:2026:02:13`) does not yet
|
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
|
exist, luxtools renders a virtual page in normal show mode instead of the
|
||||||
@@ -297,12 +360,13 @@ default "page does not exist" output.
|
|||||||
The virtual page includes:
|
The virtual page includes:
|
||||||
|
|
||||||
- a German-formatted heading (for example `Freitag, 13. Februar 2026`)
|
- a German-formatted heading (for example `Freitag, 13. Februar 2026`)
|
||||||
- matching local calendar events from configured `.ics` files (when available)
|
- matching calendar events from all enabled calendar slots (grouped by slot)
|
||||||
|
- maintenance tasks with completion buttons (from the maintenance slot)
|
||||||
- matching day photos (via existing `{{images>...}}` rendering) when available
|
- matching day photos (via existing `{{images>...}}` rendering) when available
|
||||||
|
|
||||||
The page is only created once you edit and save actual content.
|
The page is only created once you edit and save actual content.
|
||||||
|
|
||||||
### 0.5) Cache invalidation
|
### 0.6) Cache invalidation
|
||||||
|
|
||||||
luxtools provides an admin-only **Invalidate Cache** action in the page tools menu.
|
luxtools provides an admin-only **Invalidate Cache** action in the page tools menu.
|
||||||
|
|
||||||
@@ -314,6 +378,206 @@ luxtools provides an admin-only **Invalidate Cache** action in the page tools me
|
|||||||
permission errors).
|
permission errors).
|
||||||
- Also useful when actively adding external photos to the current day page.
|
- Also useful when actively adding external photos to the current day page.
|
||||||
|
|
||||||
|
### 0.7) Multi-calendar slot system
|
||||||
|
|
||||||
|
The plugin supports 4 calendar slots, each with independent configuration for
|
||||||
|
a local `.ics` file, CalDAV URL, authentication, and display color.
|
||||||
|
|
||||||
|
- **general**: The default event calendar. Events appear on day pages like before.
|
||||||
|
- **maintenance**: A task-oriented calendar. Events are treated as tasks with
|
||||||
|
completion tracking. Tasks can be marked complete/reopened via buttons on day pages.
|
||||||
|
- **slot3**, **slot4**: Reserved for future use. Events from these slots appear
|
||||||
|
on day pages with the slot's label as the section heading.
|
||||||
|
|
||||||
|
Calendar data is always read from local `.ics` files for rendering. If a remote
|
||||||
|
CalDAV source is configured, use the sync feature to populate the local file.
|
||||||
|
|
||||||
|
### 0.8) Maintenance task completion
|
||||||
|
|
||||||
|
Maintenance tasks shown on day pages include a "Complete" button. Clicking it:
|
||||||
|
|
||||||
|
1. Updates the event's `STATUS` property in the local `.ics` file.
|
||||||
|
2. If the maintenance slot has a CalDAV URL configured, also updates the remote
|
||||||
|
calendar object.
|
||||||
|
3. Shows visual feedback and reports any remote write failures.
|
||||||
|
|
||||||
|
Completed tasks can be reopened with a "Reopen" button.
|
||||||
|
|
||||||
|
Write-back rules:
|
||||||
|
- `VEVENT` components: `STATUS:TODO` for open, `STATUS:COMPLETED` for completed.
|
||||||
|
- Recurring events: Completion writes an occurrence override/exception to preserve
|
||||||
|
per-occurrence state rather than modifying the master event.
|
||||||
|
|
||||||
|
### 0.9) Event popup
|
||||||
|
|
||||||
|
Clicking any event on a day page opens a popup overlay showing:
|
||||||
|
- Title
|
||||||
|
- Date/time (formatted in the browser's locale)
|
||||||
|
- Location (if available)
|
||||||
|
- Description (if available)
|
||||||
|
- Calendar slot name
|
||||||
|
|
||||||
|
Close the popup by clicking outside it or pressing Escape.
|
||||||
|
|
||||||
|
### 0.10) Maintenance task list syntax
|
||||||
|
|
||||||
|
Embed a list of open maintenance tasks anywhere on a wiki page:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{maintenance_tasks>}}
|
||||||
|
```
|
||||||
|
|
||||||
|
To limit how far back overdue tasks are shown, add the `past` setting:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{maintenance_tasks>&past=14}}
|
||||||
|
```
|
||||||
|
|
||||||
|
This renders all non-completed maintenance tasks due today or earlier, sorted
|
||||||
|
with overdue tasks first (then by date, time, and title).
|
||||||
|
|
||||||
|
The `past` value is an integer number of days. Overdue tasks older than that
|
||||||
|
window are hidden. The default is `30`.
|
||||||
|
|
||||||
|
Each task shows its date, optional time, summary, and a "Complete" button.
|
||||||
|
|
||||||
|
### 0.11) CalDAV sync
|
||||||
|
|
||||||
|
If a slot has a CalDAV URL configured, the admin panel provides a sync button.
|
||||||
|
Triggering sync downloads all calendar objects from the remote CalDAV collection
|
||||||
|
and merges them into the slot's local `.ics` file.
|
||||||
|
|
||||||
|
Sync can also be triggered from any wiki page using the inline syntax:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{calendar_sync>}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The sync button is only visible to admins. Non-admin users see nothing.
|
||||||
|
|
||||||
|
Sync is admin-only and does not run automatically. For scheduled sync, see the
|
||||||
|
automatic sync section below.
|
||||||
|
|
||||||
|
### 0.12) Event popup improvements
|
||||||
|
|
||||||
|
Clicking an event on a day page or in the calendar widget opens the event detail popup.
|
||||||
|
When the popup is opened from a specific day context (e.g. clicking an event in a calendar
|
||||||
|
day cell), the date is hidden and only the time is shown (since the day is already known).
|
||||||
|
|
||||||
|
Clicking empty space inside a calendar day cell opens a day popup listing all events for
|
||||||
|
that day. If there are no events, a "No events" message is shown.
|
||||||
|
|
||||||
|
### 0.13) Event creation
|
||||||
|
|
||||||
|
Authenticated users can create new calendar events from the day popup.
|
||||||
|
|
||||||
|
1. Click empty space in a calendar day cell to open the day popup.
|
||||||
|
2. Click "Create Event".
|
||||||
|
3. Fill in the form: summary (required), date, all-day toggle, start/end time,
|
||||||
|
location, description, and target calendar slot.
|
||||||
|
4. Click "Save".
|
||||||
|
|
||||||
|
The event is written to the local `.ics` file immediately. If the slot has a
|
||||||
|
CalDAV URL configured, the event is also uploaded to the remote server.
|
||||||
|
|
||||||
|
### 0.14) Event editing
|
||||||
|
|
||||||
|
Authenticated users can edit existing calendar events from the event popup.
|
||||||
|
|
||||||
|
1. Click an event to open the detail popup.
|
||||||
|
2. Click "Edit".
|
||||||
|
3. Modify the fields and click "Save".
|
||||||
|
|
||||||
|
For recurring events, editing creates or updates an occurrence override rather
|
||||||
|
than modifying the master event.
|
||||||
|
|
||||||
|
Changes are written to the local `.ics` file first, then pushed to CalDAV if
|
||||||
|
configured.
|
||||||
|
|
||||||
|
### 0.15) Event deletion
|
||||||
|
|
||||||
|
Authenticated users can delete events from the event popup.
|
||||||
|
|
||||||
|
1. Click an event to open the detail popup.
|
||||||
|
2. Click "Delete".
|
||||||
|
3. Confirm the deletion.
|
||||||
|
|
||||||
|
For recurring events, you are asked whether to delete:
|
||||||
|
- **This occurrence**: Adds an EXDATE to the master event.
|
||||||
|
- **This and future occurrences**: Sets an UNTIL date on the RRULE.
|
||||||
|
- **All occurrences**: Removes all components with this UID.
|
||||||
|
|
||||||
|
Deletion is applied to the local `.ics` file first, then to CalDAV if configured.
|
||||||
|
|
||||||
|
### 0.16) Automatic sync proposal
|
||||||
|
|
||||||
|
Automatic calendar sync is not currently implemented. The recommended approach
|
||||||
|
for a long-lived personal DokuWiki plugin:
|
||||||
|
|
||||||
|
**Option A: Cron calling the AJAX endpoint (simplest)**
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/15 * * * * curl -s -X POST 'https://wiki.example.com/lib/exe/ajax.php' \
|
||||||
|
-d 'call=luxtools_calendar_sync§ok=ADMIN_TOKEN' \
|
||||||
|
--cookie 'DokuWikiCookie=SESSION_ID'
|
||||||
|
```
|
||||||
|
|
||||||
|
Pros: Reuses the existing sync endpoint with no code changes.
|
||||||
|
Cons: Requires a valid admin session cookie and security token, which expire and
|
||||||
|
must be refreshed. Fragile for long-term unattended use.
|
||||||
|
|
||||||
|
**Option B: CLI script (recommended)**
|
||||||
|
|
||||||
|
Create `bin/calendar-sync.php` that boots DokuWiki from the command line,
|
||||||
|
loads the plugin, and calls `CalendarSyncService::syncAll()` directly.
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/15 * * * * php /path/to/dokuwiki/lib/plugins/luxtools/bin/calendar-sync.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Pros: No session management, no authentication needed, runs as the web server user.
|
||||||
|
Cons: Requires a small CLI entry point script.
|
||||||
|
|
||||||
|
**Recommendation**: Option B is more robust and maintainable. A CLI script can
|
||||||
|
be as simple as:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// Boot DokuWiki
|
||||||
|
define('DOKU_INC', '/path/to/dokuwiki/');
|
||||||
|
require_once(DOKU_INC . 'inc/init.php');
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
use dokuwiki\plugin\luxtools\CalendarSlot;
|
||||||
|
use dokuwiki\plugin\luxtools\CalendarSyncService;
|
||||||
|
|
||||||
|
$plugin = plugin_load('action', 'luxtools');
|
||||||
|
if (!$plugin) { fwrite(STDERR, "Plugin not loaded\n"); exit(1); }
|
||||||
|
|
||||||
|
$slots = CalendarSlot::loadEnabled($plugin);
|
||||||
|
$result = CalendarSyncService::syncAll($slots);
|
||||||
|
echo $result['ok'] ? "Sync completed.\n" : "Sync completed with errors.\n";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- **Recurring event completion write-back**: For recurring events, completing a
|
||||||
|
single occurrence writes an override/exception component to the `.ics` file.
|
||||||
|
This works well for simple `RRULE` patterns. Some CalDAV servers may handle
|
||||||
|
the override differently. If the override is rejected by the remote server,
|
||||||
|
the local file will still have the correct state, but remote sync may
|
||||||
|
overwrite it on next sync.
|
||||||
|
|
||||||
|
- **Sync direction**: Sync is currently one-directional (remote → local). Local
|
||||||
|
changes (event creation, editing, deletion, task completion) are written back
|
||||||
|
to the remote immediately when CalDAV is configured. However, a full
|
||||||
|
remote-to-local sync may overwrite local changes if the remote still has stale
|
||||||
|
data. The immediate write-back mitigates this in most cases.
|
||||||
|
|
||||||
|
- **Event creation/editing permissions**: Event CRUD operations require an
|
||||||
|
authenticated DokuWiki user. Admin access is not required for event management,
|
||||||
|
only for calendar sync.
|
||||||
|
|
||||||
### 1) List files by glob pattern
|
### 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:
|
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:
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace dokuwiki\plugin\luxtools\test;
|
|
||||||
|
|
||||||
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
|
|
||||||
use DokuWikiTest;
|
|
||||||
|
|
||||||
require_once(__DIR__ . '/../autoload.php');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for local ICS event parsing.
|
|
||||||
*
|
|
||||||
* @group plugin_luxtools
|
|
||||||
* @group plugins
|
|
||||||
*/
|
|
||||||
class ChronologicalIcsEventsTest extends DokuWikiTest
|
|
||||||
{
|
|
||||||
public function testEventsForDateParsesAllDayAndTimedEntries(): void
|
|
||||||
{
|
|
||||||
$dir = TMP_DIR . '/chrono_ics/' . uniqid('case_', true);
|
|
||||||
@mkdir($dir, 0777, true);
|
|
||||||
$ics = $dir . '/calendar.ics';
|
|
||||||
|
|
||||||
$content = "BEGIN:VCALENDAR\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "DTSTART;VALUE=DATE:20260216\n"
|
|
||||||
. "SUMMARY:Ganztag Event\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "DTSTART:20260216T134500\n"
|
|
||||||
. "SUMMARY:Termin A\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "DTSTART:20260217T090000\n"
|
|
||||||
. "SUMMARY:Anderer Tag\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "END:VCALENDAR\n";
|
|
||||||
@file_put_contents($ics, $content);
|
|
||||||
|
|
||||||
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
|
|
||||||
if (count($events) !== 2) {
|
|
||||||
throw new \Exception('Expected 2 events for 2026-02-16, got ' . count($events));
|
|
||||||
}
|
|
||||||
|
|
||||||
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
|
|
||||||
if (!in_array('Ganztag Event', $summaries, true)) {
|
|
||||||
throw new \Exception('Missing all-day event summary');
|
|
||||||
}
|
|
||||||
if (!in_array('Termin A', $summaries, true)) {
|
|
||||||
throw new \Exception('Missing timed event summary');
|
|
||||||
}
|
|
||||||
|
|
||||||
$timed = null;
|
|
||||||
foreach ($events as $event) {
|
|
||||||
if ((string)($event['summary'] ?? '') === 'Termin A') {
|
|
||||||
$timed = $event;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($timed)) {
|
|
||||||
throw new \Exception('Timed event payload missing');
|
|
||||||
}
|
|
||||||
if (trim((string)($timed['startIso'] ?? '')) === '') {
|
|
||||||
throw new \Exception('Timed event should expose startIso for client-side timezone conversion');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEventsForDateReadsMultipleConfiguredFiles(): void
|
|
||||||
{
|
|
||||||
$dir = TMP_DIR . '/chrono_ics/' . uniqid('multi_', true);
|
|
||||||
@mkdir($dir, 0777, true);
|
|
||||||
$ics1 = $dir . '/one.ics';
|
|
||||||
$ics2 = $dir . '/two.ics';
|
|
||||||
|
|
||||||
@file_put_contents($ics1, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T100000\nSUMMARY:Eins\nEND:VEVENT\nEND:VCALENDAR\n");
|
|
||||||
@file_put_contents($ics2, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T110000\nSUMMARY:Zwei\nEND:VEVENT\nEND:VCALENDAR\n");
|
|
||||||
|
|
||||||
$config = $ics1 . "\n" . $ics2;
|
|
||||||
$events = ChronologicalIcsEvents::eventsForDate($config, '2026-02-18');
|
|
||||||
|
|
||||||
if (count($events) !== 2) {
|
|
||||||
throw new \Exception('Expected 2 events from two files, got ' . count($events));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEventsForDateSupportsWeeklyRecurrence(): void
|
|
||||||
{
|
|
||||||
$dir = TMP_DIR . '/chrono_ics/' . uniqid('rrule_', true);
|
|
||||||
@mkdir($dir, 0777, true);
|
|
||||||
$ics = $dir . '/recurring.ics';
|
|
||||||
|
|
||||||
$content = "BEGIN:VCALENDAR\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "UID:weekly-1\n"
|
|
||||||
. "DTSTART:20260205T090000\n"
|
|
||||||
. "RRULE:FREQ=WEEKLY;INTERVAL=1\n"
|
|
||||||
. "SUMMARY:Wiederkehrender Termin\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "END:VCALENDAR\n";
|
|
||||||
@file_put_contents($ics, $content);
|
|
||||||
|
|
||||||
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12');
|
|
||||||
if (count($events) < 1) {
|
|
||||||
throw new \Exception('Expected recurring event on 2026-02-12, got none');
|
|
||||||
}
|
|
||||||
|
|
||||||
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
|
|
||||||
if (!in_array('Wiederkehrender Termin', $summaries, true)) {
|
|
||||||
throw new \Exception('Recurring summary not found on matching date');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEventsForDateRespectsExdateForRecurringEvent(): void
|
|
||||||
{
|
|
||||||
$dir = TMP_DIR . '/chrono_ics/' . uniqid('exdate_', true);
|
|
||||||
@mkdir($dir, 0777, true);
|
|
||||||
$ics = $dir . '/recurring-exdate.ics';
|
|
||||||
|
|
||||||
$content = "BEGIN:VCALENDAR\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "UID:weekly-2\n"
|
|
||||||
. "DTSTART:20260205T090000\n"
|
|
||||||
. "RRULE:FREQ=WEEKLY;COUNT=4\n"
|
|
||||||
. "EXDATE:20260212T090000\n"
|
|
||||||
. "SUMMARY:Termin mit Ausnahme\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "END:VCALENDAR\n";
|
|
||||||
@file_put_contents($ics, $content);
|
|
||||||
|
|
||||||
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12');
|
|
||||||
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
|
|
||||||
if (in_array('Termin mit Ausnahme', $summaries, true)) {
|
|
||||||
throw new \Exception('Recurring event with EXDATE should not appear on excluded day');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEventsForDateKeepsUtcDateAndTimeAsIs(): void
|
|
||||||
{
|
|
||||||
$previousTimezone = date_default_timezone_get();
|
|
||||||
date_default_timezone_set('Europe/Berlin');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$dir = TMP_DIR . '/chrono_ics/' . uniqid('tz_', true);
|
|
||||||
@mkdir($dir, 0777, true);
|
|
||||||
$ics = $dir . '/timezone.ics';
|
|
||||||
|
|
||||||
$content = "BEGIN:VCALENDAR\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "UID:utc-shift\n"
|
|
||||||
. "DTSTART:20260216T233000Z\n"
|
|
||||||
. "SUMMARY:UTC Spaet\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "END:VCALENDAR\n";
|
|
||||||
@file_put_contents($ics, $content);
|
|
||||||
|
|
||||||
$eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
|
|
||||||
$eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17');
|
|
||||||
|
|
||||||
$summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16);
|
|
||||||
$summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17);
|
|
||||||
|
|
||||||
if (!in_array('UTC Spaet', $summaries16, true)) {
|
|
||||||
throw new \Exception('UTC event should stay on its own UTC date');
|
|
||||||
}
|
|
||||||
if (in_array('UTC Spaet', $summaries17, true)) {
|
|
||||||
throw new \Exception('UTC event should not be shifted to next day by server timezone');
|
|
||||||
}
|
|
||||||
|
|
||||||
$utcEvent = null;
|
|
||||||
foreach ($eventsOn16 as $entry) {
|
|
||||||
if ((string)($entry['summary'] ?? '') === 'UTC Spaet') {
|
|
||||||
$utcEvent = $entry;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!is_array($utcEvent)) {
|
|
||||||
throw new \Exception('UTC event payload missing after day match');
|
|
||||||
}
|
|
||||||
if ((string)($utcEvent['time'] ?? '') !== '23:30') {
|
|
||||||
throw new \Exception('UTC event time should remain unchanged (expected 23:30)');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
date_default_timezone_set($previousTimezone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEventsForDateShowsMultiDayAllDayEventOnOverlappingDays(): void
|
|
||||||
{
|
|
||||||
$dir = TMP_DIR . '/chrono_ics/' . uniqid('multiday_', true);
|
|
||||||
@mkdir($dir, 0777, true);
|
|
||||||
$ics = $dir . '/multiday.ics';
|
|
||||||
|
|
||||||
$content = "BEGIN:VCALENDAR\n"
|
|
||||||
. "BEGIN:VEVENT\n"
|
|
||||||
. "UID:multi-day-1\n"
|
|
||||||
. "DTSTART;VALUE=DATE:20260216\n"
|
|
||||||
. "DTEND;VALUE=DATE:20260218\n"
|
|
||||||
. "SUMMARY:Mehrtagesereignis\n"
|
|
||||||
. "END:VEVENT\n"
|
|
||||||
. "END:VCALENDAR\n";
|
|
||||||
@file_put_contents($ics, $content);
|
|
||||||
|
|
||||||
$eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
|
|
||||||
$eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17');
|
|
||||||
$eventsOn18 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-18');
|
|
||||||
|
|
||||||
$summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16);
|
|
||||||
$summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17);
|
|
||||||
$summaries18 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn18);
|
|
||||||
|
|
||||||
if (!in_array('Mehrtagesereignis', $summaries16, true)) {
|
|
||||||
throw new \Exception('Multi-day all-day event should appear on start day');
|
|
||||||
}
|
|
||||||
if (!in_array('Mehrtagesereignis', $summaries17, true)) {
|
|
||||||
throw new \Exception('Multi-day all-day event should appear on overlapping day');
|
|
||||||
}
|
|
||||||
if (in_array('Mehrtagesereignis', $summaries18, true)) {
|
|
||||||
throw new \Exception('Multi-day all-day event should respect exclusive DTEND day');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace dokuwiki\plugin\luxtools\test;
|
|
||||||
|
|
||||||
use DokuWikiTest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General tests for the luxtools plugin
|
|
||||||
*
|
|
||||||
* @group plugin_luxtools
|
|
||||||
* @group plugins
|
|
||||||
*/
|
|
||||||
class GeneralTest extends DokuWikiTest
|
|
||||||
{
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple test to make sure the plugin.info.txt is in correct format
|
|
||||||
*/
|
|
||||||
public function testPluginInfo(): void
|
|
||||||
{
|
|
||||||
$file = __DIR__ . '/../plugin.info.txt';
|
|
||||||
$this->assertFileExists($file);
|
|
||||||
|
|
||||||
$info = confToHash($file);
|
|
||||||
|
|
||||||
$this->assertArrayHasKey('base', $info);
|
|
||||||
$this->assertArrayHasKey('author', $info);
|
|
||||||
$this->assertArrayHasKey('email', $info);
|
|
||||||
$this->assertArrayHasKey('date', $info);
|
|
||||||
$this->assertArrayHasKey('name', $info);
|
|
||||||
$this->assertArrayHasKey('desc', $info);
|
|
||||||
$this->assertArrayHasKey('url', $info);
|
|
||||||
|
|
||||||
$this->assertEquals('luxtools', $info['base']);
|
|
||||||
$this->assertRegExp('/^https?:\/\//', $info['url']);
|
|
||||||
$this->assertTrue(mail_isvalid($info['email']));
|
|
||||||
$this->assertRegExp('/^\d\d\d\d-\d\d-\d\d$/', $info['date']);
|
|
||||||
$this->assertTrue(false !== strtotime($info['date']));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)) {
|
|
||||||
self::markTestSkipped('No config default.php exists -> skipping test');
|
|
||||||
}
|
|
||||||
|
|
||||||
$conf = null;
|
|
||||||
$meta = null;
|
|
||||||
|
|
||||||
include($conf_file);
|
|
||||||
$this->assertIsArray(
|
|
||||||
$conf,
|
|
||||||
'The ' . DOKU_PLUGIN . 'luxtools/conf/default.php file needs to define $conf as an array.'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (file_exists($meta_file)) {
|
|
||||||
include($meta_file);
|
|
||||||
|
|
||||||
if ($meta === null) {
|
|
||||||
// If the file exists but does not define $meta, treat it as empty.
|
|
||||||
$meta = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$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.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace dokuwiki\plugin\luxtools\test;
|
|
||||||
|
|
||||||
use dokuwiki\plugin\luxtools\Path;
|
|
||||||
use DokuWikiTest;
|
|
||||||
|
|
||||||
require_once(__DIR__ . '/../autoload.php');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path related tests for the luxtools plugin
|
|
||||||
*
|
|
||||||
* @group plugin_luxtools
|
|
||||||
* @group plugins
|
|
||||||
*/
|
|
||||||
class PathTest extends DokuWikiTest
|
|
||||||
{
|
|
||||||
|
|
||||||
protected $path;
|
|
||||||
|
|
||||||
public function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
$this->path = new Path(
|
|
||||||
<<<EOT
|
|
||||||
C:\\xampp\\htdocs\\wiki\\
|
|
||||||
\\\\server\\share\\path\\
|
|
||||||
/linux/file/path/
|
|
||||||
/linux/another/path/../..//another/blargh/../path
|
|
||||||
A> alias
|
|
||||||
EOT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test the configuration parsing for paths and aliases
|
|
||||||
*/
|
|
||||||
public function testGetPaths()
|
|
||||||
{
|
|
||||||
$expect = [
|
|
||||||
'C:/xampp/htdocs/wiki/' => [
|
|
||||||
'root' => 'C:/xampp/htdocs/wiki/',
|
|
||||||
'web' => '/lib/plugins/luxtools/file.php?root=C%3A%2Fxampp%2Fhtdocs%2Fwiki%2F&file=',
|
|
||||||
],
|
|
||||||
'\\\\server/share/path/' => [
|
|
||||||
'root' => '\\\\server/share/path/',
|
|
||||||
'web' => '/lib/plugins/luxtools/file.php?root=%5C%5Cserver%2Fshare%2Fpath%2F&file=',
|
|
||||||
],
|
|
||||||
'/linux/file/path/' => [
|
|
||||||
'root' => '/linux/file/path/',
|
|
||||||
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Ffile%2Fpath%2F&file=',
|
|
||||||
],
|
|
||||||
'/linux/another/path/' => [
|
|
||||||
'root' => '/linux/another/path/',
|
|
||||||
'alias' => 'alias/',
|
|
||||||
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
|
|
||||||
],
|
|
||||||
'alias/' => [
|
|
||||||
'root' => '/linux/another/path/',
|
|
||||||
'alias' => 'alias/',
|
|
||||||
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->assertEquals($expect, $this->path->getPaths());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data provider for testGetPathInfoSuccess
|
|
||||||
*/
|
|
||||||
public function providePathInfoSuccess()
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['/linux/another/path', '/linux/another/path/'],
|
|
||||||
['/linux/another/path/foo', '/linux/another/path/foo/'],
|
|
||||||
['alias', '/linux/another/path/'],
|
|
||||||
['alias/foo', '/linux/another/path/foo/'],
|
|
||||||
['C:\\xampp\\htdocs\\wiki', 'C:/xampp/htdocs/wiki/'],
|
|
||||||
['C:\\xampp\\htdocs\\wiki\\foo', 'C:/xampp/htdocs/wiki/foo/'],
|
|
||||||
['\\\\server\\share\\path\\', '\\\\server/share/path/'],
|
|
||||||
['\\\\server\\share\\path\\foo', '\\\\server/share/path/foo/'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @dataProvider providePathInfoSuccess
|
|
||||||
*/
|
|
||||||
public function testGetPathInfoSuccess($path, $expect)
|
|
||||||
{
|
|
||||||
$pathInfo = $this->path->getPathInfo($path);
|
|
||||||
$this->assertEquals($expect, $pathInfo['path']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function providePathInfoFailure()
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['/linux/file/path/../../../etc/'],
|
|
||||||
['W:\\xampp\\htdocs\\wiki\\foo\\bar'],
|
|
||||||
['/'],
|
|
||||||
['./'],
|
|
||||||
['../'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @dataProvider providePathInfoFailure
|
|
||||||
*/
|
|
||||||
public function testGetPathInfoFailure($path)
|
|
||||||
{
|
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace dokuwiki\plugin\luxtools\test;
|
|
||||||
|
|
||||||
use dokuwiki\plugin\luxtools\ScratchpadMap;
|
|
||||||
use DokuWikiTest;
|
|
||||||
|
|
||||||
require_once(__DIR__ . '/../autoload.php');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ScratchpadMap tests for the luxtools plugin
|
|
||||||
*
|
|
||||||
* @group plugin_luxtools
|
|
||||||
* @group plugins
|
|
||||||
*/
|
|
||||||
class ScratchpadMapTest extends DokuWikiTest
|
|
||||||
{
|
|
||||||
public function testResolveAndParsing()
|
|
||||||
{
|
|
||||||
$map = new ScratchpadMap(
|
|
||||||
<<<EOT
|
|
||||||
/var/scratchpads/startpad.txt
|
|
||||||
A> start
|
|
||||||
|
|
||||||
C:\\pads\\notes.md
|
|
||||||
A> notes
|
|
||||||
|
|
||||||
\\\\server\\share\\pads\\team.txt
|
|
||||||
A> team
|
|
||||||
EOT
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertEquals('/var/scratchpads/startpad.txt', $map->resolve('start'));
|
|
||||||
$this->assertEquals('C:/pads/notes.md', $map->resolve('notes'));
|
|
||||||
$this->assertEquals('\\\\server/share/pads/team.txt', $map->resolve('team'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUnknownAliasThrows()
|
|
||||||
{
|
|
||||||
$map = new ScratchpadMap("/tmp/pad.txt\nA> pad\n");
|
|
||||||
$this->expectExceptionMessageMatches('/Unknown scratchpad alias/');
|
|
||||||
$map->resolve('nope');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace dokuwiki\plugin\luxtools\test;
|
|
||||||
|
|
||||||
use DokuWikiTest;
|
|
||||||
use DOMWrap\Document;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for the luxtools plugin.
|
|
||||||
*
|
|
||||||
* These test assume that the directory luxtools has the following content:
|
|
||||||
* - exampledir (directory)
|
|
||||||
* - example2.txt (text file)
|
|
||||||
* - example.txt (text file)
|
|
||||||
* - exampleimage.png (image file)
|
|
||||||
*
|
|
||||||
* @group plugin_luxtools
|
|
||||||
* @group plugins
|
|
||||||
*/
|
|
||||||
class plugin_luxtools_test extends DokuWikiTest
|
|
||||||
{
|
|
||||||
|
|
||||||
public function setUp(): void
|
|
||||||
{
|
|
||||||
global $conf;
|
|
||||||
|
|
||||||
$this->pluginsEnabled[] = 'luxtools';
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
// Setup config so that access to the TMP directory will be allowed
|
|
||||||
// Use the built-in file.php endpoint.
|
|
||||||
$conf ['plugin']['luxtools']['paths'] = TMP_DIR . '/filelistdata/' . "\n" . 'A> /Scape';
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function setUpBeforeClass(): void
|
|
||||||
{
|
|
||||||
parent::setUpBeforeClass();
|
|
||||||
|
|
||||||
// copy test files to test directory
|
|
||||||
\TestUtils::rcopy(TMP_DIR, dirname(__FILE__) . '/filelistdata');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a list of checks on the given document
|
|
||||||
*
|
|
||||||
* @param Document $doc
|
|
||||||
* @param array $structure Array of selectors and expected count or content
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
protected function structureCheck(Document $doc, $structure)
|
|
||||||
{
|
|
||||||
foreach ($structure as $selector => $expected) {
|
|
||||||
if (is_numeric($expected)) {
|
|
||||||
$this->assertEquals(
|
|
||||||
$expected,
|
|
||||||
$doc->find($selector)->count(),
|
|
||||||
'Selector ' . $selector . ' not found'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
$expected,
|
|
||||||
$doc->find($selector)->text(),
|
|
||||||
'Selector ' . $selector . ' not found'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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:
|
|
||||||
// - example.txt
|
|
||||||
// - exampleimage.png
|
|
||||||
$result = strpos($xhtml, 'example.txt');
|
|
||||||
$this->assertFalse($result === false, '"example.txt" not listed');
|
|
||||||
$result = strpos($xhtml, 'exampleimage.png');
|
|
||||||
$this->assertFalse($result === false, '"exampleimage.png" not listed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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:
|
|
||||||
// - exampledir
|
|
||||||
// - example2.txt
|
|
||||||
// - example.txt
|
|
||||||
// - exampleimage.png
|
|
||||||
$result = strpos($xhtml, 'exampledir');
|
|
||||||
$this->assertFalse($result === false, '"exampledir" not listed');
|
|
||||||
$result = strpos($xhtml, 'example2.txt');
|
|
||||||
$this->assertFalse($result === false, '"example2.txt" not listed');
|
|
||||||
$result = strpos($xhtml, 'example.txt');
|
|
||||||
$this->assertFalse($result === false, '"example.txt" not listed');
|
|
||||||
$result = strpos($xhtml, 'exampleimage.png');
|
|
||||||
$this->assertFalse($result === false, '"exampleimage.png" not listed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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.luxtools-plugin' => 1,
|
|
||||||
'div.luxtools-plugin table' => 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->structureCheck($doc, $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 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.luxtools-plugin' => 1,
|
|
||||||
'div.luxtools-plugin table' => 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->structureCheck($doc, $structure);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function checks that the table mode
|
|
||||||
* generates the expected XHTML structure.
|
|
||||||
*/
|
|
||||||
public function test_table()
|
|
||||||
{
|
|
||||||
global $conf;
|
|
||||||
|
|
||||||
// Render filelist
|
|
||||||
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=table&direct=1&recursive=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,
|
|
||||||
'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.
|
|
||||||
*/
|
|
||||||
public function test_images_gallery()
|
|
||||||
{
|
|
||||||
$instructions = p_get_instructions('{{images>' . TMP_DIR . '/filelistdata/*&direct=1}}');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$structure = [
|
|
||||||
'div.luxtools-plugin.luxtools-gallery' => 1,
|
|
||||||
'div.luxtools-plugin.luxtools-gallery a' => 1,
|
|
||||||
'div.luxtools-plugin.luxtools-gallery img' => 1,
|
|
||||||
];
|
|
||||||
$this->structureCheck($doc, $structure);
|
|
||||||
|
|
||||||
$this->assertStringContainsString('exampleimage.png', $xhtml);
|
|
||||||
$this->assertStringContainsString('thumb=1', $xhtml);
|
|
||||||
$this->assertStringContainsString('width="150"', $xhtml);
|
|
||||||
$this->assertStringContainsString('height="150"', $xhtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grouping wrapper should use default flex mode with zero gap.
|
|
||||||
*/
|
|
||||||
public function test_grouping_default_flex()
|
|
||||||
{
|
|
||||||
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
|
||||||
$syntax = '<grouping>'
|
|
||||||
. '{{image>' . $imagePath . '|One|120}}'
|
|
||||||
. '{{image>' . $imagePath . '|Two|120}}'
|
|
||||||
. '</grouping>';
|
|
||||||
|
|
||||||
$instructions = p_get_instructions($syntax);
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$structure = [
|
|
||||||
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
|
|
||||||
'div.luxtools-grouping .luxtools-imagebox' => 2,
|
|
||||||
];
|
|
||||||
$this->structureCheck($doc, $structure);
|
|
||||||
|
|
||||||
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-cols: 2', $style);
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-gap: 0', $style);
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-justify: start', $style);
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-align: start', $style);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grouping wrapper should accept custom flex layout and gap.
|
|
||||||
*/
|
|
||||||
public function test_grouping_custom_flex()
|
|
||||||
{
|
|
||||||
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
|
||||||
$syntax = '<grouping layout="flex" gap="8px">'
|
|
||||||
. '{{image>' . $imagePath . '|One|120}}'
|
|
||||||
. '{{image>' . $imagePath . '|Two|120}}'
|
|
||||||
. '</grouping>';
|
|
||||||
|
|
||||||
$instructions = p_get_instructions($syntax);
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$structure = [
|
|
||||||
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
|
|
||||||
'div.luxtools-grouping .luxtools-imagebox' => 2,
|
|
||||||
];
|
|
||||||
$this->structureCheck($doc, $structure);
|
|
||||||
|
|
||||||
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-gap: 8px', $style);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grouping wrapper should accept justify and align controls.
|
|
||||||
*/
|
|
||||||
public function test_grouping_justify_and_align()
|
|
||||||
{
|
|
||||||
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
|
||||||
$syntax = '<grouping layout="flex" justify="space-between" align="center">'
|
|
||||||
. '{{image>' . $imagePath . '|One|120}}'
|
|
||||||
. '{{image>' . $imagePath . '|Two|120}}'
|
|
||||||
. '</grouping>';
|
|
||||||
|
|
||||||
$instructions = p_get_instructions($syntax);
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-justify: space-between', $style);
|
|
||||||
$this->assertStringContainsString('--luxtools-grouping-align: center', $style);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unknown grouping attributes should render a warning string.
|
|
||||||
*/
|
|
||||||
public function test_grouping_unknown_option_warning()
|
|
||||||
{
|
|
||||||
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
|
|
||||||
$syntax = '<grouping gpa="0.5rem">'
|
|
||||||
. '{{image>' . $imagePath . '|One|120}}'
|
|
||||||
. '</grouping>';
|
|
||||||
|
|
||||||
$instructions = p_get_instructions($syntax);
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$this->assertStringContainsString('[grouping: unknown option(s): gpa]', $xhtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the built-in file endpoint includes the host page id so file.php can
|
|
||||||
* enforce per-page ACL.
|
|
||||||
*/
|
|
||||||
public function test_file_links_include_page_id_for_acl()
|
|
||||||
{
|
|
||||||
global $ID;
|
|
||||||
$ID = 'luxtools:acltest';
|
|
||||||
|
|
||||||
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1}}');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$href = (string)$doc->find('div.luxtools-plugin a')->first()->attr('href');
|
|
||||||
$this->assertNotSame('', $href);
|
|
||||||
$this->assertStringContainsString('lib/plugins/luxtools/file.php', $href);
|
|
||||||
$this->assertStringContainsString('id=luxtools%3Aacltest', $href);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function checks that the open syntax renders an inline link.
|
|
||||||
*/
|
|
||||||
public function test_open_link()
|
|
||||||
{
|
|
||||||
$instructions = p_get_instructions('{{open>/tmp/somewhere|Open here}}');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$structure = [
|
|
||||||
'a.luxtools-open' => 1,
|
|
||||||
];
|
|
||||||
$this->structureCheck($doc, $structure);
|
|
||||||
|
|
||||||
$this->assertStringContainsString('Open here', $xhtml);
|
|
||||||
$this->assertStringContainsString('data-path="/tmp/somewhere"', $xhtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function checks that the directory syntax renders a flat table,
|
|
||||||
* listing both folders and files.
|
|
||||||
*/
|
|
||||||
public function test_directory_table_flat()
|
|
||||||
{
|
|
||||||
$instructions = p_get_instructions('{{directory>' . TMP_DIR . '/filelistdata/&direct=1}}');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
$doc = new Document();
|
|
||||||
$doc->html($xhtml);
|
|
||||||
|
|
||||||
$structure = [
|
|
||||||
'div.luxtools-plugin' => 1,
|
|
||||||
'div.luxtools-plugin table' => 1,
|
|
||||||
'div.luxtools-plugin table > tbody > tr' => 3,
|
|
||||||
'a.luxtools-open' => 1,
|
|
||||||
];
|
|
||||||
$this->structureCheck($doc, $structure);
|
|
||||||
|
|
||||||
// Should list the top-level entries, but not recurse into exampledir
|
|
||||||
$this->assertStringContainsString('example.txt', $xhtml);
|
|
||||||
$this->assertStringContainsString('exampleimage.png', $xhtml);
|
|
||||||
$this->assertStringContainsString('exampledir', $xhtml);
|
|
||||||
$this->assertStringNotContainsString('example2.txt', $xhtml);
|
|
||||||
|
|
||||||
// Directory row should trigger the same behaviour as {{open>...}} for that folder
|
|
||||||
$this->assertStringContainsString('data-path="/Scape/exampledir"', $xhtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strict ISO dates in plain text should be auto-linked to canonical day IDs.
|
|
||||||
*/
|
|
||||||
public function test_auto_link_iso_date_plain_text()
|
|
||||||
{
|
|
||||||
$instructions = p_get_instructions('Meeting with John on 2024-10-24.');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
if (strpos($xhtml, '>2024-10-24</a>') === false) {
|
|
||||||
throw new \Exception('Auto-link text for 2024-10-24 not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') === false) {
|
|
||||||
throw new \Exception('Auto-link target chronological:2024:10:24 not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-linking must not run inside code blocks.
|
|
||||||
*/
|
|
||||||
public function test_auto_link_skips_code_blocks()
|
|
||||||
{
|
|
||||||
$syntax = 'Outside date 2024-10-25.' . "\n\n" . '<code>Inside code 2024-10-24</code>';
|
|
||||||
$instructions = p_get_instructions($syntax);
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
if (strpos($xhtml, '>2024-10-25</a>') === false) {
|
|
||||||
throw new \Exception('Outside date 2024-10-25 was not auto-linked');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos(urldecode($xhtml), 'chronological:2024:10:25') === false) {
|
|
||||||
throw new \Exception('Outside auto-link target chronological:2024:10:25 not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') !== false) {
|
|
||||||
throw new \Exception('Date inside code block was incorrectly auto-linked');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'Inside code 2024-10-24') === false) {
|
|
||||||
throw new \Exception('Code block content was unexpectedly altered');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendar widget should render links to canonical day IDs.
|
|
||||||
*/
|
|
||||||
public function test_calendar_widget_links_canonical_day_ids()
|
|
||||||
{
|
|
||||||
$instructions = p_get_instructions('{{calendar>2024-10}}');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'luxtools-calendar') === false) {
|
|
||||||
throw new \Exception('Calendar container not rendered');
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = urldecode($xhtml);
|
|
||||||
if (strpos($decoded, 'chronological:2024:10:01') === false) {
|
|
||||||
throw new \Exception('Expected canonical day link for 2024-10-01 not found');
|
|
||||||
}
|
|
||||||
if (strpos($decoded, 'chronological:2024:10:31') === false) {
|
|
||||||
throw new \Exception('Expected canonical day link for 2024-10-31 not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($decoded, 'chronological:2024:10') === false) {
|
|
||||||
throw new \Exception('Expected month link chronological:2024:10 not found in header');
|
|
||||||
}
|
|
||||||
if (strpos($decoded, 'chronological:2024') === false) {
|
|
||||||
throw new \Exception('Expected year link chronological:2024 not found in header');
|
|
||||||
}
|
|
||||||
if (strpos($decoded, 'chronological:2024:09') === false) {
|
|
||||||
throw new \Exception('Expected previous month canonical ID chronological:2024:09 not found');
|
|
||||||
}
|
|
||||||
if (strpos($decoded, 'chronological:2024:11') === false) {
|
|
||||||
throw new \Exception('Expected next month canonical ID chronological:2024:11 not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'luxtools-calendar-nav') === false) {
|
|
||||||
throw new \Exception('Calendar navigation container not rendered');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'luxtools-calendar-nav-button') === false) {
|
|
||||||
throw new \Exception('Calendar navigation buttons not rendered');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'data-luxtools-calendar="1"') === false) {
|
|
||||||
throw new \Exception('Calendar JS state attribute not rendered');
|
|
||||||
}
|
|
||||||
if (strpos($xhtml, 'data-luxtools-ajax-url=') === false) {
|
|
||||||
throw new \Exception('Calendar AJAX endpoint metadata not rendered');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'luxtools-calendar-day') === false || strpos($xhtml, '<td class="luxtools-calendar-day') === false) {
|
|
||||||
throw new \Exception('Calendar day cells not rendered as expected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empty calendar target should default to current month rendering.
|
|
||||||
*/
|
|
||||||
public function test_calendar_widget_defaults_to_current_month()
|
|
||||||
{
|
|
||||||
$instructions = p_get_instructions('{{calendar>}}');
|
|
||||||
$xhtml = p_render('xhtml', $instructions, $info);
|
|
||||||
|
|
||||||
if (strpos($xhtml, 'luxtools-calendar-table') === false) {
|
|
||||||
throw new \Exception('Calendar table not rendered for default month');
|
|
||||||
}
|
|
||||||
|
|
||||||
$today = date('Y-m-d');
|
|
||||||
$parts = explode('-', $today);
|
|
||||||
$expected = 'chronological:' . $parts[0] . ':' . $parts[1] . ':' . $parts[2];
|
|
||||||
|
|
||||||
if (strpos(urldecode($xhtml), $expected) === false) {
|
|
||||||
throw new \Exception('Expected canonical link for current date not found: ' . $expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Just a stupid example text file.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Just another stupid example text file.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
1335
action.php
1335
action.php
File diff suppressed because it is too large
Load Diff
135
admin/main.php
135
admin/main.php
@@ -8,6 +8,9 @@ if (!defined('DOKU_INC')) die();
|
|||||||
|
|
||||||
class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
||||||
{
|
{
|
||||||
|
/** @var string[] Calendar slot keys */
|
||||||
|
protected $calendarSlotKeys = ['general', 'slot2', 'slot3', 'slot4'];
|
||||||
|
|
||||||
/** @var string[] */
|
/** @var string[] */
|
||||||
protected $configKeys = [
|
protected $configKeys = [
|
||||||
'paths',
|
'paths',
|
||||||
@@ -29,8 +32,32 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
'gallery_thumb_scale',
|
'gallery_thumb_scale',
|
||||||
'open_service_url',
|
'open_service_url',
|
||||||
'image_base_path',
|
'image_base_path',
|
||||||
'calendar_ics_files',
|
'calendar_general_file',
|
||||||
|
'calendar_general_caldav_url',
|
||||||
|
'calendar_general_username',
|
||||||
|
'calendar_general_password',
|
||||||
|
'calendar_general_color',
|
||||||
|
'calendar_general_display',
|
||||||
|
'calendar_slot2_file',
|
||||||
|
'calendar_slot2_caldav_url',
|
||||||
|
'calendar_slot2_username',
|
||||||
|
'calendar_slot2_password',
|
||||||
|
'calendar_slot2_color',
|
||||||
|
'calendar_slot2_display',
|
||||||
|
'calendar_slot3_file',
|
||||||
|
'calendar_slot3_caldav_url',
|
||||||
|
'calendar_slot3_username',
|
||||||
|
'calendar_slot3_password',
|
||||||
|
'calendar_slot3_color',
|
||||||
|
'calendar_slot3_display',
|
||||||
|
'calendar_slot4_file',
|
||||||
|
'calendar_slot4_caldav_url',
|
||||||
|
'calendar_slot4_username',
|
||||||
|
'calendar_slot4_password',
|
||||||
|
'calendar_slot4_color',
|
||||||
|
'calendar_slot4_display',
|
||||||
'pagelink_search_depth',
|
'pagelink_search_depth',
|
||||||
|
'omdb_apikey',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getMenuText($language)
|
public function getMenuText($language)
|
||||||
@@ -90,14 +117,22 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
$newConf['open_service_url'] = $INPUT->str('open_service_url');
|
$newConf['open_service_url'] = $INPUT->str('open_service_url');
|
||||||
$newConf['image_base_path'] = $INPUT->str('image_base_path');
|
$newConf['image_base_path'] = $INPUT->str('image_base_path');
|
||||||
|
|
||||||
$icsFiles = $INPUT->str('calendar_ics_files');
|
// Calendar slot settings
|
||||||
$icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles);
|
foreach ($this->calendarSlotKeys as $slot) {
|
||||||
$newConf['calendar_ics_files'] = $icsFiles;
|
$newConf['calendar_' . $slot . '_file'] = trim($INPUT->str('calendar_' . $slot . '_file'));
|
||||||
|
$newConf['calendar_' . $slot . '_caldav_url'] = trim($INPUT->str('calendar_' . $slot . '_caldav_url'));
|
||||||
|
$newConf['calendar_' . $slot . '_username'] = trim($INPUT->str('calendar_' . $slot . '_username'));
|
||||||
|
$newConf['calendar_' . $slot . '_password'] = trim($INPUT->str('calendar_' . $slot . '_password'));
|
||||||
|
$newConf['calendar_' . $slot . '_color'] = trim($INPUT->str('calendar_' . $slot . '_color'));
|
||||||
|
$newConf['calendar_' . $slot . '_display'] = trim($INPUT->str('calendar_' . $slot . '_display'));
|
||||||
|
}
|
||||||
|
|
||||||
$depth = (int)$INPUT->int('pagelink_search_depth');
|
$depth = (int)$INPUT->int('pagelink_search_depth');
|
||||||
if ($depth < 0) $depth = 0;
|
if ($depth < 0) $depth = 0;
|
||||||
$newConf['pagelink_search_depth'] = $depth;
|
$newConf['pagelink_search_depth'] = $depth;
|
||||||
|
|
||||||
|
$newConf['omdb_apikey'] = trim($INPUT->str('omdb_apikey'));
|
||||||
|
|
||||||
if ($this->savePluginLocalConf($newConf)) {
|
if ($this->savePluginLocalConf($newConf)) {
|
||||||
msg($this->getLang('saved'), 1);
|
msg($this->getLang('saved'), 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -240,17 +275,101 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
|
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
|
||||||
echo '</label><br />';
|
echo '</label><br />';
|
||||||
|
|
||||||
// calendar_ics_files
|
// Calendar slot settings
|
||||||
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files');
|
$slotLabels = [
|
||||||
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />';
|
'general' => 'General',
|
||||||
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>';
|
'slot2' => 'Slot 2',
|
||||||
|
'slot3' => 'Slot 3',
|
||||||
|
'slot4' => 'Slot 4',
|
||||||
|
];
|
||||||
|
$displayOptions = [
|
||||||
|
'none' => (string)$this->getLang('calendar_slot_display_none'),
|
||||||
|
'top-left' => (string)$this->getLang('calendar_slot_display_top_left'),
|
||||||
|
'top-right' => (string)$this->getLang('calendar_slot_display_top_right'),
|
||||||
|
'bottom-left' => (string)$this->getLang('calendar_slot_display_bottom_left'),
|
||||||
|
'bottom-right' => (string)$this->getLang('calendar_slot_display_bottom_right'),
|
||||||
|
];
|
||||||
|
foreach ($this->calendarSlotKeys as $slot) {
|
||||||
|
echo '<h2>' . hsc($this->getLang('calendar_slot_heading') . ': ' . $slotLabels[$slot]) . '</h2>';
|
||||||
|
|
||||||
|
$prefix = 'calendar_' . $slot . '_';
|
||||||
|
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_file')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="' . hsc($prefix . 'file') . '" value="' . hsc((string)$this->getConf($prefix . 'file')) . '" />';
|
||||||
echo '</label><br />';
|
echo '</label><br />';
|
||||||
|
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_caldav_url')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="' . hsc($prefix . 'caldav_url') . '" value="' . hsc((string)$this->getConf($prefix . 'caldav_url')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_username')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="' . hsc($prefix . 'username') . '" value="' . hsc((string)$this->getConf($prefix . 'username')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_password')) . '</span> ';
|
||||||
|
echo '<input type="password" class="edit" name="' . hsc($prefix . 'password') . '" value="' . hsc((string)$this->getConf($prefix . 'password')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_color')) . '</span> ';
|
||||||
|
echo '<input type="color" name="' . hsc($prefix . 'color') . '" value="' . hsc((string)$this->getConf($prefix . 'color') ?: '#999999') . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
|
||||||
|
$currentDisplay = (string)$this->getConf($prefix . 'display');
|
||||||
|
if ($currentDisplay === '') $currentDisplay = 'none';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_display')) . '</span> ';
|
||||||
|
echo '<select name="' . hsc($prefix . 'display') . '" class="edit">';
|
||||||
|
foreach ($displayOptions as $value => $label) {
|
||||||
|
if ($label === '') $label = $value;
|
||||||
|
$selected = ($currentDisplay === $value) ? ' selected="selected"' : '';
|
||||||
|
echo '<option value="' . hsc($value) . '"' . $selected . '>' . hsc($label) . '</option>';
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
echo '</label><br />';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV Sync button (outside the save form, separate action)
|
||||||
|
$ajaxUrl = DOKU_BASE . 'lib/exe/ajax.php';
|
||||||
|
$sectok = getSecurityToken();
|
||||||
|
echo '<div class="luxtools-admin-sync" style="margin: 1em 0;">';
|
||||||
|
echo '<button type="button" class="button" id="luxtools-sync-btn">'
|
||||||
|
. hsc($this->getLang('calendar_sync_button'))
|
||||||
|
. '</button>';
|
||||||
|
echo '<span id="luxtools-sync-status" style="margin-left: 1em;"></span>';
|
||||||
|
echo '</div>';
|
||||||
|
echo '<script>';
|
||||||
|
echo 'document.getElementById("luxtools-sync-btn").addEventListener("click", function() {';
|
||||||
|
echo ' var btn = this;';
|
||||||
|
echo ' var status = document.getElementById("luxtools-sync-status");';
|
||||||
|
echo ' btn.disabled = true;';
|
||||||
|
echo ' status.textContent = "Syncing...";';
|
||||||
|
echo ' var xhr = new XMLHttpRequest();';
|
||||||
|
echo ' xhr.open("POST", ' . json_encode($ajaxUrl) . ', true);';
|
||||||
|
echo ' xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");';
|
||||||
|
echo ' xhr.onload = function() {';
|
||||||
|
echo ' btn.disabled = false;';
|
||||||
|
echo ' try {';
|
||||||
|
echo ' var r = JSON.parse(xhr.responseText);';
|
||||||
|
echo ' status.textContent = r.message || (r.ok ? "Done" : "Failed");';
|
||||||
|
echo ' status.style.color = r.ok ? "green" : "red";';
|
||||||
|
echo ' } catch(e) { status.textContent = "Error"; status.style.color = "red"; }';
|
||||||
|
echo ' };';
|
||||||
|
echo ' xhr.onerror = function() { btn.disabled = false; status.textContent = "Network error"; status.style.color = "red"; };';
|
||||||
|
echo ' xhr.send("call=luxtools_calendar_sync§ok=" + encodeURIComponent(' . json_encode($sectok) . '));';
|
||||||
|
echo '});';
|
||||||
|
echo '</script>';
|
||||||
|
|
||||||
// pagelink_search_depth
|
// pagelink_search_depth
|
||||||
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
|
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 '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
|
||||||
echo '</label><br />';
|
echo '</label><br />';
|
||||||
|
|
||||||
|
// OMDb API key
|
||||||
|
echo '<h2>' . hsc($this->getLang('omdb_heading')) . '</h2>';
|
||||||
|
echo '<label class="block"><span>' . hsc($this->getLang('omdb_apikey')) . '</span> ';
|
||||||
|
echo '<input type="text" class="edit" name="omdb_apikey" value="' . hsc((string)$this->getConf('omdb_apikey')) . '" />';
|
||||||
|
echo '</label><br />';
|
||||||
|
echo '<p><em>' . hsc($this->getLang('omdb_apikey_note')) . '</em></p>';
|
||||||
|
|
||||||
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
|
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
|
||||||
|
|
||||||
echo '</fieldset>';
|
echo '</fieldset>';
|
||||||
|
|||||||
@@ -37,12 +37,42 @@ $conf['open_service_url'] = 'http://127.0.0.1:8765';
|
|||||||
// Base filesystem path for chronological photo integration.
|
// Base filesystem path for chronological photo integration.
|
||||||
$conf['image_base_path'] = '';
|
$conf['image_base_path'] = '';
|
||||||
|
|
||||||
// Local calendar ICS files (one absolute file path per line).
|
// Calendar slot configuration (4 slots: general, slot2, slot3, slot4)
|
||||||
$conf['calendar_ics_files'] = '';
|
// Each slot has: file, caldav_url, username, password, color, display
|
||||||
|
$conf['calendar_general_file'] = '';
|
||||||
|
$conf['calendar_general_caldav_url'] = '';
|
||||||
|
$conf['calendar_general_username'] = '';
|
||||||
|
$conf['calendar_general_password'] = '';
|
||||||
|
$conf['calendar_general_color'] = '#4a90d9';
|
||||||
|
$conf['calendar_general_display'] = 'none';
|
||||||
|
|
||||||
|
$conf['calendar_slot2_file'] = '';
|
||||||
|
$conf['calendar_slot2_caldav_url'] = '';
|
||||||
|
$conf['calendar_slot2_username'] = '';
|
||||||
|
$conf['calendar_slot2_password'] = '';
|
||||||
|
$conf['calendar_slot2_color'] = '#e67e22';
|
||||||
|
$conf['calendar_slot2_display'] = 'none';
|
||||||
|
|
||||||
|
$conf['calendar_slot3_file'] = '';
|
||||||
|
$conf['calendar_slot3_caldav_url'] = '';
|
||||||
|
$conf['calendar_slot3_username'] = '';
|
||||||
|
$conf['calendar_slot3_password'] = '';
|
||||||
|
$conf['calendar_slot3_color'] = '#27ae60';
|
||||||
|
$conf['calendar_slot3_display'] = 'none';
|
||||||
|
|
||||||
|
$conf['calendar_slot4_file'] = '';
|
||||||
|
$conf['calendar_slot4_caldav_url'] = '';
|
||||||
|
$conf['calendar_slot4_username'] = '';
|
||||||
|
$conf['calendar_slot4_password'] = '';
|
||||||
|
$conf['calendar_slot4_color'] = '#8e44ad';
|
||||||
|
$conf['calendar_slot4_display'] = 'none';
|
||||||
|
|
||||||
// Maximum depth when searching for .pagelink files under allowed roots.
|
// Maximum depth when searching for .pagelink files under allowed roots.
|
||||||
$conf['pagelink_search_depth'] = 3;
|
$conf['pagelink_search_depth'] = 3;
|
||||||
|
|
||||||
|
// OMDb API key for movie metadata import (used client-side).
|
||||||
|
$conf['omdb_apikey'] = '';
|
||||||
|
|
||||||
// Image syntax defaults
|
// Image syntax defaults
|
||||||
$conf['default_image_width'] = 250;
|
$conf['default_image_width'] = 250;
|
||||||
$conf['default_image_align'] = 'right'; // left|right|center
|
$conf['default_image_align'] = 'right'; // left|right|center
|
||||||
|
|||||||
76
deploy.ps1
76
deploy.ps1
@@ -7,22 +7,60 @@
|
|||||||
# .\deploy.ps1 C:\path\to\luxtools
|
# .\deploy.ps1 C:\path\to\luxtools
|
||||||
# .\deploy.ps1 --no-delete # don't delete extraneous files at target
|
# .\deploy.ps1 --no-delete # don't delete extraneous files at target
|
||||||
|
|
||||||
$TARGET = "S:\Web\lib\plugins\luxtools"
|
$TARGET = "S:\7-Infrastructure\lib\plugins\luxtools"
|
||||||
$DRY_RUN = $false
|
$DRY_RUN = $false
|
||||||
$DELETE = $true
|
$DELETE = $true
|
||||||
|
|
||||||
foreach ($arg in $args) {
|
function Resolve-PathUsingExistingCase
|
||||||
if ($arg -eq "--dry-run" -or $arg -eq "-n") {
|
{
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
||||||
|
$root = [System.IO.Path]::GetPathRoot($fullPath).TrimEnd('\\')
|
||||||
|
|
||||||
|
if ($fullPath.TrimEnd('\\') -ieq $root)
|
||||||
|
{
|
||||||
|
return $root
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = Split-Path -Path $fullPath -Parent
|
||||||
|
$leaf = Split-Path -Path $fullPath -Leaf
|
||||||
|
$resolvedParent = Resolve-PathUsingExistingCase -Path $parent
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $resolvedParent)
|
||||||
|
{
|
||||||
|
$match = Get-ChildItem -LiteralPath $resolvedParent -Force -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -ieq $leaf } |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if ($null -ne $match)
|
||||||
|
{
|
||||||
|
return (Join-Path -Path $resolvedParent -ChildPath $match.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Join-Path -Path $resolvedParent -ChildPath $leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($arg in $args)
|
||||||
|
{
|
||||||
|
if ($arg -eq "--dry-run" -or $arg -eq "-n")
|
||||||
|
{
|
||||||
$DRY_RUN = $true
|
$DRY_RUN = $true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($arg -eq "--no-delete") {
|
if ($arg -eq "--no-delete")
|
||||||
|
{
|
||||||
$DELETE = $false
|
$DELETE = $false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($arg -eq "-h" -or $arg -eq "--help") {
|
if ($arg -eq "-h" -or $arg -eq "--help")
|
||||||
|
{
|
||||||
Get-Content $PSCommandPath
|
Get-Content $PSCommandPath
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -30,10 +68,13 @@ foreach ($arg in $args) {
|
|||||||
$TARGET = $arg
|
$TARGET = $arg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$TARGET = Resolve-PathUsingExistingCase -Path $TARGET
|
||||||
|
|
||||||
$SRC_DIR = $PSScriptRoot
|
$SRC_DIR = $PSScriptRoot
|
||||||
|
|
||||||
# Safety checks: make sure source looks like luxtools plugin
|
# Safety checks: make sure source looks like luxtools plugin
|
||||||
if (-not (Test-Path "$SRC_DIR/plugin.info.txt")) {
|
if (-not (Test-Path "$SRC_DIR/plugin.info.txt"))
|
||||||
|
{
|
||||||
Write-Error "Error: '$SRC_DIR' doesn't look like luxtools (missing plugin.info.txt)."
|
Write-Error "Error: '$SRC_DIR' doesn't look like luxtools (missing plugin.info.txt)."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -43,11 +84,14 @@ New-Item -ItemType Directory -Force -Path "$TARGET" | Out-Null
|
|||||||
|
|
||||||
# Safety check: refuse to deploy to an obviously wrong directory.
|
# Safety check: refuse to deploy to an obviously wrong directory.
|
||||||
# Allow empty dir (fresh install) OR existing luxtools plugin dir.
|
# Allow empty dir (fresh install) OR existing luxtools plugin dir.
|
||||||
if (Test-Path "$TARGET/plugin.info.txt") {
|
if (Test-Path "$TARGET/plugin.info.txt")
|
||||||
|
{
|
||||||
$content = Get-Content "$TARGET/plugin.info.txt" -ErrorAction SilentlyContinue
|
$content = Get-Content "$TARGET/plugin.info.txt" -ErrorAction SilentlyContinue
|
||||||
if ($content -match "^base\s+luxtools" -or $content -match "^base\s+luxtools\s+") {
|
if ($content -match "^base\s+luxtools" -or $content -match "^base\s+luxtools\s+")
|
||||||
|
{
|
||||||
# It's a luxtools plugin, allow it
|
# It's a luxtools plugin, allow it
|
||||||
} else {
|
} else
|
||||||
|
{
|
||||||
Write-Error "Error: target '$TARGET' has a plugin.info.txt, but it doesn't look like luxtools."
|
Write-Error "Error: target '$TARGET' has a plugin.info.txt, but it doesn't look like luxtools."
|
||||||
Write-Error "Refusing to deploy."
|
Write-Error "Refusing to deploy."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -60,6 +104,8 @@ $EXCLUDE_DIRS = @(
|
|||||||
"_agent-data",
|
"_agent-data",
|
||||||
".github",
|
".github",
|
||||||
".vscode",
|
".vscode",
|
||||||
|
".zed",
|
||||||
|
".claude",
|
||||||
"_test"
|
"_test"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,21 +135,25 @@ $ROBOCOPY_ARGS = @(
|
|||||||
"/Z" # restartable mode
|
"/Z" # restartable mode
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($EXCLUDE_DIRS.Count -gt 0) {
|
if ($EXCLUDE_DIRS.Count -gt 0)
|
||||||
|
{
|
||||||
$ROBOCOPY_ARGS += "/XD"
|
$ROBOCOPY_ARGS += "/XD"
|
||||||
$ROBOCOPY_ARGS += $EXCLUDE_DIRS
|
$ROBOCOPY_ARGS += $EXCLUDE_DIRS
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($EXCLUDE_FILES.Count -gt 0) {
|
if ($EXCLUDE_FILES.Count -gt 0)
|
||||||
|
{
|
||||||
$ROBOCOPY_ARGS += "/XF"
|
$ROBOCOPY_ARGS += "/XF"
|
||||||
$ROBOCOPY_ARGS += $EXCLUDE_FILES
|
$ROBOCOPY_ARGS += $EXCLUDE_FILES
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($DRY_RUN) {
|
if ($DRY_RUN)
|
||||||
|
{
|
||||||
$ROBOCOPY_ARGS += "/L" # list mode
|
$ROBOCOPY_ARGS += "/L" # list mode
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($DELETE) {
|
if ($DELETE)
|
||||||
|
{
|
||||||
$ROBOCOPY_ARGS += "/MIR" # mirror
|
$ROBOCOPY_ARGS += "/MIR" # mirror
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ RSYNC_ARGS=(
|
|||||||
--exclude=_agent-data/
|
--exclude=_agent-data/
|
||||||
--exclude=.github/
|
--exclude=.github/
|
||||||
--exclude=.vscode/
|
--exclude=.vscode/
|
||||||
|
--exclude=.zed/
|
||||||
|
--exclude=.claude/
|
||||||
--exclude=_test/
|
--exclude=_test/
|
||||||
--exclude=deleted.files
|
--exclude=deleted.files
|
||||||
--exclude=*.swp
|
--exclude=*.swp
|
||||||
|
|||||||
62
dialog.css
Normal file
62
dialog.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* Dialog Infrastructure (shared overlay & popup)
|
||||||
|
* ============================================================ */
|
||||||
|
.luxtools-dialog-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 10000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-dialog {
|
||||||
|
background: @ini_background;
|
||||||
|
border: 1px solid @ini_border;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
padding: 1.5em;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-dialog-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5em;
|
||||||
|
right: 0.75em;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: @ini_text;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-dialog-close:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-dialog-title {
|
||||||
|
margin: 0 0 0.75em 0;
|
||||||
|
padding-right: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-dialog-field {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-dialog-actions {
|
||||||
|
margin-top: 1em;
|
||||||
|
padding-top: 0.75em;
|
||||||
|
border-top: 1px solid @ini_border;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
9
images/movie.svg
Normal file
9
images/movie.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2" ry="2"/>
|
||||||
|
<line x1="2" y1="8" x2="22" y2="8"/>
|
||||||
|
<line x1="6" y1="2" x2="6" y2="8"/>
|
||||||
|
<line x1="10" y1="2" x2="10" y2="8"/>
|
||||||
|
<line x1="14" y1="2" x2="14" y2="8"/>
|
||||||
|
<line x1="18" y1="2" x2="18" y2="8"/>
|
||||||
|
<polygon points="10,12 10,19 16,15.5" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 521 B |
@@ -51,14 +51,33 @@
|
|||||||
|
|
||||||
function getCalendarStateKey(calendar) {
|
function getCalendarStateKey(calendar) {
|
||||||
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
|
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
|
||||||
return 'luxtools.calendar.month.' + baseNs;
|
return 'luxtools_calendar_month_' + baseNs.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPersistCalendarMonth(calendar) {
|
||||||
|
return (calendar.getAttribute('data-luxtools-size') || 'large') === 'small';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookieValue(name) {
|
||||||
|
var match = document.cookie.match('(^|;)\\s*' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*([^;]+)');
|
||||||
|
return match ? decodeURIComponent(match[2]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name, value) {
|
||||||
|
var date = new Date();
|
||||||
|
date.setFullYear(date.getFullYear() + 1);
|
||||||
|
document.cookie = name + '=' + encodeURIComponent(value) + '; expires=' + date.toUTCString() + '; path=/; SameSite=Lax';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCookie(name) {
|
||||||
|
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSavedCalendarMonth(calendar) {
|
function readSavedCalendarMonth(calendar) {
|
||||||
if (!window.localStorage) return null;
|
if (!shouldPersistCalendarMonth(calendar)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var raw = window.localStorage.getItem(getCalendarStateKey(calendar));
|
var raw = getCookieValue(getCalendarStateKey(calendar));
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
var parsed = JSON.parse(raw);
|
var parsed = JSON.parse(raw);
|
||||||
@@ -73,30 +92,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveCalendarMonth(calendar) {
|
function saveCalendarMonth(calendar) {
|
||||||
if (!window.localStorage) return;
|
if (!shouldPersistCalendarMonth(calendar)) return;
|
||||||
|
|
||||||
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
|
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
|
||||||
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
|
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
|
||||||
if (!year || month < 1 || month > 12) return;
|
if (!year || month < 1 || month > 12) return;
|
||||||
|
|
||||||
try {
|
setCookie(getCalendarStateKey(calendar), JSON.stringify({ year: year, month: month }));
|
||||||
window.localStorage.setItem(getCalendarStateKey(calendar), JSON.stringify({
|
|
||||||
year: year,
|
|
||||||
month: month
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
// ignore storage failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSavedCalendarMonth(calendar) {
|
function clearSavedCalendarMonth(calendar) {
|
||||||
if (!window.localStorage) return;
|
if (!shouldPersistCalendarMonth(calendar)) return;
|
||||||
|
deleteCookie(getCalendarStateKey(calendar));
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(getCalendarStateKey(calendar));
|
|
||||||
} catch (e) {
|
|
||||||
// ignore storage failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchCalendarMonth(calendar, year, month) {
|
function fetchCalendarMonth(calendar, year, month) {
|
||||||
@@ -104,11 +111,15 @@
|
|||||||
if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url'));
|
if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url'));
|
||||||
|
|
||||||
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
|
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
|
||||||
|
var size = calendar.getAttribute('data-luxtools-size') || 'large';
|
||||||
|
var showTimes = calendar.getAttribute('data-luxtools-show-times') || '1';
|
||||||
var params = new URLSearchParams({
|
var params = new URLSearchParams({
|
||||||
call: 'luxtools_calendar_month',
|
call: 'luxtools_calendar_month',
|
||||||
year: String(year),
|
year: String(year),
|
||||||
month: String(month),
|
month: String(month),
|
||||||
base: baseNs
|
base: baseNs,
|
||||||
|
size: size,
|
||||||
|
show_times: showTimes
|
||||||
});
|
});
|
||||||
|
|
||||||
var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString();
|
var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString();
|
||||||
@@ -161,6 +172,9 @@
|
|||||||
}
|
}
|
||||||
syncCalendarToday(replacement);
|
syncCalendarToday(replacement);
|
||||||
calendar.replaceWith(replacement);
|
calendar.replaceWith(replacement);
|
||||||
|
if (Luxtools.initChronologicalEventTimes) {
|
||||||
|
Luxtools.initChronologicalEventTimes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCalendarMonth(calendar, targetYear, targetMonth, persistState) {
|
function loadCalendarMonth(calendar, targetYear, targetMonth, persistState) {
|
||||||
@@ -212,11 +226,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreCalendarMonth(calendar) {
|
function restoreCalendarMonth(calendar) {
|
||||||
var saved = readSavedCalendarMonth(calendar);
|
if (!shouldPersistCalendarMonth(calendar)) return;
|
||||||
if (!saved) return;
|
|
||||||
|
|
||||||
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
|
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
|
||||||
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
|
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
|
||||||
|
if (!year || !month) return;
|
||||||
|
|
||||||
|
var saved = readSavedCalendarMonth(calendar);
|
||||||
|
if (!saved) {
|
||||||
|
var now = new Date();
|
||||||
|
var todayYear = now.getFullYear();
|
||||||
|
var todayMonth = now.getMonth() + 1;
|
||||||
|
if (year !== todayYear || month !== todayMonth) {
|
||||||
|
loadCalendarMonth(calendar, todayYear, todayMonth, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (saved.year === year && saved.month === month) return;
|
if (saved.year === year && saved.month === month) return;
|
||||||
|
|
||||||
loadCalendarMonth(calendar, saved.year, saved.month, true);
|
loadCalendarMonth(calendar, saved.year, saved.month, true);
|
||||||
@@ -243,7 +269,17 @@
|
|||||||
navigateCalendarMonth(calendar, direction, true);
|
navigateCalendarMonth(calendar, direction, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateClientDateCookie() {
|
||||||
|
var now = new Date();
|
||||||
|
setCookie('luxtools_client_month', JSON.stringify({
|
||||||
|
year: now.getFullYear(),
|
||||||
|
month: now.getMonth() + 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function initCalendarWidgets() {
|
function initCalendarWidgets() {
|
||||||
|
updateClientDateCookie();
|
||||||
|
|
||||||
var calendars = document.querySelectorAll('div.luxtools-calendar[data-luxtools-calendar="1"]');
|
var calendars = document.querySelectorAll('div.luxtools-calendar[data-luxtools-calendar="1"]');
|
||||||
for (var i = 0; i < calendars.length; i++) {
|
for (var i = 0; i < calendars.length; i++) {
|
||||||
syncCalendarToday(calendars[i]);
|
syncCalendarToday(calendars[i]);
|
||||||
|
|||||||
95
js/dialog.js
Normal file
95
js/dialog.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/* global window, document */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified Dialog Infrastructure
|
||||||
|
*
|
||||||
|
* Provides a shared modal overlay and dialog container that all other
|
||||||
|
* client-side modules (event popups, cache purge, etc.) can use.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Luxtools.Dialog.show(htmlString) – render content and open
|
||||||
|
* Luxtools.Dialog.close() – close the dialog
|
||||||
|
* Luxtools.Dialog.getContainer() – return the dialog DOM element
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
|
|
||||||
|
var Dialog = (function () {
|
||||||
|
var overlay = null;
|
||||||
|
var container = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily create the overlay + dialog container and attach them to
|
||||||
|
* the document body. Wires up click-outside-to-close and Escape.
|
||||||
|
*/
|
||||||
|
function ensureElements() {
|
||||||
|
if (overlay) return;
|
||||||
|
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'luxtools-dialog-overlay';
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'luxtools-dialog';
|
||||||
|
container.setAttribute('role', 'dialog');
|
||||||
|
container.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
|
overlay.appendChild(container);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Close when clicking the backdrop (but not the dialog itself)
|
||||||
|
overlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && overlay && overlay.style.display !== 'none') {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the dialog with the given HTML content.
|
||||||
|
*
|
||||||
|
* The HTML should include a close button with class
|
||||||
|
* `luxtools-dialog-close` – it will be wired up automatically.
|
||||||
|
*
|
||||||
|
* @param {string} html - innerHTML for the dialog container
|
||||||
|
*/
|
||||||
|
function show(html) {
|
||||||
|
ensureElements();
|
||||||
|
container.innerHTML = html;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
|
||||||
|
// Auto-wire the close button inside the rendered content
|
||||||
|
var closeBtn = container.querySelector('.luxtools-dialog-close');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close (hide) the dialog.
|
||||||
|
*/
|
||||||
|
function close() {
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the dialog container element (creates it if necessary).
|
||||||
|
* Useful for querying form inputs after `show()`.
|
||||||
|
*
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function getContainer() {
|
||||||
|
ensureElements();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show: show, close: close, getContainer: getContainer };
|
||||||
|
})();
|
||||||
|
|
||||||
|
Luxtools.Dialog = Dialog;
|
||||||
|
})();
|
||||||
1101
js/event-popup.js
Normal file
1101
js/event-popup.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@
|
|||||||
var lb = null;
|
var lb = null;
|
||||||
var img = null;
|
var img = null;
|
||||||
var cap = null;
|
var cap = null;
|
||||||
|
var prevZone = null;
|
||||||
|
var nextZone = null;
|
||||||
var items = [];
|
var items = [];
|
||||||
var index = 0;
|
var index = 0;
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@
|
|||||||
document.body.appendChild(lb);
|
document.body.appendChild(lb);
|
||||||
img = lb.querySelector('img.luxtools-lightbox-img');
|
img = lb.querySelector('img.luxtools-lightbox-img');
|
||||||
cap = lb.querySelector('.luxtools-lightbox-caption');
|
cap = lb.querySelector('.luxtools-lightbox-caption');
|
||||||
|
prevZone = lb.querySelector('.luxtools-lightbox-zone-prev');
|
||||||
|
nextZone = lb.querySelector('.luxtools-lightbox-zone-next');
|
||||||
|
|
||||||
lb.addEventListener('click', onClick);
|
lb.addEventListener('click', onClick);
|
||||||
}
|
}
|
||||||
@@ -70,6 +74,18 @@
|
|||||||
img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)';
|
img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)';
|
||||||
}
|
}
|
||||||
img.style.cursor = scale > 1 ? 'grab' : '';
|
img.style.cursor = scale > 1 ? 'grab' : '';
|
||||||
|
updateNavigationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNavigationState() {
|
||||||
|
var navigationEnabled = canNavigate();
|
||||||
|
|
||||||
|
if (lb && lb.classList) {
|
||||||
|
lb.classList.toggle('luxtools-lightbox-no-nav', !navigationEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevZone) prevZone.disabled = !navigationEnabled;
|
||||||
|
if (nextZone) nextZone.disabled = !navigationEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetZoom() {
|
function resetZoom() {
|
||||||
@@ -97,6 +113,10 @@
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canNavigate() {
|
||||||
|
return scale <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
function onWheel(e) {
|
function onWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -148,10 +168,10 @@
|
|||||||
if (key === 'Escape') {
|
if (key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
close();
|
close();
|
||||||
} else if (key === 'ArrowRight') {
|
} else if (key === 'ArrowRight' && canNavigate()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
next();
|
next();
|
||||||
} else if (key === 'ArrowLeft') {
|
} else if (key === 'ArrowLeft' && canNavigate()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
prev();
|
prev();
|
||||||
}
|
}
|
||||||
@@ -168,8 +188,16 @@
|
|||||||
if (!t || !t.getAttribute) return;
|
if (!t || !t.getAttribute) return;
|
||||||
var action = t.getAttribute('data-luxtools-action') || '';
|
var action = t.getAttribute('data-luxtools-action') || '';
|
||||||
if (action === 'close') { e.preventDefault(); close(); return; }
|
if (action === 'close') { e.preventDefault(); close(); return; }
|
||||||
if (action === 'next') { e.preventDefault(); next(); return; }
|
if (action === 'next') {
|
||||||
if (action === 'prev') { e.preventDefault(); prev(); return; }
|
e.preventDefault();
|
||||||
|
if (canNavigate()) next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'prev') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canNavigate()) prev();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (t.closest && t.closest('button.luxtools-lightbox-zone')) return;
|
if (t.closest && t.closest('button.luxtools-lightbox-zone')) return;
|
||||||
if (t.closest && t.closest('img.luxtools-lightbox-img')) return;
|
if (t.closest && t.closest('img.luxtools-lightbox-img')) return;
|
||||||
|
|||||||
265
js/main.js
265
js/main.js
@@ -1,7 +1,7 @@
|
|||||||
/* global window, document */
|
/* global window, document */
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
"use strict";
|
||||||
|
|
||||||
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
var Luxtools = window.Luxtools || (window.Luxtools = {});
|
||||||
var Lightbox = Luxtools.Lightbox;
|
var Lightbox = Luxtools.Lightbox;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
function findOpenElement(target) {
|
function findOpenElement(target) {
|
||||||
var el = target;
|
var el = target;
|
||||||
while (el && el !== document) {
|
while (el && el !== document) {
|
||||||
if (el.classList && el.classList.contains('luxtools-open')) return el;
|
if (el.classList && el.classList.contains("luxtools-open")) return el;
|
||||||
el = el.parentNode;
|
el = el.parentNode;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
function findGalleryItem(target) {
|
function findGalleryItem(target) {
|
||||||
var el = target;
|
var el = target;
|
||||||
while (el && el !== document) {
|
while (el && el !== document) {
|
||||||
if (el.classList && el.classList.contains('luxtools-gallery-item')) return el;
|
if (el.classList && el.classList.contains("luxtools-gallery-item"))
|
||||||
|
return el;
|
||||||
el = el.parentNode;
|
el = el.parentNode;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -35,7 +36,9 @@
|
|||||||
// Image gallery lightbox: intercept clicks so we don't navigate away.
|
// Image gallery lightbox: intercept clicks so we don't navigate away.
|
||||||
var galleryItem = findGalleryItem(event.target);
|
var galleryItem = findGalleryItem(event.target);
|
||||||
if (galleryItem && Lightbox && Lightbox.open) {
|
if (galleryItem && Lightbox && Lightbox.open) {
|
||||||
var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null;
|
var gallery = galleryItem.closest
|
||||||
|
? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]')
|
||||||
|
: null;
|
||||||
if (gallery) {
|
if (gallery) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
Lightbox.open(gallery, galleryItem);
|
Lightbox.open(gallery, galleryItem);
|
||||||
@@ -47,17 +50,16 @@
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
// {{open>...}} renders as a link; avoid jumping to '#'.
|
// {{open>...}} renders as a link; avoid jumping to '#'.
|
||||||
if (el.tagName && el.tagName.toLowerCase() === 'a') {
|
if (el.tagName && el.tagName.toLowerCase() === "a") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
var raw = el.getAttribute('data-path') || '';
|
var raw = el.getAttribute("data-path") || "";
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
if (!OpenService || !OpenService.openViaService) return;
|
if (!OpenService || !OpenService.openViaService) return;
|
||||||
|
|
||||||
// Prefer local client service.
|
// Prefer local client service.
|
||||||
OpenService.openViaService(el, raw)
|
OpenService.openViaService(el, raw).catch(function (err) {
|
||||||
.catch(function (err) {
|
|
||||||
// If the browser blocks the request before it reaches localhost (mixed-content,
|
// If the browser blocks the request before it reaches localhost (mixed-content,
|
||||||
// extensions, stricter CORS handling), fall back to a no-CORS GET ping.
|
// extensions, stricter CORS handling), fall back to a no-CORS GET ping.
|
||||||
if (OpenService && OpenService.pingOpenViaImage) {
|
if (OpenService && OpenService.pingOpenViaImage) {
|
||||||
@@ -65,53 +67,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to old behavior (often blocked in modern browsers).
|
// Fallback to old behavior (often blocked in modern browsers).
|
||||||
var url = OpenService && OpenService.normalizeToFileUrl ? OpenService.normalizeToFileUrl(raw) : '';
|
var url =
|
||||||
|
OpenService && OpenService.normalizeToFileUrl
|
||||||
|
? OpenService.normalizeToFileUrl(raw)
|
||||||
|
: "";
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
console.warn('Local client service failed, falling back to file:// navigation:', err);
|
console.warn(
|
||||||
|
"Local client service failed, falling back to file:// navigation:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
window.open(url, '_blank', 'noopener');
|
window.open(url, "_blank", "noopener");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
console.error('Failed to open file URL:', e2);
|
console.error("Failed to open file URL:", e2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initChronologicalEventTimes() {
|
function initChronologicalEventTimes() {
|
||||||
var nodes = document.querySelectorAll('.luxtools-event-time[data-luxtools-start]');
|
var nodes = document.querySelectorAll(
|
||||||
|
".luxtools-event-time[data-luxtools-start]",
|
||||||
|
);
|
||||||
if (!nodes || nodes.length === 0) return;
|
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++) {
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
var node = nodes[i];
|
var node = nodes[i];
|
||||||
var raw = node.getAttribute('data-luxtools-start') || '';
|
var raw = node.getAttribute("data-luxtools-start") || "";
|
||||||
if (!raw) continue;
|
if (!raw) continue;
|
||||||
|
|
||||||
var date = new Date(raw);
|
var match = raw.match(/T(\d{2}):(\d{2})/);
|
||||||
if (isNaN(date.getTime())) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
var label;
|
node.textContent = match[1] + ":" + match[2];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,69 +110,185 @@
|
|||||||
// Purge Cache Dialog
|
// Purge Cache Dialog
|
||||||
// ============================================================
|
// ============================================================
|
||||||
function initPurgeCacheDialog() {
|
function initPurgeCacheDialog() {
|
||||||
var $link = jQuery('a.luxtools-invalidate-cache');
|
document.addEventListener(
|
||||||
if ($link.length === 0) return;
|
"click",
|
||||||
|
function (e) {
|
||||||
|
var link = e.target.closest
|
||||||
|
? e.target.closest("a.luxtools-invalidate-cache")
|
||||||
|
: null;
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
$link.on('click.luxtools', function (e) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
var href = $link.attr('href') || '';
|
var href = link.getAttribute("href") || "";
|
||||||
var lang = (window.LANG && window.LANG.plugins && window.LANG.plugins.luxtools)
|
var lang =
|
||||||
|
window.LANG && window.LANG.plugins && window.LANG.plugins.luxtools
|
||||||
? window.LANG.plugins.luxtools
|
? window.LANG.plugins.luxtools
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
var $dialog = jQuery(
|
var html = '<div class="luxtools-dialog-content">';
|
||||||
'<div>' +
|
html +=
|
||||||
'<p>' + (lang.cache_purge_dialog_intro || 'The DokuWiki cache will always be purged. Optionally also purge the luxtools-specific caches:') + '</p>' +
|
'<button type="button" class="luxtools-dialog-close" aria-label="Close">×</button>';
|
||||||
'<p><label><input type="checkbox" id="luxtools-purge-pagelinks"> <strong>' + (lang.cache_purge_pagelinks_label || 'Pagelinks') + '</strong>' +
|
html +=
|
||||||
' – ' + (lang.cache_purge_pagelinks_desc || 'Purges the pagelink mapping cache') + '</label></p>' +
|
'<h3 class="luxtools-dialog-title">' +
|
||||||
'<p><label><input type="checkbox" id="luxtools-purge-thumbs"> <strong>' + (lang.cache_purge_thumbs_label || 'Thumbnails') + '</strong>' +
|
(lang.cache_purge_dialog_title || "Purge Cache") +
|
||||||
' – ' + (lang.cache_purge_thumbs_desc || 'Purges all cached image thumbnails') + '</label></p>' +
|
"</h3>";
|
||||||
'</div>'
|
html +=
|
||||||
);
|
"<p>" +
|
||||||
|
(lang.cache_purge_dialog_intro ||
|
||||||
|
"The DokuWiki cache will always be purged. Optionally also purge the luxtools-specific caches:") +
|
||||||
|
"</p>";
|
||||||
|
html +=
|
||||||
|
'<p><label><input type="checkbox" id="luxtools-purge-pagelinks"> <strong>' +
|
||||||
|
(lang.cache_purge_pagelinks_label || "Pagelinks") +
|
||||||
|
"</strong>";
|
||||||
|
html +=
|
||||||
|
" – " +
|
||||||
|
(lang.cache_purge_pagelinks_desc ||
|
||||||
|
"Purges the pagelink mapping cache") +
|
||||||
|
"</label></p>";
|
||||||
|
html +=
|
||||||
|
'<p><label><input type="checkbox" id="luxtools-purge-thumbs"> <strong>' +
|
||||||
|
(lang.cache_purge_thumbs_label || "Thumbnails") +
|
||||||
|
"</strong>";
|
||||||
|
html +=
|
||||||
|
" – " +
|
||||||
|
(lang.cache_purge_thumbs_desc ||
|
||||||
|
"Purges all cached image thumbnails") +
|
||||||
|
"</label></p>";
|
||||||
|
html += '<div class="luxtools-dialog-actions">';
|
||||||
|
html +=
|
||||||
|
'<button type="button" class="button luxtools-purge-confirm">' +
|
||||||
|
(lang.cache_purge_confirm || "Purge Cache") +
|
||||||
|
"</button> ";
|
||||||
|
html +=
|
||||||
|
'<button type="button" class="button luxtools-purge-cancel">' +
|
||||||
|
(lang.cache_purge_cancel || "Cancel") +
|
||||||
|
"</button>";
|
||||||
|
html += "</div>";
|
||||||
|
html += "</div>";
|
||||||
|
|
||||||
$dialog.dialog({
|
Luxtools.Dialog.show(html);
|
||||||
title: lang.cache_purge_dialog_title || 'Purge Cache',
|
|
||||||
modal: true,
|
var container = Luxtools.Dialog.getContainer();
|
||||||
width: 420,
|
|
||||||
buttons: [
|
var cancelBtn = container.querySelector(".luxtools-purge-cancel");
|
||||||
{
|
if (cancelBtn) {
|
||||||
text: lang.cache_purge_cancel || 'Cancel',
|
cancelBtn.addEventListener("click", function () {
|
||||||
click: function () { $dialog.dialog('close'); }
|
Luxtools.Dialog.close();
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
text: lang.cache_purge_confirm || 'Purge Cache',
|
|
||||||
click: function () {
|
var confirmBtn = container.querySelector(".luxtools-purge-confirm");
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.addEventListener("click", function () {
|
||||||
var url = href;
|
var url = href;
|
||||||
if ($dialog.find('#luxtools-purge-pagelinks').prop('checked')) {
|
var plCheck = container.querySelector("#luxtools-purge-pagelinks");
|
||||||
url += '&luxtools_purge_pagelinks=1';
|
var thCheck = container.querySelector("#luxtools-purge-thumbs");
|
||||||
|
if (plCheck && plCheck.checked) {
|
||||||
|
url += "&luxtools_purge_pagelinks=1";
|
||||||
}
|
}
|
||||||
if ($dialog.find('#luxtools-purge-thumbs').prop('checked')) {
|
if (thCheck && thCheck.checked) {
|
||||||
url += '&luxtools_purge_thumbs=1';
|
url += "&luxtools_purge_thumbs=1";
|
||||||
}
|
}
|
||||||
$dialog.dialog('close');
|
Luxtools.Dialog.close();
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
close: function () {
|
|
||||||
jQuery(this).dialog('destroy').remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Calendar Sync Button (syntax widget)
|
||||||
|
// ============================================================
|
||||||
|
function initCalendarSyncButtons() {
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
function (e) {
|
||||||
|
var btn = e.target;
|
||||||
|
if (
|
||||||
|
!btn ||
|
||||||
|
!btn.classList ||
|
||||||
|
!btn.classList.contains("luxtools-calendar-sync-btn")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var ajaxUrl = btn.getAttribute("data-luxtools-ajax-url") || "";
|
||||||
|
var sectok = btn.getAttribute("data-luxtools-sectok") || "";
|
||||||
|
if (!ajaxUrl) return;
|
||||||
|
|
||||||
|
var status = btn.parentNode
|
||||||
|
? btn.parentNode.querySelector(".luxtools-calendar-sync-status")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Syncing...";
|
||||||
|
status.style.color = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", ajaxUrl, true);
|
||||||
|
xhr.setRequestHeader(
|
||||||
|
"Content-Type",
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
);
|
||||||
|
xhr.onload = function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (status) {
|
||||||
|
status.textContent = r.message || (r.ok ? "Done" : "Failed");
|
||||||
|
status.style.color = r.ok ? "green" : "red";
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Error";
|
||||||
|
status.style.color = "red";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Network error";
|
||||||
|
status.style.color = "red";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(
|
||||||
|
"call=luxtools_calendar_sync§ok=" + encodeURIComponent(sectok),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Initialize
|
// Initialize
|
||||||
// ============================================================
|
// ============================================================
|
||||||
document.addEventListener('click', onClick, false);
|
document.addEventListener("click", onClick, false);
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener(
|
||||||
|
"DOMContentLoaded",
|
||||||
|
function () {
|
||||||
if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init();
|
if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init();
|
||||||
initChronologicalEventTimes();
|
initChronologicalEventTimes();
|
||||||
if (CalendarWidget && CalendarWidget.init) CalendarWidget.init();
|
if (CalendarWidget && CalendarWidget.init) CalendarWidget.init();
|
||||||
initPurgeCacheDialog();
|
initPurgeCacheDialog();
|
||||||
}, false);
|
initCalendarSyncButtons();
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"DOMContentLoaded",
|
||||||
|
function () {
|
||||||
if (Scratchpads && Scratchpads.init) Scratchpads.init();
|
if (Scratchpads && Scratchpads.init) Scratchpads.init();
|
||||||
}, false);
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Luxtools.initChronologicalEventTimes = initChronologicalEventTimes;
|
||||||
})();
|
})();
|
||||||
|
|||||||
206
js/movie-import.js
Normal file
206
js/movie-import.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/* global window, jQuery */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Movie Import toolbar button for luxtools.
|
||||||
|
*
|
||||||
|
* Fetches movie metadata from OMDb (client-side) and inserts/replaces
|
||||||
|
* a wiki markup block in the editor.
|
||||||
|
*
|
||||||
|
* The OMDb API key is intentionally passed to the browser via JSINFO.
|
||||||
|
* It will be visible in browser developer tools and network requests.
|
||||||
|
* This is an accepted tradeoff for this single-user LAN deployment.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var MARKER_BEGIN = '<!-- BEGIN MOVIE -->';
|
||||||
|
var MARKER_END = '<!-- END MOVIE -->';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OMDb API key from JSINFO (set by PHP in action.php).
|
||||||
|
*/
|
||||||
|
function getApiKey() {
|
||||||
|
if (window.JSINFO && window.JSINFO.luxtools_omdb_apikey) {
|
||||||
|
return String(window.JSINFO.luxtools_omdb_apikey);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get localized string from DokuWiki's LANG object.
|
||||||
|
*/
|
||||||
|
function lang(key, fallback) {
|
||||||
|
if (window.LANG && window.LANG.plugins && window.LANG.plugins.luxtools &&
|
||||||
|
window.LANG.plugins.luxtools[key]) {
|
||||||
|
return String(window.LANG.plugins.luxtools[key]);
|
||||||
|
}
|
||||||
|
return fallback || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first heading from the editor text.
|
||||||
|
* DokuWiki headings use ====== Heading ====== syntax.
|
||||||
|
* Returns the heading text trimmed, or empty string.
|
||||||
|
*/
|
||||||
|
function extractFirstHeading(text) {
|
||||||
|
var match = text.match(/^={2,6}\s*(.+?)\s*={2,6}\s*$/m);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a title string that may contain a trailing year in parentheses.
|
||||||
|
* e.g. "Project Hail Mary (2026)" -> { title: "Project Hail Mary", year: "2026" }
|
||||||
|
* e.g. "Inception" -> { title: "Inception", year: null }
|
||||||
|
*/
|
||||||
|
function parseTitleYear(raw) {
|
||||||
|
var match = raw.match(/^(.+?)\s*\((\d{4})\)\s*$/);
|
||||||
|
if (match) {
|
||||||
|
return { title: match[1].trim(), year: match[2] };
|
||||||
|
}
|
||||||
|
return { title: raw.trim(), year: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch movie data from OMDb.
|
||||||
|
* Returns a Promise that resolves with the parsed JSON response.
|
||||||
|
*/
|
||||||
|
function fetchMovie(apiKey, title, year) {
|
||||||
|
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(apiKey) +
|
||||||
|
'&type=movie&t=' + encodeURIComponent(title);
|
||||||
|
if (year) {
|
||||||
|
url += '&y=' + encodeURIComponent(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jQuery.ajax({
|
||||||
|
url: url,
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the wiki markup block for a movie.
|
||||||
|
*/
|
||||||
|
function buildMarkup(movie) {
|
||||||
|
var lines = [];
|
||||||
|
lines.push(MARKER_BEGIN);
|
||||||
|
|
||||||
|
// Poster image (omit if N/A or empty)
|
||||||
|
var poster = movie.Poster || '';
|
||||||
|
if (poster && poster !== 'N/A') {
|
||||||
|
lines.push('{{image>' + poster + '}}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data table
|
||||||
|
lines.push('^ Title | ' + safe(movie.Title) + ' |');
|
||||||
|
lines.push('^ Year | ' + safe(movie.Year) + ' |');
|
||||||
|
lines.push('^ Runtime | ' + safe(movie.Runtime) + ' |');
|
||||||
|
lines.push('^ Genre | ' + safe(movie.Genre) + ' |');
|
||||||
|
lines.push('^ Director | ' + safe(movie.Director) + ' |');
|
||||||
|
lines.push('^ Actors | ' + safe(movie.Actors) + ' |');
|
||||||
|
lines.push('^ Plot | ' + safe(movie.Plot) + ' |');
|
||||||
|
|
||||||
|
lines.push(MARKER_END);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return value for table cell, replacing N/A with empty string.
|
||||||
|
*/
|
||||||
|
function safe(val) {
|
||||||
|
if (!val || val === 'N/A') return '';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the editor textarea element by id.
|
||||||
|
*/
|
||||||
|
function getEditor(edid) {
|
||||||
|
var id = edid || 'wiki__text';
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main import action: prompt for title, fetch from OMDb, insert/replace markup.
|
||||||
|
*/
|
||||||
|
function importMovie(edid) {
|
||||||
|
var apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
alert(lang('movie_error_no_apikey', 'OMDb API key is not configured. Set it in Admin → luxtools.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var editor = getEditor(edid);
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
var text = editor.value || '';
|
||||||
|
var heading = extractFirstHeading(text);
|
||||||
|
var defaultTitle = heading || '';
|
||||||
|
|
||||||
|
var input = prompt(lang('movie_prompt', 'Enter movie title (optionally with year):'), defaultTitle);
|
||||||
|
if (input === null || input.trim() === '') return;
|
||||||
|
|
||||||
|
var parsed = parseTitleYear(input.trim());
|
||||||
|
|
||||||
|
fetchMovie(apiKey, parsed.title, parsed.year)
|
||||||
|
.done(function (data) {
|
||||||
|
if (!data || data.Response === 'False') {
|
||||||
|
var errMsg = (data && data.Error) ? data.Error : lang('movie_error_not_found', 'Movie not found.');
|
||||||
|
alert(errMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var markup = buildMarkup(data);
|
||||||
|
insertOrReplace(editor, markup);
|
||||||
|
})
|
||||||
|
.fail(function () {
|
||||||
|
alert(lang('movie_error_fetch', 'OMDb lookup failed.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert movie markup into the editor, replacing an existing movie block if present.
|
||||||
|
*/
|
||||||
|
function insertOrReplace(editor, markup) {
|
||||||
|
var text = editor.value || '';
|
||||||
|
|
||||||
|
var beginIdx = text.indexOf(MARKER_BEGIN);
|
||||||
|
var endIdx = text.indexOf(MARKER_END);
|
||||||
|
|
||||||
|
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
||||||
|
// Replace existing block
|
||||||
|
var before = text.substring(0, beginIdx);
|
||||||
|
var after = text.substring(endIdx + MARKER_END.length);
|
||||||
|
editor.value = before + markup + after;
|
||||||
|
} else {
|
||||||
|
// Append after the first heading line, or at the end
|
||||||
|
var headingMatch = text.match(/^(={2,6}\s*.+?\s*={2,6}\s*)$/m);
|
||||||
|
if (headingMatch) {
|
||||||
|
var headingEnd = text.indexOf(headingMatch[0]) + headingMatch[0].length;
|
||||||
|
editor.value = text.substring(0, headingEnd) + '\n\n' + markup + text.substring(headingEnd);
|
||||||
|
} else {
|
||||||
|
editor.value = text + '\n\n' + markup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger input event so DokuWiki knows the content changed
|
||||||
|
try {
|
||||||
|
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
} catch (e) {
|
||||||
|
// IE fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register toolbar button action handler.
|
||||||
|
// DokuWiki toolbar looks for window.addBtnAction<Type> for custom button types.
|
||||||
|
window.addBtnActionLuxtoolsMovieImport = function ($btn, props, edid) {
|
||||||
|
$btn.on('click', function () {
|
||||||
|
importMovie(edid);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return 'luxtools-movie-import';
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -64,8 +64,18 @@ $lang["open_service_url"] =
|
|||||||
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
|
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
|
||||||
$lang["image_base_path"] =
|
$lang["image_base_path"] =
|
||||||
"Basis-Dateisystempfad für die chronologische Foto-Integration.";
|
"Basis-Dateisystempfad für die chronologische Foto-Integration.";
|
||||||
$lang["calendar_ics_files"] =
|
$lang["calendar_slot_heading"] = "Kalender-Slot";
|
||||||
"Lokale Kalender-.ics-Dateien (ein absoluter Dateipfad pro Zeile).";
|
$lang["calendar_slot_file"] = "Lokaler ICS-Dateipfad";
|
||||||
|
$lang["calendar_slot_caldav_url"] = "CalDAV-URL";
|
||||||
|
$lang["calendar_slot_username"] = "Benutzername";
|
||||||
|
$lang["calendar_slot_password"] = "Passwort";
|
||||||
|
$lang["calendar_slot_color"] = "Anzeigefarbe";
|
||||||
|
$lang["calendar_slot_display"] = "Anzeige";
|
||||||
|
$lang["calendar_slot_display_none"] = "Keine";
|
||||||
|
$lang["calendar_slot_display_top_left"] = "Oben links";
|
||||||
|
$lang["calendar_slot_display_top_right"] = "Oben rechts";
|
||||||
|
$lang["calendar_slot_display_bottom_left"] = "Unten links";
|
||||||
|
$lang["calendar_slot_display_bottom_right"] = "Unten rechts";
|
||||||
$lang["pagelink_search_depth"] =
|
$lang["pagelink_search_depth"] =
|
||||||
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
|
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
|
||||||
|
|
||||||
@@ -85,6 +95,10 @@ $lang["pagelink_unlinked"] = "Seite nicht verknüpft";
|
|||||||
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
|
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
|
||||||
$lang["chronological_photos_title"] = "Fotos";
|
$lang["chronological_photos_title"] = "Fotos";
|
||||||
$lang["chronological_events_title"] = "Termine";
|
$lang["chronological_events_title"] = "Termine";
|
||||||
|
$lang["calendar_sync_button"] = "Kalender synchronisieren";
|
||||||
|
$lang["calendar_sync_success"] = "Kalender-Synchronisierung abgeschlossen.";
|
||||||
|
$lang["calendar_sync_error"] = "Kalender-Synchronisierung fehlgeschlagen.";
|
||||||
|
$lang["calendar_sync_partial"] = "Kalender-Synchronisierung mit Fehlern abgeschlossen.";
|
||||||
$lang["cache_invalidate_button"] = "Cache invalidieren";
|
$lang["cache_invalidate_button"] = "Cache invalidieren";
|
||||||
$lang["cache_invalidate_button_title"] = "Gesamten DokuWiki-Cache leeren";
|
$lang["cache_invalidate_button_title"] = "Gesamten DokuWiki-Cache leeren";
|
||||||
$lang["cache_invalidate_success"] = "DokuWiki-Cache invalidiert.";
|
$lang["cache_invalidate_success"] = "DokuWiki-Cache invalidiert.";
|
||||||
@@ -100,3 +114,20 @@ $lang["cache_purge_cancel"] = "Abbrechen";
|
|||||||
$lang["cache_purge_confirm"] = "Cache leeren";
|
$lang["cache_purge_confirm"] = "Cache leeren";
|
||||||
$lang["cache_purge_pagelinks_success"] = "Seitenlink-Cache geleert.";
|
$lang["cache_purge_pagelinks_success"] = "Seitenlink-Cache geleert.";
|
||||||
$lang["cache_purge_thumbs_success"] = "Vorschaubild-Cache geleert.";
|
$lang["cache_purge_thumbs_success"] = "Vorschaubild-Cache geleert.";
|
||||||
|
|
||||||
|
$lang["toolbar_movie_title"] = "Film-Import";
|
||||||
|
$lang["movie_prompt"] = "Filmtitel eingeben (optional mit Jahr):";
|
||||||
|
$lang["movie_error_no_apikey"] = "OMDb-API-Schlüssel nicht konfiguriert. Unter Admin → luxtools einstellen.";
|
||||||
|
$lang["movie_error_not_found"] = "Film nicht gefunden.";
|
||||||
|
$lang["movie_error_fetch"] = "OMDb-Abfrage fehlgeschlagen.";
|
||||||
|
$lang["omdb_heading"] = "Film-Import (OMDb)";
|
||||||
|
$lang["omdb_apikey"] = "OMDb-API-Schlüssel";
|
||||||
|
$lang["omdb_apikey_note"] = "Der API-Schlüssel wird an den Browser übergeben für clientseitige OMDb-Abfragen. Er ist in den Browser-Entwicklertools und Netzwerkanfragen sichtbar.";
|
||||||
|
|
||||||
|
$lang["event_create_success"] = "Termin erstellt.";
|
||||||
|
$lang["event_create_error"] = "Termin konnte nicht erstellt werden.";
|
||||||
|
$lang["event_edit_success"] = "Termin aktualisiert.";
|
||||||
|
$lang["event_edit_error"] = "Termin konnte nicht aktualisiert werden.";
|
||||||
|
$lang["event_delete_success"] = "Termin gelöscht.";
|
||||||
|
$lang["event_delete_error"] = "Termin konnte nicht gelöscht werden.";
|
||||||
|
$lang["event_caldav_push_failed"] = "Lokale Aktualisierung erfolgreich, aber CalDAV-Upload fehlgeschlagen.";
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ $lang["gallery_thumb_scale"] = "Skalierungsfaktor fuer Galerie-Thumbnails. 2 erz
|
|||||||
$lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765).";
|
$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).";
|
$lang["pagelink_search_depth"] = "Maximale Verzeichnisebene fuer .pagelink-Suche (0 = nur Root).";
|
||||||
|
|
||||||
|
$lang["omdb_apikey"] = "OMDb-API-Schlüssel für den Film-Import. Wird clientseitig im Browser verwendet.";
|
||||||
|
|||||||
@@ -64,8 +64,18 @@ $lang["open_service_url"] =
|
|||||||
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
|
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
|
||||||
$lang["image_base_path"] =
|
$lang["image_base_path"] =
|
||||||
"Base filesystem path for chronological photo integration.";
|
"Base filesystem path for chronological photo integration.";
|
||||||
$lang["calendar_ics_files"] =
|
$lang["calendar_slot_heading"] = "Calendar Slot";
|
||||||
"Local calendar .ics files (one absolute file path per line).";
|
$lang["calendar_slot_file"] = "Local ICS file path";
|
||||||
|
$lang["calendar_slot_caldav_url"] = "CalDAV URL";
|
||||||
|
$lang["calendar_slot_username"] = "Username";
|
||||||
|
$lang["calendar_slot_password"] = "Password";
|
||||||
|
$lang["calendar_slot_color"] = "Display color";
|
||||||
|
$lang["calendar_slot_display"] = "Display";
|
||||||
|
$lang["calendar_slot_display_none"] = "None";
|
||||||
|
$lang["calendar_slot_display_top_left"] = "Top Left";
|
||||||
|
$lang["calendar_slot_display_top_right"] = "Top Right";
|
||||||
|
$lang["calendar_slot_display_bottom_left"] = "Bottom Left";
|
||||||
|
$lang["calendar_slot_display_bottom_right"] = "Bottom Right";
|
||||||
$lang["pagelink_search_depth"] =
|
$lang["pagelink_search_depth"] =
|
||||||
"Maximum directory depth for .pagelink search (0 = only root).";
|
"Maximum directory depth for .pagelink search (0 = only root).";
|
||||||
|
|
||||||
@@ -86,6 +96,10 @@ $lang["pagelink_multi_warning"] = "Multiple folders linked";
|
|||||||
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
|
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
|
||||||
$lang["chronological_photos_title"] = "Photos";
|
$lang["chronological_photos_title"] = "Photos";
|
||||||
$lang["chronological_events_title"] = "Events";
|
$lang["chronological_events_title"] = "Events";
|
||||||
|
$lang["calendar_sync_button"] = "Sync Calendars";
|
||||||
|
$lang["calendar_sync_success"] = "Calendar sync completed.";
|
||||||
|
$lang["calendar_sync_error"] = "Calendar sync failed.";
|
||||||
|
$lang["calendar_sync_partial"] = "Calendar sync completed with errors.";
|
||||||
$lang["cache_invalidate_button"] = "Invalidate Cache";
|
$lang["cache_invalidate_button"] = "Invalidate Cache";
|
||||||
$lang["cache_invalidate_button_title"] = "Purge the entire DokuWiki cache";
|
$lang["cache_invalidate_button_title"] = "Purge the entire DokuWiki cache";
|
||||||
$lang["cache_invalidate_success"] = "DokuWiki cache invalidated.";
|
$lang["cache_invalidate_success"] = "DokuWiki cache invalidated.";
|
||||||
@@ -101,3 +115,20 @@ $lang["cache_purge_cancel"] = "Cancel";
|
|||||||
$lang["cache_purge_confirm"] = "Purge Cache";
|
$lang["cache_purge_confirm"] = "Purge Cache";
|
||||||
$lang["cache_purge_pagelinks_success"] = "Pagelinks cache purged.";
|
$lang["cache_purge_pagelinks_success"] = "Pagelinks cache purged.";
|
||||||
$lang["cache_purge_thumbs_success"] = "Thumbnail cache purged.";
|
$lang["cache_purge_thumbs_success"] = "Thumbnail cache purged.";
|
||||||
|
|
||||||
|
$lang["toolbar_movie_title"] = "Movie Import";
|
||||||
|
$lang["movie_prompt"] = "Enter movie title (optionally with year):";
|
||||||
|
$lang["movie_error_no_apikey"] = "OMDb API key is not configured. Set it in Admin → luxtools.";
|
||||||
|
$lang["movie_error_not_found"] = "Movie not found.";
|
||||||
|
$lang["movie_error_fetch"] = "OMDb lookup failed.";
|
||||||
|
$lang["omdb_heading"] = "Movie Import (OMDb)";
|
||||||
|
$lang["omdb_apikey"] = "OMDb API key";
|
||||||
|
$lang["omdb_apikey_note"] = "The API key is passed to the browser for client-side OMDb lookups. It will be visible in browser developer tools and network requests.";
|
||||||
|
|
||||||
|
$lang["event_create_success"] = "Event created.";
|
||||||
|
$lang["event_create_error"] = "Failed to create event.";
|
||||||
|
$lang["event_edit_success"] = "Event updated.";
|
||||||
|
$lang["event_edit_error"] = "Failed to update event.";
|
||||||
|
$lang["event_delete_success"] = "Event deleted.";
|
||||||
|
$lang["event_delete_error"] = "Failed to delete event.";
|
||||||
|
$lang["event_caldav_push_failed"] = "Local update succeeded, but CalDAV upload failed.";
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ $lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharpe
|
|||||||
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} link (e.g. http://127.0.0.1:8765).';
|
$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).';
|
$lang['pagelink_search_depth'] = 'Maximum directory depth for .pagelink search (0 = only root).';
|
||||||
|
|
||||||
|
$lang['omdb_apikey'] = 'OMDb API key for movie metadata import. Used client-side in the browser.';
|
||||||
|
|||||||
537
src/CalDavClient.php
Normal file
537
src/CalDavClient.php
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
use Sabre\VObject\Component\VCalendar;
|
||||||
|
use Sabre\VObject\Reader;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CalDAV client for remote calendar operations.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - Downloading a full calendar collection into a local ICS file (sync)
|
||||||
|
* - Updating the STATUS of a single event/task occurrence on the remote server
|
||||||
|
*
|
||||||
|
* Uses plain PHP curl for HTTP. No additional dependencies required.
|
||||||
|
*/
|
||||||
|
class CalDavClient
|
||||||
|
{
|
||||||
|
/** @var int HTTP timeout in seconds */
|
||||||
|
protected const TIMEOUT = 30;
|
||||||
|
|
||||||
|
/** @var string Last request error message for diagnostics */
|
||||||
|
protected static string $lastRequestError = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the STATUS of a specific event or task on the remote CalDAV server.
|
||||||
|
*
|
||||||
|
* Fetches the calendar object containing the UID, modifies its status,
|
||||||
|
* and PUTs it back using the ETag for conflict detection.
|
||||||
|
*
|
||||||
|
* @param string $caldavUrl CalDAV collection URL
|
||||||
|
* @param string $username HTTP Basic auth username
|
||||||
|
* @param string $password HTTP Basic auth password
|
||||||
|
* @param string $uid Event/task UID
|
||||||
|
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
|
||||||
|
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
|
||||||
|
* @param string $dateIso Occurrence date YYYY-MM-DD
|
||||||
|
* @return string Empty string on success, error message on failure
|
||||||
|
*/
|
||||||
|
public static function updateEventStatus(
|
||||||
|
string $caldavUrl,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $uid,
|
||||||
|
string $recurrenceId,
|
||||||
|
string $newStatus,
|
||||||
|
string $dateIso
|
||||||
|
): string {
|
||||||
|
if ($caldavUrl === '' || $uid === '') return 'Missing CalDAV URL or UID';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the calendar object href for this UID via REPORT
|
||||||
|
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
||||||
|
if ($objectInfo === null) {
|
||||||
|
$msg = "CalDAV: Could not find object with UID '$uid' on server";
|
||||||
|
dbglog($msg);
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$objectHref = $objectInfo['href'];
|
||||||
|
$etag = $objectInfo['etag'];
|
||||||
|
$calendarData = $objectInfo['data'];
|
||||||
|
|
||||||
|
// Parse and update the status
|
||||||
|
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($calendar instanceof VCalendar)) {
|
||||||
|
$msg = "CalDAV: Failed to parse calendar data for UID '$uid'";
|
||||||
|
dbglog($msg);
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = IcsWriter::applyStatusUpdateToCalendar(
|
||||||
|
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
|
||||||
|
);
|
||||||
|
if (!$updated) {
|
||||||
|
$msg = "CalDAV: applyStatusUpdateToCalendar failed for UID '$uid'";
|
||||||
|
dbglog($msg);
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newData = $calendar->serialize();
|
||||||
|
|
||||||
|
// PUT the updated object back with If-Match for conflict detection
|
||||||
|
$putError = self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
|
||||||
|
if ($putError !== '') {
|
||||||
|
dbglog($putError);
|
||||||
|
return $putError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$msg = 'CalDAV: Exception during updateEventStatus: ' . $e->getMessage();
|
||||||
|
dbglog($msg);
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a remote CalDAV calendar collection into the slot's local ICS file.
|
||||||
|
*
|
||||||
|
* Downloads all calendar objects from the collection and merges them
|
||||||
|
* into a single ICS file at the slot's configured file path.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot $slot
|
||||||
|
* @return bool True if sync succeeded
|
||||||
|
*/
|
||||||
|
public static function syncSlot(CalendarSlot $slot): bool
|
||||||
|
{
|
||||||
|
if (!$slot->hasRemoteSource()) return false;
|
||||||
|
|
||||||
|
$caldavUrl = $slot->getCaldavUrl();
|
||||||
|
$username = $slot->getUsername();
|
||||||
|
$password = $slot->getPassword();
|
||||||
|
$localFile = $slot->getFile();
|
||||||
|
|
||||||
|
if ($localFile === '') {
|
||||||
|
// No local file configured - nothing to sync into
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$objects = self::fetchAllCalendarObjects($caldavUrl, $username, $password);
|
||||||
|
if ($objects === null) return false;
|
||||||
|
|
||||||
|
$merged = self::mergeCalendarObjects($objects);
|
||||||
|
if ($merged === '') return false;
|
||||||
|
|
||||||
|
return IcsWriter::atomicWritePublic($localFile, $merged);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific calendar object by UID using a REPORT request.
|
||||||
|
*
|
||||||
|
* @param string $caldavUrl
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $uid
|
||||||
|
* @return array{href: string, etag: string, data: string}|null
|
||||||
|
*/
|
||||||
|
protected static function findObjectByUid(
|
||||||
|
string $caldavUrl,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $uid
|
||||||
|
): ?array {
|
||||||
|
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
|
||||||
|
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
||||||
|
'<D:prop>' .
|
||||||
|
'<D:getetag/>' .
|
||||||
|
'<C:calendar-data/>' .
|
||||||
|
'</D:prop>' .
|
||||||
|
'<C:filter>' .
|
||||||
|
'<C:comp-filter name="VCALENDAR">' .
|
||||||
|
'<C:comp-filter name="VEVENT">' .
|
||||||
|
'<C:prop-filter name="UID">' .
|
||||||
|
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
|
||||||
|
'</C:prop-filter>' .
|
||||||
|
'</C:comp-filter>' .
|
||||||
|
'</C:comp-filter>' .
|
||||||
|
'</C:filter>' .
|
||||||
|
'</C:calendar-query>';
|
||||||
|
|
||||||
|
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
|
||||||
|
'Content-Type: application/xml; charset=utf-8',
|
||||||
|
'Depth: 1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::parseReportResponse($response, $caldavUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all calendar objects from a CalDAV collection.
|
||||||
|
*
|
||||||
|
* @param string $caldavUrl
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @return string[]|null Array of ICS data strings, or null on failure
|
||||||
|
*/
|
||||||
|
protected static function fetchAllCalendarObjects(
|
||||||
|
string $caldavUrl,
|
||||||
|
string $username,
|
||||||
|
string $password
|
||||||
|
): ?array {
|
||||||
|
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
|
||||||
|
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
||||||
|
'<D:prop>' .
|
||||||
|
'<C:calendar-data/>' .
|
||||||
|
'</D:prop>' .
|
||||||
|
'<C:filter>' .
|
||||||
|
'<C:comp-filter name="VCALENDAR"/>' .
|
||||||
|
'</C:filter>' .
|
||||||
|
'</C:calendar-query>';
|
||||||
|
|
||||||
|
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
|
||||||
|
'Content-Type: application/xml; charset=utf-8',
|
||||||
|
'Depth: 1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response === null) return null;
|
||||||
|
|
||||||
|
return self::parseCalendarDataFromMultistatus($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge multiple ICS calendar objects into a single calendar string.
|
||||||
|
*
|
||||||
|
* @param string[] $objects Array of ICS data strings
|
||||||
|
* @return string Merged ICS content
|
||||||
|
*/
|
||||||
|
protected static function mergeCalendarObjects(array $objects): string
|
||||||
|
{
|
||||||
|
if ($objects === []) return '';
|
||||||
|
|
||||||
|
$merged = new VCalendar();
|
||||||
|
$merged->PRODID = '-//LuxTools DokuWiki Plugin//CalDAV Sync//EN';
|
||||||
|
$merged->VERSION = '2.0';
|
||||||
|
|
||||||
|
foreach ($objects as $icsData) {
|
||||||
|
if (trim($icsData) === '') continue;
|
||||||
|
try {
|
||||||
|
$cal = Reader::read($icsData, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($cal instanceof VCalendar)) continue;
|
||||||
|
|
||||||
|
// Copy VTIMEZONE components first
|
||||||
|
foreach ($cal->select('VTIMEZONE') as $tz) {
|
||||||
|
// Check if this timezone already exists in merged
|
||||||
|
$tzid = (string)($tz->TZID ?? '');
|
||||||
|
$exists = false;
|
||||||
|
foreach ($merged->select('VTIMEZONE') as $existingTz) {
|
||||||
|
if ((string)($existingTz->TZID ?? '') === $tzid) {
|
||||||
|
$exists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$exists) {
|
||||||
|
$merged->add(clone $tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy VEVENT components
|
||||||
|
foreach ($cal->select('VEVENT') as $component) {
|
||||||
|
$merged->add(clone $component);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Skip malformed objects
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $merged->serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT a calendar object back to the server.
|
||||||
|
*
|
||||||
|
* @param string $href Full URL of the calendar object
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $data ICS data to write
|
||||||
|
* @param string $etag ETag for If-Match header (empty to skip)
|
||||||
|
* @return string Empty string on success, error message on failure
|
||||||
|
*/
|
||||||
|
protected static function putCalendarObject(
|
||||||
|
string $href,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $data,
|
||||||
|
string $etag
|
||||||
|
): string {
|
||||||
|
$headers = [
|
||||||
|
'Content-Type: text/calendar; charset=utf-8',
|
||||||
|
];
|
||||||
|
if ($etag !== '') {
|
||||||
|
$headers[] = 'If-Match: ' . $etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = self::request('PUT', $href, $username, $password, $data, $headers);
|
||||||
|
if ($response === null) {
|
||||||
|
return self::$lastRequestError ?: 'CalDAV PUT failed (unknown error)';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a REPORT multistatus response to extract href, etag, and calendar data
|
||||||
|
* for the first matching object.
|
||||||
|
*
|
||||||
|
* @param string $xml
|
||||||
|
* @param string $baseUrl
|
||||||
|
* @return array{href: string, etag: string, data: string}|null
|
||||||
|
*/
|
||||||
|
protected static function parseReportResponse(string $xml, string $baseUrl): ?array
|
||||||
|
{
|
||||||
|
$doc = self::parseXml($xml);
|
||||||
|
if ($doc === null) return null;
|
||||||
|
|
||||||
|
$doc->registerXPathNamespace('d', 'DAV:');
|
||||||
|
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||||
|
|
||||||
|
$responses = $doc->xpath('//d:response');
|
||||||
|
if (!$responses || count($responses) === 0) return null;
|
||||||
|
|
||||||
|
foreach ($responses as $resp) {
|
||||||
|
$resp->registerXPathNamespace('d', 'DAV:');
|
||||||
|
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||||
|
|
||||||
|
$hrefs = $resp->xpath('d:href');
|
||||||
|
$href = ($hrefs && count($hrefs) > 0) ? trim((string)$hrefs[0]) : '';
|
||||||
|
|
||||||
|
$etags = $resp->xpath('.//d:getetag');
|
||||||
|
$etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : '';
|
||||||
|
|
||||||
|
$caldata = $resp->xpath('.//cal:calendar-data');
|
||||||
|
$data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : '';
|
||||||
|
|
||||||
|
if ($href === '' || $data === '') continue;
|
||||||
|
|
||||||
|
// Resolve relative href to absolute URL
|
||||||
|
if (strpos($href, 'http') !== 0) {
|
||||||
|
$parsed = parse_url($baseUrl);
|
||||||
|
$scheme = ($parsed['scheme'] ?? 'https');
|
||||||
|
$host = ($parsed['host'] ?? '');
|
||||||
|
$port = isset($parsed['port']) ? (':' . $parsed['port']) : '';
|
||||||
|
$href = $scheme . '://' . $host . $port . $href;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'href' => $href,
|
||||||
|
'etag' => $etag,
|
||||||
|
'data' => $data,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse calendar-data elements from a CalDAV multistatus response.
|
||||||
|
*
|
||||||
|
* @param string $xml
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
protected static function parseCalendarDataFromMultistatus(string $xml): array
|
||||||
|
{
|
||||||
|
$doc = self::parseXml($xml);
|
||||||
|
if ($doc === null) return [];
|
||||||
|
|
||||||
|
$doc->registerXPathNamespace('d', 'DAV:');
|
||||||
|
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
$responses = $doc->xpath('//d:response');
|
||||||
|
if (!$responses) return [];
|
||||||
|
|
||||||
|
foreach ($responses as $resp) {
|
||||||
|
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||||
|
$caldata = $resp->xpath('.//cal:calendar-data');
|
||||||
|
if ($caldata && count($caldata) > 0) {
|
||||||
|
$data = trim((string)$caldata[0]);
|
||||||
|
if ($data !== '') {
|
||||||
|
$results[] = $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an XML string safely.
|
||||||
|
*
|
||||||
|
* @param string $xml
|
||||||
|
* @return \SimpleXMLElement|null
|
||||||
|
*/
|
||||||
|
protected static function parseXml(string $xml): ?\SimpleXMLElement
|
||||||
|
{
|
||||||
|
if (trim($xml) === '') return null;
|
||||||
|
|
||||||
|
// Disable external entity loading for security
|
||||||
|
$prev = libxml_use_internal_errors(true);
|
||||||
|
try {
|
||||||
|
$doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET);
|
||||||
|
libxml_clear_errors();
|
||||||
|
return ($doc !== false) ? $doc : null;
|
||||||
|
} finally {
|
||||||
|
libxml_use_internal_errors($prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public wrapper for findObjectByUid.
|
||||||
|
*
|
||||||
|
* @param string $caldavUrl
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $uid
|
||||||
|
* @return array{href: string, etag: string, data: string}|null
|
||||||
|
*/
|
||||||
|
public static function findObjectByUidPublic(
|
||||||
|
string $caldavUrl,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $uid
|
||||||
|
): ?array {
|
||||||
|
return self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public wrapper for putCalendarObject.
|
||||||
|
*
|
||||||
|
* @param string $href
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $data
|
||||||
|
* @param string $etag
|
||||||
|
* @return string Empty string on success, error on failure
|
||||||
|
*/
|
||||||
|
public static function putCalendarObjectPublic(
|
||||||
|
string $href,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $data,
|
||||||
|
string $etag
|
||||||
|
): string {
|
||||||
|
return self::putCalendarObject($href, $username, $password, $data, $etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a calendar object from the server.
|
||||||
|
*
|
||||||
|
* @param string $href Full URL of the calendar object
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $etag ETag for If-Match header (empty to skip)
|
||||||
|
* @return bool True on success
|
||||||
|
*/
|
||||||
|
public static function deleteCalendarObject(
|
||||||
|
string $href,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $etag
|
||||||
|
): bool {
|
||||||
|
$headers = [];
|
||||||
|
if ($etag !== '') {
|
||||||
|
$headers[] = 'If-Match: ' . $etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = self::request('DELETE', $href, $username, $password, '', $headers);
|
||||||
|
return $response !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform an HTTP request to a CalDAV server.
|
||||||
|
*
|
||||||
|
* @param string $method HTTP method (GET, PUT, REPORT, PROPFIND, etc.)
|
||||||
|
* @param string $url Full URL
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $body Request body (empty for GET)
|
||||||
|
* @param string[] $headers Additional HTTP headers
|
||||||
|
* @return string|null Response body, or null on failure
|
||||||
|
*/
|
||||||
|
protected static function request(
|
||||||
|
string $method,
|
||||||
|
string $url,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $body = '',
|
||||||
|
array $headers = []
|
||||||
|
): ?string {
|
||||||
|
self::$lastRequestError = '';
|
||||||
|
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
self::$lastRequestError = 'CalDAV: curl extension not available';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
if ($ch === false) {
|
||||||
|
self::$lastRequestError = 'CalDAV: curl_init() failed';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
|
||||||
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
if ($username !== '') {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||||
|
curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
if ($body !== '') {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP headers
|
||||||
|
if ($headers !== []) {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture HTTP status code
|
||||||
|
$responseBody = curl_exec($ch);
|
||||||
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if (!is_string($responseBody)) {
|
||||||
|
self::$lastRequestError = "CalDAV $method failed: curl error: $curlError";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept 2xx and 207 (multistatus) responses
|
||||||
|
if ($httpCode >= 200 && $httpCode < 300) {
|
||||||
|
return $responseBody;
|
||||||
|
}
|
||||||
|
if ($httpCode === 207) {
|
||||||
|
return $responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$lastRequestError = "CalDAV $method failed: HTTP $httpCode";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/CalendarEvent.php
Normal file
55
src/CalendarEvent.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized calendar event/task for internal use.
|
||||||
|
*
|
||||||
|
* All calendar data (from any slot, any source) is converted into this
|
||||||
|
* structure before rendering or querying.
|
||||||
|
*/
|
||||||
|
class CalendarEvent
|
||||||
|
{
|
||||||
|
/** @var string Calendar slot key (e.g. 'general', 'slot2') */
|
||||||
|
public $slotKey;
|
||||||
|
|
||||||
|
/** @var string Unique source event UID */
|
||||||
|
public $uid;
|
||||||
|
|
||||||
|
/** @var string Recurrence ID (empty for non-recurring or master) */
|
||||||
|
public $recurrenceId;
|
||||||
|
|
||||||
|
/** @var string Event summary/title */
|
||||||
|
public $summary;
|
||||||
|
|
||||||
|
/** @var string ISO 8601 start date/time */
|
||||||
|
public $startIso;
|
||||||
|
|
||||||
|
/** @var string ISO 8601 end date/time (may be empty) */
|
||||||
|
public $endIso;
|
||||||
|
|
||||||
|
/** @var bool Whether this is an all-day event */
|
||||||
|
public $allDay;
|
||||||
|
|
||||||
|
/** @var string Formatted time string (HH:MM) or empty for all-day */
|
||||||
|
public $time;
|
||||||
|
|
||||||
|
/** @var string Location (may be empty) */
|
||||||
|
public $location;
|
||||||
|
|
||||||
|
/** @var string Description (may be empty) */
|
||||||
|
public $description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status: empty, CONFIRMED, TENTATIVE, CANCELLED, TODO, COMPLETED,
|
||||||
|
* IN-PROCESS, NEEDS-ACTION.
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $status;
|
||||||
|
|
||||||
|
/** @var string Component type from source: VEVENT */
|
||||||
|
public $componentType;
|
||||||
|
|
||||||
|
/** @var string The date (YYYY-MM-DD) this event applies to */
|
||||||
|
public $dateIso;
|
||||||
|
}
|
||||||
469
src/CalendarService.php
Normal file
469
src/CalendarService.php
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slot-aware calendar service.
|
||||||
|
*
|
||||||
|
* Provides normalized event data grouped by slot for rendering and widget indicators.
|
||||||
|
*/
|
||||||
|
class CalendarService
|
||||||
|
{
|
||||||
|
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */
|
||||||
|
protected static $dayCache = [];
|
||||||
|
|
||||||
|
/** @var array<string,VCalendar|null> In-request cache keyed by file path */
|
||||||
|
protected static $vcalCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all normalized events for a given date across all enabled slots.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot[] $slots Keyed by slot key
|
||||||
|
* @param string $dateIso YYYY-MM-DD
|
||||||
|
* @return CalendarEvent[] Sorted: all-day first, then by time, then by title
|
||||||
|
*/
|
||||||
|
public static function eventsForDate(array $slots, string $dateIso): array
|
||||||
|
{
|
||||||
|
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||||
|
|
||||||
|
$all = [];
|
||||||
|
foreach ($slots as $slot) {
|
||||||
|
if (!$slot->isEnabled()) continue;
|
||||||
|
$events = self::slotEventsForDate($slot, $dateIso);
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$all[] = $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($all, [self::class, 'compareEvents']);
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events for a specific slot and date.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot $slot
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return CalendarEvent[]
|
||||||
|
*/
|
||||||
|
public static function slotEventsForDate(CalendarSlot $slot, string $dateIso): array
|
||||||
|
{
|
||||||
|
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||||
|
if (!$slot->isEnabled()) return [];
|
||||||
|
|
||||||
|
$cacheKey = $slot->getKey() . '|' . $dateIso;
|
||||||
|
if (isset(self::$dayCache[$cacheKey])) {
|
||||||
|
return self::$dayCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
$file = $slot->getFile();
|
||||||
|
if ($file !== '' && is_file($file) && is_readable($file)) {
|
||||||
|
$events = self::parseEventsFromFile($file, $slot->getKey(), $dateIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$dayCache[$cacheKey] = $events;
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events for a specific slot on a date, grouped by slot key.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot[] $slots
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return array<string,CalendarEvent[]> Keyed by slot key
|
||||||
|
*/
|
||||||
|
public static function eventsForDateGrouped(array $slots, string $dateIso): array
|
||||||
|
{
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($slots as $slot) {
|
||||||
|
if (!$slot->isEnabled()) continue;
|
||||||
|
$events = self::slotEventsForDate($slot, $dateIso);
|
||||||
|
if ($events !== []) {
|
||||||
|
$grouped[$slot->getKey()] = $events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a slot has any events on a given date.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot $slot
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function slotHasEventsOnDate(CalendarSlot $slot, string $dateIso): bool
|
||||||
|
{
|
||||||
|
return self::slotEventsForDate($slot, $dateIso) !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get slot-level day indicator data for a whole month.
|
||||||
|
*
|
||||||
|
* Expands each slot's ICS calendar once for the full month range,
|
||||||
|
* then buckets events by day — instead of 31 individual expand calls.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot[] $slots
|
||||||
|
* @param int $year
|
||||||
|
* @param int $month
|
||||||
|
* @return array<string,string[]> date => [slotKey, ...]
|
||||||
|
*/
|
||||||
|
public static function monthIndicators(array $slots, int $year, int $month): array
|
||||||
|
{
|
||||||
|
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||||
|
$indicators = [];
|
||||||
|
|
||||||
|
$utc = new DateTimeZone('UTC');
|
||||||
|
// Expand from 1 day before month start to 1 day after month end
|
||||||
|
$rangeStart = new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $utc);
|
||||||
|
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
|
||||||
|
$rangeEnd = $rangeStart->add(new DateInterval('P' . ($daysInMonth + 2) . 'D'));
|
||||||
|
|
||||||
|
foreach ($slots as $slot) {
|
||||||
|
if (!$slot->isEnabled()) continue;
|
||||||
|
$file = $slot->getFile();
|
||||||
|
if ($file === '' || !is_file($file) || !is_readable($file)) continue;
|
||||||
|
|
||||||
|
$calendar = self::readCalendar($file);
|
||||||
|
if ($calendar === null) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uidTimezones = self::buildUidTimezoneMap($calendar);
|
||||||
|
$expanded = $calendar->expand($rangeStart, $rangeEnd);
|
||||||
|
if (!($expanded instanceof VCalendar)) continue;
|
||||||
|
|
||||||
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||||
|
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
$events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso, $uidTimezones);
|
||||||
|
$cacheKey = $slot->getKey() . '|' . $dateIso;
|
||||||
|
self::$dayCache[$cacheKey] = $events;
|
||||||
|
|
||||||
|
if ($events !== []) {
|
||||||
|
$indicators[$dateIso][] = $slot->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indicators;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare month data for the calendar widget in one pass.
|
||||||
|
*
|
||||||
|
* Uses monthIndicators() to warm the per-slot day cache, then reuses the
|
||||||
|
* normalized events already cached for each day.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot[] $slots
|
||||||
|
* @param int $year
|
||||||
|
* @param int $month
|
||||||
|
* @return array{indicators: array<string,string[]>, events: array<string,CalendarEvent[]>}
|
||||||
|
*/
|
||||||
|
public static function monthWidgetData(array $slots, int $year, int $month): array
|
||||||
|
{
|
||||||
|
$indicators = self::monthIndicators($slots, $year, $month);
|
||||||
|
$eventsByDate = [];
|
||||||
|
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||||
|
|
||||||
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||||
|
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
$events = self::eventsForDate($slots, $dateIso);
|
||||||
|
if ($events !== []) {
|
||||||
|
$eventsByDate[$dateIso] = $events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'indicators' => $indicators,
|
||||||
|
'events' => $eventsByDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse an ICS file, caching the parsed VCalendar per file path.
|
||||||
|
*
|
||||||
|
* @param string $file
|
||||||
|
* @return VCalendar|null
|
||||||
|
*/
|
||||||
|
protected static function readCalendar(string $file): ?VCalendar
|
||||||
|
{
|
||||||
|
if (array_key_exists($file, self::$vcalCache)) {
|
||||||
|
return self::$vcalCache[$file];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($file);
|
||||||
|
if (!is_string($raw) || trim($raw) === '') {
|
||||||
|
self::$vcalCache[$file] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($component instanceof VCalendar)) {
|
||||||
|
self::$vcalCache[$file] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
self::$vcalCache[$file] = $component;
|
||||||
|
return $component;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$vcalCache[$file] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a UID → TZID map from the original (non-expanded) calendar.
|
||||||
|
*
|
||||||
|
* VCalendar::expand() normalizes all timezone-aware datetimes to UTC, losing
|
||||||
|
* the original TZID. This map lets us restore the correct timezone afterwards.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @return array<string,string> uid => tzid
|
||||||
|
*/
|
||||||
|
protected static function buildUidTimezoneMap(VCalendar $calendar): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($calendar->select('VEVENT') as $vevent) {
|
||||||
|
if (!isset($vevent->DTSTART)) continue;
|
||||||
|
$uid = trim((string)($vevent->UID ?? ''));
|
||||||
|
if ($uid === '' || isset($map[$uid])) continue;
|
||||||
|
$tzid = (string)($vevent->DTSTART['TZID'] ?? '');
|
||||||
|
if ($tzid !== '') $map[$uid] = $tzid;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse events from a local ICS file for a specific date.
|
||||||
|
*
|
||||||
|
* @param string $file
|
||||||
|
* @param string $slotKey
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return CalendarEvent[]
|
||||||
|
*/
|
||||||
|
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
|
||||||
|
{
|
||||||
|
$calendar = self::readCalendar($file);
|
||||||
|
if ($calendar === null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uidTimezones = self::buildUidTimezoneMap($calendar);
|
||||||
|
|
||||||
|
$utc = new DateTimeZone('UTC');
|
||||||
|
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
|
||||||
|
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
|
||||||
|
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
|
||||||
|
|
||||||
|
$expanded = $calendar->expand($rangeStart, $rangeEnd);
|
||||||
|
if (!($expanded instanceof VCalendar)) return [];
|
||||||
|
|
||||||
|
return self::collectFromCalendar($expanded, $slotKey, $dateIso, $uidTimezones);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect normalized events from an expanded VCalendar for a specific date.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $slotKey
|
||||||
|
* @param string $dateIso
|
||||||
|
* @param array<string,string> $uidTimezones uid => tzid, for restoring timezone after expand()
|
||||||
|
* @return CalendarEvent[]
|
||||||
|
*/
|
||||||
|
protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso, array $uidTimezones = []): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
// VEVENTs
|
||||||
|
foreach ($calendar->select('VEVENT') as $vevent) {
|
||||||
|
if (!($vevent instanceof VEvent)) continue;
|
||||||
|
$event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso, $uidTimezones);
|
||||||
|
if ($event === null) continue;
|
||||||
|
|
||||||
|
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
|
||||||
|
if (isset($seen[$dedupeKey])) continue;
|
||||||
|
$seen[$dedupeKey] = true;
|
||||||
|
$result[] = $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($result, [self::class, 'compareEvents']);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a VEVENT for a specific day into a CalendarEvent.
|
||||||
|
*
|
||||||
|
* @param VEvent $vevent
|
||||||
|
* @param string $slotKey
|
||||||
|
* @param string $dateIso
|
||||||
|
* @param array<string,string> $uidTimezones uid => tzid, for restoring timezone after expand()
|
||||||
|
* @return CalendarEvent|null
|
||||||
|
*/
|
||||||
|
protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso, array $uidTimezones = []): ?CalendarEvent
|
||||||
|
{
|
||||||
|
if (!isset($vevent->DTSTART)) return null;
|
||||||
|
|
||||||
|
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||||
|
$start = self::toImmutable($vevent->DTSTART->getDateTime());
|
||||||
|
if ($start === null) return null;
|
||||||
|
|
||||||
|
// VCalendar::expand() normalizes all timezone-aware datetimes to UTC, losing
|
||||||
|
// the original TZID. Restore it using the pre-expansion UID→TZID map so that
|
||||||
|
// display times and day-boundary checks use the event's original timezone.
|
||||||
|
$restoredTz = null;
|
||||||
|
if (!$isAllDay && $uidTimezones !== [] && $start->getTimezone()->getName() === 'UTC') {
|
||||||
|
$uid = trim((string)($vevent->UID ?? ''));
|
||||||
|
$tzid = $uidTimezones[$uid] ?? '';
|
||||||
|
if ($tzid !== '') {
|
||||||
|
try {
|
||||||
|
$restoredTz = new DateTimeZone($tzid);
|
||||||
|
$start = $start->setTimezone($restoredTz);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$restoredTz = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = self::resolveEnd($vevent, $start, $isAllDay);
|
||||||
|
|
||||||
|
// Apply the same timezone restoration to the end datetime.
|
||||||
|
if ($restoredTz !== null && $end->getTimezone()->getName() === 'UTC') {
|
||||||
|
$end = $end->setTimezone($restoredTz);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null;
|
||||||
|
|
||||||
|
$event = new CalendarEvent();
|
||||||
|
$event->slotKey = $slotKey;
|
||||||
|
$event->uid = trim((string)($vevent->UID ?? ''));
|
||||||
|
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
|
||||||
|
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
|
||||||
|
if ($event->summary === '') $event->summary = '(ohne Titel)';
|
||||||
|
$event->startIso = $start->format(DateTimeInterface::ATOM);
|
||||||
|
$event->endIso = $end->format(DateTimeInterface::ATOM);
|
||||||
|
$event->allDay = $isAllDay;
|
||||||
|
$event->time = $isAllDay ? '' : $start->format('H:i');
|
||||||
|
$event->location = trim((string)($vevent->LOCATION ?? ''));
|
||||||
|
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
|
||||||
|
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
|
||||||
|
$event->componentType = 'VEVENT';
|
||||||
|
$event->dateIso = $dateIso;
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the end date/time for a VEVENT.
|
||||||
|
*
|
||||||
|
* @param VEvent $vevent
|
||||||
|
* @param DateTimeImmutable $start
|
||||||
|
* @param bool $isAllDay
|
||||||
|
* @return DateTimeImmutable
|
||||||
|
*/
|
||||||
|
protected static function resolveEnd(VEvent $vevent, DateTimeImmutable $start, bool $isAllDay): DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (isset($vevent->DTEND)) {
|
||||||
|
$end = self::toImmutable($vevent->DTEND->getDateTime());
|
||||||
|
if ($end !== null) return $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($vevent->DURATION)) {
|
||||||
|
try {
|
||||||
|
$duration = $vevent->DURATION->getDateInterval();
|
||||||
|
if ($duration instanceof DateInterval) {
|
||||||
|
return $start->add($duration);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a date range intersects a given day.
|
||||||
|
*
|
||||||
|
* @param DateTimeImmutable $start
|
||||||
|
* @param DateTimeImmutable $end
|
||||||
|
* @param bool $isAllDay
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected static function intersectsDay(
|
||||||
|
DateTimeImmutable $start,
|
||||||
|
DateTimeImmutable $end,
|
||||||
|
bool $isAllDay,
|
||||||
|
string $dateIso
|
||||||
|
): bool {
|
||||||
|
$eventTimezone = $start->getTimezone();
|
||||||
|
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
|
||||||
|
$dayEnd = $dayStart->add(new DateInterval('P1D'));
|
||||||
|
|
||||||
|
if ($end <= $start) {
|
||||||
|
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intersects = ($start < $dayEnd) && ($end > $dayStart);
|
||||||
|
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
|
||||||
|
$intersects = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $intersects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two CalendarEvents for sorting.
|
||||||
|
*
|
||||||
|
* @param CalendarEvent $a
|
||||||
|
* @param CalendarEvent $b
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
protected static function compareEvents(CalendarEvent $a, CalendarEvent $b): int
|
||||||
|
{
|
||||||
|
if ($a->allDay !== $b->allDay) {
|
||||||
|
return $a->allDay ? -1 : 1;
|
||||||
|
}
|
||||||
|
$timeCmp = strcmp($a->time, $b->time);
|
||||||
|
if ($timeCmp !== 0) return $timeCmp;
|
||||||
|
return strcmp($a->summary, $b->summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DateTimeInterface to DateTimeImmutable.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface $dt
|
||||||
|
* @return DateTimeImmutable|null
|
||||||
|
*/
|
||||||
|
protected static function toImmutable(DateTimeInterface $dt): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
if ($dt instanceof DateTimeImmutable) return $dt;
|
||||||
|
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dt->getTimestamp());
|
||||||
|
if (!($immutable instanceof DateTimeImmutable)) return null;
|
||||||
|
return $immutable->setTimezone($dt->getTimezone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all runtime caches.
|
||||||
|
*/
|
||||||
|
public static function clearCache(): void
|
||||||
|
{
|
||||||
|
self::$dayCache = [];
|
||||||
|
self::$vcalCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/CalendarSlot.php
Normal file
225
src/CalendarSlot.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents one calendar slot configuration.
|
||||||
|
*
|
||||||
|
* Each slot has a stable key, a human-readable label, local/remote source
|
||||||
|
* configuration, a display color, an optional widget indicator position,
|
||||||
|
* and a derived enabled state.
|
||||||
|
*/
|
||||||
|
class CalendarSlot
|
||||||
|
{
|
||||||
|
/** @var string[] Ordered list of all supported slot keys */
|
||||||
|
public const SLOT_KEYS = ['general', 'slot2', 'slot3', 'slot4'];
|
||||||
|
|
||||||
|
/** @var string[] Allowed widget indicator display positions */
|
||||||
|
public const INDICATOR_DISPLAYS = ['none', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||||
|
|
||||||
|
/** @var array<string,string> Human-readable labels for slot keys */
|
||||||
|
public const SLOT_LABELS = [
|
||||||
|
'general' => 'General',
|
||||||
|
'slot2' => 'Slot 2',
|
||||||
|
'slot3' => 'Slot 3',
|
||||||
|
'slot4' => 'Slot 4',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $key;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $label;
|
||||||
|
|
||||||
|
/** @var string Local ICS file path */
|
||||||
|
protected $file;
|
||||||
|
|
||||||
|
/** @var string CalDAV URL */
|
||||||
|
protected $caldavUrl;
|
||||||
|
|
||||||
|
/** @var string CalDAV username */
|
||||||
|
protected $username;
|
||||||
|
|
||||||
|
/** @var string CalDAV password */
|
||||||
|
protected $password;
|
||||||
|
|
||||||
|
/** @var string CSS color for widget indicators */
|
||||||
|
protected $color;
|
||||||
|
|
||||||
|
/** @var string Widget indicator display position */
|
||||||
|
protected $display;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $key
|
||||||
|
* @param string $file
|
||||||
|
* @param string $caldavUrl
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @param string $color
|
||||||
|
* @param string $display
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $key,
|
||||||
|
string $file = '',
|
||||||
|
string $caldavUrl = '',
|
||||||
|
string $username = '',
|
||||||
|
string $password = '',
|
||||||
|
string $color = '',
|
||||||
|
string $display = 'none'
|
||||||
|
) {
|
||||||
|
$this->key = $key;
|
||||||
|
$this->label = self::SLOT_LABELS[$key] ?? $key;
|
||||||
|
$this->file = trim($file);
|
||||||
|
$this->caldavUrl = trim($caldavUrl);
|
||||||
|
$this->username = trim($username);
|
||||||
|
$this->password = trim($password);
|
||||||
|
$this->color = trim($color);
|
||||||
|
$this->display = self::normalizeIndicatorDisplay($display);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKey(): string
|
||||||
|
{
|
||||||
|
return $this->key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFile(): string
|
||||||
|
{
|
||||||
|
return $this->file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCaldavUrl(): string
|
||||||
|
{
|
||||||
|
return $this->caldavUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsername(): string
|
||||||
|
{
|
||||||
|
return $this->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPassword(): string
|
||||||
|
{
|
||||||
|
return $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDisplay(): string
|
||||||
|
{
|
||||||
|
return $this->display;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldDisplayIndicator(): bool
|
||||||
|
{
|
||||||
|
return $this->display !== 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this slot should participate in calendar widget visibility.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isVisibleInWidget(): bool
|
||||||
|
{
|
||||||
|
return $this->shouldDisplayIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slot is enabled if it has a local file path or a CalDAV URL.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->file !== '' || $this->caldavUrl !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this slot has a usable local ICS file.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasLocalFile(): bool
|
||||||
|
{
|
||||||
|
if ($this->file === '') return false;
|
||||||
|
return is_file($this->file) && is_readable($this->file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this slot has a configured remote CalDAV source.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasRemoteSource(): bool
|
||||||
|
{
|
||||||
|
return $this->caldavUrl !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all configured calendar slots from plugin configuration.
|
||||||
|
*
|
||||||
|
* @param object $plugin Plugin instance with getConf() method
|
||||||
|
* @return CalendarSlot[] Keyed by slot key
|
||||||
|
*/
|
||||||
|
public static function loadAll($plugin): array
|
||||||
|
{
|
||||||
|
$slots = [];
|
||||||
|
foreach (self::SLOT_KEYS as $key) {
|
||||||
|
$slots[$key] = new self(
|
||||||
|
$key,
|
||||||
|
(string)$plugin->getConf('calendar_' . $key . '_file'),
|
||||||
|
(string)$plugin->getConf('calendar_' . $key . '_caldav_url'),
|
||||||
|
(string)$plugin->getConf('calendar_' . $key . '_username'),
|
||||||
|
(string)$plugin->getConf('calendar_' . $key . '_password'),
|
||||||
|
(string)$plugin->getConf('calendar_' . $key . '_color'),
|
||||||
|
(string)$plugin->getConf('calendar_' . $key . '_display')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load only enabled calendar slots.
|
||||||
|
*
|
||||||
|
* @param object $plugin Plugin instance with getConf() method
|
||||||
|
* @return CalendarSlot[] Keyed by slot key
|
||||||
|
*/
|
||||||
|
public static function loadEnabled($plugin): array
|
||||||
|
{
|
||||||
|
$all = self::loadAll($plugin);
|
||||||
|
return array_filter($all, static function (CalendarSlot $slot): bool {
|
||||||
|
return $slot->isEnabled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only slots that should appear in the calendar widget.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot[] $slots
|
||||||
|
* @return CalendarSlot[]
|
||||||
|
*/
|
||||||
|
public static function filterWidgetVisible(array $slots): array
|
||||||
|
{
|
||||||
|
return array_filter($slots, static function (CalendarSlot $slot): bool {
|
||||||
|
return $slot->isVisibleInWidget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function normalizeIndicatorDisplay(string $display): string
|
||||||
|
{
|
||||||
|
$display = strtolower(trim($display));
|
||||||
|
$display = str_replace(['_', ' '], '-', $display);
|
||||||
|
if (!in_array($display, self::INDICATOR_DISPLAYS, true)) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
return $display;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/CalendarSyncService.php
Normal file
40
src/CalendarSyncService.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized calendar sync service.
|
||||||
|
*
|
||||||
|
* Extracts sync logic from action.php so it can be reused
|
||||||
|
* by the admin page, the calendar_sync syntax, and future
|
||||||
|
* CLI entry points without duplicating code.
|
||||||
|
*/
|
||||||
|
class CalendarSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run a full sync for all enabled slots that have a remote source.
|
||||||
|
*
|
||||||
|
* @param CalendarSlot[] $slots Keyed by slot key
|
||||||
|
* @return array{ok: bool, message: string, results: array<string,bool>}
|
||||||
|
*/
|
||||||
|
public static function syncAll(array $slots): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
$hasErrors = false;
|
||||||
|
|
||||||
|
foreach ($slots as $slot) {
|
||||||
|
if (!$slot->hasRemoteSource()) continue;
|
||||||
|
|
||||||
|
$ok = CalDavClient::syncSlot($slot);
|
||||||
|
$results[$slot->getKey()] = $ok;
|
||||||
|
if (!$ok) $hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CalendarService::clearCache();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => !$hasErrors,
|
||||||
|
'results' => $results,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,36 @@ namespace dokuwiki\plugin\luxtools;
|
|||||||
*/
|
*/
|
||||||
class ChronologicalCalendarWidget
|
class ChronologicalCalendarWidget
|
||||||
{
|
{
|
||||||
|
/** @var int Maximum number of inline events shown per day cell in large mode */
|
||||||
|
protected const MAX_INLINE_EVENTS = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render full calendar widget HTML for one month.
|
* Render full calendar widget HTML for one month.
|
||||||
*
|
*
|
||||||
* @param int $year
|
* @param int $year
|
||||||
* @param int $month
|
* @param int $month
|
||||||
* @param string $baseNs
|
* @param string $baseNs
|
||||||
|
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
|
||||||
|
* @param array<string,string> $slotColors slotKey => CSS color
|
||||||
|
* @param array<string,string> $slotDisplays slotKey => configured indicator position
|
||||||
|
* @param array{size?:string,showTimes?:bool,dayEvents?:array<string,CalendarEvent[]>} $options
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
|
public static function render(
|
||||||
{
|
int $year,
|
||||||
|
int $month,
|
||||||
|
string $baseNs = 'chronological',
|
||||||
|
array $indicators = [],
|
||||||
|
array $slotColors = [],
|
||||||
|
array $slotDisplays = [],
|
||||||
|
array $options = []
|
||||||
|
): string {
|
||||||
if (!self::isValidMonth($year, $month)) return '';
|
if (!self::isValidMonth($year, $month)) return '';
|
||||||
|
|
||||||
|
$size = self::normalizeSize((string)($options['size'] ?? 'large'));
|
||||||
|
$showTimes = (bool)($options['showTimes'] ?? true);
|
||||||
|
$dayEvents = is_array($options['dayEvents'] ?? null) ? $options['dayEvents'] : [];
|
||||||
|
|
||||||
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
|
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
|
||||||
$daysInMonth = (int)date('t', $firstDayTs);
|
$daysInMonth = (int)date('t', $firstDayTs);
|
||||||
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
|
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
|
||||||
@@ -50,10 +68,12 @@ class ChronologicalCalendarWidget
|
|||||||
$yearUrlTemplate = $dayUrlTemplate;
|
$yearUrlTemplate = $dayUrlTemplate;
|
||||||
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
|
$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"'
|
$html = '<div class="luxtools-plugin luxtools-calendar luxtools-calendar-size-' . hsc($size) . '" data-luxtools-calendar="1"'
|
||||||
. ' data-base-ns="' . hsc($baseNs) . '"'
|
. ' data-base-ns="' . hsc($baseNs) . '"'
|
||||||
. ' data-current-year="' . hsc((string)$year) . '"'
|
. ' data-current-year="' . hsc((string)$year) . '"'
|
||||||
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
|
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
|
||||||
|
. ' data-luxtools-size="' . hsc($size) . '"'
|
||||||
|
. ' data-luxtools-show-times="' . ($showTimes ? '1' : '0') . '"'
|
||||||
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
|
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
|
||||||
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
|
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
|
||||||
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
|
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
|
||||||
@@ -105,14 +125,45 @@ class ChronologicalCalendarWidget
|
|||||||
} else {
|
} else {
|
||||||
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
|
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
|
||||||
$dayId = ChronoID::dateToDayId($date, $baseNs);
|
$dayId = ChronoID::dateToDayId($date, $baseNs);
|
||||||
|
$events = $dayEvents[$date] ?? [];
|
||||||
|
|
||||||
$classes = 'luxtools-calendar-day';
|
$classes = 'luxtools-calendar-day';
|
||||||
|
if ($events !== []) {
|
||||||
|
$classes .= ' luxtools-calendar-day-has-events';
|
||||||
|
}
|
||||||
|
|
||||||
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
|
// Encode day event data as JSON for the day popup
|
||||||
if ($dayId !== null && function_exists('html_wikilink')) {
|
$dayEventsJson = self::encodeDayEventsJson($events);
|
||||||
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
|
$dayDataAttr = $dayEventsJson !== '[]'
|
||||||
|
? ' data-luxtools-day-events="' . hsc($dayEventsJson) . '"'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '" data-luxtools-day="1"' . $dayDataAttr . '>';
|
||||||
|
|
||||||
|
if ($size === 'small') {
|
||||||
|
$dayIndicators = $indicators[$date] ?? [];
|
||||||
|
if ($dayIndicators !== []) {
|
||||||
|
$indicatorHtml = '';
|
||||||
|
foreach ($dayIndicators as $slotKey) {
|
||||||
|
$display = $slotDisplays[$slotKey] ?? 'none';
|
||||||
|
if ($display === 'none') continue;
|
||||||
|
|
||||||
|
$color = $slotColors[$slotKey] ?? '';
|
||||||
|
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
|
||||||
|
$indicatorHtml .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($display) . '"' . $style . '></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($indicatorHtml !== '') {
|
||||||
|
$html .= '<div class="luxtools-calendar-indicators">' . $indicatorHtml . '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= self::renderDayLink($dayId, (string)$dayNumber);
|
||||||
} else {
|
} else {
|
||||||
$html .= hsc((string)$dayNumber);
|
$html .= '<div class="luxtools-calendar-day-frame">';
|
||||||
|
$html .= '<div class="luxtools-calendar-day-number">' . self::renderDayLink($dayId, (string)$dayNumber) . '</div>';
|
||||||
|
$html .= self::renderInlineEvents($events, $slotColors, $showTimes);
|
||||||
|
$html .= '</div>';
|
||||||
}
|
}
|
||||||
$html .= '</td>';
|
$html .= '</td>';
|
||||||
}
|
}
|
||||||
@@ -127,6 +178,26 @@ class ChronologicalCalendarWidget
|
|||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $size
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function normalizeSize(string $size): string
|
||||||
|
{
|
||||||
|
$size = strtolower(trim($size));
|
||||||
|
return $size === 'small' ? 'small' : 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $value
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function normalizeShowTimes(?string $value): bool
|
||||||
|
{
|
||||||
|
if ($value === null) return true;
|
||||||
|
return trim($value) !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $year
|
* @param int $year
|
||||||
* @param int $month
|
* @param int $month
|
||||||
@@ -138,4 +209,121 @@ class ChronologicalCalendarWidget
|
|||||||
if ($month < 1 || $month > 12) return false;
|
if ($month < 1 || $month > 12) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $dayId
|
||||||
|
* @param string $label
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function renderDayLink(?string $dayId, string $label): string
|
||||||
|
{
|
||||||
|
if ($dayId !== null && function_exists('html_wikilink')) {
|
||||||
|
return (string)html_wikilink($dayId, $label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hsc($label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param CalendarEvent[] $events
|
||||||
|
* @param array<string,string> $slotColors
|
||||||
|
* @param bool $showTimes
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function renderInlineEvents(array $events, array $slotColors, bool $showTimes): string
|
||||||
|
{
|
||||||
|
if ($events === []) {
|
||||||
|
return '<div class="luxtools-calendar-day-events"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<div class="luxtools-calendar-day-events"><ul class="luxtools-calendar-event-list">';
|
||||||
|
|
||||||
|
$visibleCount = min(count($events), self::MAX_INLINE_EVENTS);
|
||||||
|
for ($index = 0; $index < $visibleCount; $index++) {
|
||||||
|
$event = $events[$index];
|
||||||
|
$color = $slotColors[$event->slotKey] ?? '';
|
||||||
|
$style = $color !== '' ? ' style="--luxtools-slot-color:' . hsc($color) . '"' : '';
|
||||||
|
$dataAttrs = self::renderEventDataAttributes($event);
|
||||||
|
|
||||||
|
$html .= '<li class="luxtools-calendar-event"' . $style . $dataAttrs . '>';
|
||||||
|
if ($showTimes && !$event->allDay && $event->time !== '') {
|
||||||
|
$html .= '<span class="luxtools-calendar-event-time luxtools-event-time" data-luxtools-start="'
|
||||||
|
. hsc($event->startIso) . '">' . hsc($event->time) . '</span>';
|
||||||
|
}
|
||||||
|
$html .= '<span class="luxtools-calendar-event-title">' . hsc($event->summary) . '</span>';
|
||||||
|
$html .= '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = count($events) - $visibleCount;
|
||||||
|
if ($remaining > 0) {
|
||||||
|
$html .= '<li class="luxtools-calendar-event luxtools-calendar-event-more">+' . hsc((string)$remaining) . ' more</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</ul></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param CalendarEvent $event
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function renderEventDataAttributes(CalendarEvent $event): string
|
||||||
|
{
|
||||||
|
$attrs = ' data-luxtools-event="1"';
|
||||||
|
$attrs .= ' data-event-summary="' . hsc($event->summary) . '"';
|
||||||
|
$attrs .= ' data-event-start="' . hsc($event->startIso) . '"';
|
||||||
|
if ($event->endIso !== '') {
|
||||||
|
$attrs .= ' data-event-end="' . hsc($event->endIso) . '"';
|
||||||
|
}
|
||||||
|
if ($event->location !== '') {
|
||||||
|
$attrs .= ' data-event-location="' . hsc($event->location) . '"';
|
||||||
|
}
|
||||||
|
if ($event->description !== '') {
|
||||||
|
$attrs .= ' data-event-description="' . hsc($event->description) . '"';
|
||||||
|
}
|
||||||
|
$attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
|
||||||
|
$attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
|
||||||
|
if ($event->uid !== '') {
|
||||||
|
$attrs .= ' data-event-uid="' . hsc($event->uid) . '"';
|
||||||
|
}
|
||||||
|
if ($event->recurrenceId !== '') {
|
||||||
|
$attrs .= ' data-event-recurrence="' . hsc($event->recurrenceId) . '"';
|
||||||
|
}
|
||||||
|
if ($event->dateIso !== '') {
|
||||||
|
$attrs .= ' data-event-date="' . hsc($event->dateIso) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode day events as a JSON array for the day popup.
|
||||||
|
*
|
||||||
|
* @param CalendarEvent[] $events
|
||||||
|
* @return string JSON string
|
||||||
|
*/
|
||||||
|
protected static function encodeDayEventsJson(array $events): string
|
||||||
|
{
|
||||||
|
if ($events === []) return '[]';
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$item = [
|
||||||
|
'summary' => $event->summary,
|
||||||
|
'start' => $event->startIso,
|
||||||
|
'allDay' => $event->allDay,
|
||||||
|
'slot' => $event->slotKey,
|
||||||
|
];
|
||||||
|
if ($event->endIso !== '') $item['end'] = $event->endIso;
|
||||||
|
if ($event->location !== '') $item['location'] = $event->location;
|
||||||
|
if ($event->description !== '') $item['description'] = $event->description;
|
||||||
|
if ($event->time !== '') $item['time'] = $event->time;
|
||||||
|
if ($event->uid !== '') $item['uid'] = $event->uid;
|
||||||
|
if ($event->recurrenceId !== '') $item['recurrence'] = $event->recurrenceId;
|
||||||
|
if ($event->dateIso !== '') $item['date'] = $event->dateIso;
|
||||||
|
$items[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
745
src/IcsWriter.php
Normal file
745
src/IcsWriter.php
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace dokuwiki\plugin\luxtools;
|
||||||
|
|
||||||
|
use Sabre\VObject\Component\VCalendar;
|
||||||
|
use Sabre\VObject\Component\VEvent;
|
||||||
|
use Sabre\VObject\Reader;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write-back support for local ICS files.
|
||||||
|
*
|
||||||
|
* Handles updating event status (completion, reopening) in local
|
||||||
|
* ICS files while preserving other properties.
|
||||||
|
*/
|
||||||
|
class IcsWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the STATUS of an event occurrence in a local ICS file.
|
||||||
|
*
|
||||||
|
* Sets STATUS to the given value (TODO or COMPLETED).
|
||||||
|
*
|
||||||
|
* For recurring events, this writes an override/exception for the specific
|
||||||
|
* occurrence rather than modifying the master event.
|
||||||
|
*
|
||||||
|
* @param string $filePath Absolute path to the local ICS file
|
||||||
|
* @param string $uid Event UID
|
||||||
|
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
|
||||||
|
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
|
||||||
|
* @param string $dateIso Occurrence date YYYY-MM-DD (for recurring event identification)
|
||||||
|
* @return bool True if the file was updated successfully
|
||||||
|
*/
|
||||||
|
public static function updateEventStatus(
|
||||||
|
string $filePath,
|
||||||
|
string $uid,
|
||||||
|
string $recurrenceId,
|
||||||
|
string $newStatus,
|
||||||
|
string $dateIso
|
||||||
|
): bool {
|
||||||
|
if ($uid === '' || $filePath === '') return false;
|
||||||
|
if (!is_file($filePath) || !is_writable($filePath)) return false;
|
||||||
|
|
||||||
|
$raw = @file_get_contents($filePath);
|
||||||
|
if (!is_string($raw) || trim($raw) === '') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($calendar instanceof VCalendar)) return false;
|
||||||
|
|
||||||
|
$updated = self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
|
||||||
|
if (!$updated) return false;
|
||||||
|
|
||||||
|
$output = $calendar->serialize();
|
||||||
|
return self::atomicWrite($filePath, $output);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a status update to the matching component in the calendar.
|
||||||
|
*
|
||||||
|
* Public alias for use by CalDavClient when modifying remote calendar data.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $recurrenceId
|
||||||
|
* @param string $newStatus
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return bool True if a component was updated
|
||||||
|
*/
|
||||||
|
public static function applyStatusUpdateToCalendar(
|
||||||
|
VCalendar $calendar,
|
||||||
|
string $uid,
|
||||||
|
string $recurrenceId,
|
||||||
|
string $newStatus,
|
||||||
|
string $dateIso
|
||||||
|
): bool {
|
||||||
|
return self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a status update to the matching component in the calendar.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $recurrenceId
|
||||||
|
* @param string $newStatus
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return bool True if a component was updated
|
||||||
|
*/
|
||||||
|
protected static function applyStatusUpdate(
|
||||||
|
VCalendar $calendar,
|
||||||
|
string $uid,
|
||||||
|
string $recurrenceId,
|
||||||
|
string $newStatus,
|
||||||
|
string $dateIso
|
||||||
|
): bool {
|
||||||
|
// Try VEVENT first
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
|
||||||
|
self::setVEventStatus($component, $newStatus);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For recurring events without a matching override, create one
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
$componentUid = trim((string)($component->UID ?? ''));
|
||||||
|
if ($componentUid !== $uid) continue;
|
||||||
|
|
||||||
|
// This is the master event; check if it has RRULE (recurring)
|
||||||
|
if (!isset($component->RRULE)) continue;
|
||||||
|
|
||||||
|
// Create an occurrence override
|
||||||
|
$override = self::createOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
|
||||||
|
if ($override !== null) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a component matches the given UID and recurrence criteria.
|
||||||
|
*
|
||||||
|
* @param VEvent $component
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $recurrenceId
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected static function matchesComponent($component, string $uid, string $recurrenceId, string $dateIso): bool
|
||||||
|
{
|
||||||
|
$componentUid = trim((string)($component->UID ?? ''));
|
||||||
|
if ($componentUid !== $uid) return false;
|
||||||
|
|
||||||
|
// If we have a specific recurrence ID, match it
|
||||||
|
if ($recurrenceId !== '') {
|
||||||
|
$componentRid = isset($component->{'RECURRENCE-ID'}) ? trim((string)$component->{'RECURRENCE-ID'}) : '';
|
||||||
|
return $componentRid === $recurrenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-recurring events (no RRULE, no RECURRENCE-ID), match by UID alone
|
||||||
|
if (!isset($component->RRULE) && !isset($component->{'RECURRENCE-ID'})) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a specific occurrence override already in the file
|
||||||
|
if (isset($component->{'RECURRENCE-ID'})) {
|
||||||
|
$ridDt = $component->{'RECURRENCE-ID'}->getDateTime();
|
||||||
|
if ($ridDt !== null && $ridDt->format('Y-m-d') === $dateIso) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the STATUS property on a VEVENT.
|
||||||
|
*
|
||||||
|
* @param VEvent $vevent
|
||||||
|
* @param string $newStatus
|
||||||
|
*/
|
||||||
|
protected static function setVEventStatus(VEvent $vevent, string $newStatus): void
|
||||||
|
{
|
||||||
|
$vevent->STATUS = $newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an occurrence override for a recurring VEVENT.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param VEvent $master
|
||||||
|
* @param string $newStatus
|
||||||
|
* @param string $dateIso
|
||||||
|
* @return VEvent|null
|
||||||
|
*/
|
||||||
|
protected static function createOccurrenceOverride(
|
||||||
|
VCalendar $calendar,
|
||||||
|
VEvent $master,
|
||||||
|
string $newStatus,
|
||||||
|
string $dateIso
|
||||||
|
): ?VEvent {
|
||||||
|
try {
|
||||||
|
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||||
|
|
||||||
|
$props = [
|
||||||
|
'UID' => (string)$master->UID,
|
||||||
|
'SUMMARY' => (string)($master->SUMMARY ?? ''),
|
||||||
|
'STATUS' => $newStatus,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isAllDay) {
|
||||||
|
$recurrenceValue = str_replace('-', '', $dateIso);
|
||||||
|
$props['DTSTART'] = $recurrenceValue;
|
||||||
|
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||||
|
// Set VALUE=DATE on the properties
|
||||||
|
$override = $calendar->add('VEVENT', $props);
|
||||||
|
$override->DTSTART['VALUE'] = 'DATE';
|
||||||
|
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
|
||||||
|
} else {
|
||||||
|
// Use the master's time for the occurrence
|
||||||
|
$masterStart = $master->DTSTART->getDateTime();
|
||||||
|
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
|
||||||
|
$tz = $masterStart->getTimezone();
|
||||||
|
if ($tz && $tz->getName() !== 'UTC') {
|
||||||
|
$props['DTSTART'] = $recurrenceValue;
|
||||||
|
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||||
|
$override = $calendar->add('VEVENT', $props);
|
||||||
|
$override->DTSTART['TZID'] = $tz->getName();
|
||||||
|
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
|
||||||
|
} else {
|
||||||
|
$recurrenceValue .= 'Z';
|
||||||
|
$props['DTSTART'] = $recurrenceValue;
|
||||||
|
$props['RECURRENCE-ID'] = $recurrenceValue;
|
||||||
|
$override = $calendar->add('VEVENT', $props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy duration or DTEND if present
|
||||||
|
if (isset($master->DTEND)) {
|
||||||
|
$duration = $master->DTSTART->getDateTime()->diff($master->DTEND->getDateTime());
|
||||||
|
$startDt = $override->DTSTART->getDateTime();
|
||||||
|
$endDt = $startDt->add($duration);
|
||||||
|
if ($isAllDay) {
|
||||||
|
$override->add('DTEND', $endDt->format('Ymd'));
|
||||||
|
$override->DTEND['VALUE'] = 'DATE';
|
||||||
|
} else {
|
||||||
|
$override->add('DTEND', $endDt);
|
||||||
|
}
|
||||||
|
} elseif (isset($master->DURATION)) {
|
||||||
|
$override->DURATION = (string)$master->DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy LOCATION and DESCRIPTION if present
|
||||||
|
if (isset($master->LOCATION)) {
|
||||||
|
$override->LOCATION = (string)$master->LOCATION;
|
||||||
|
}
|
||||||
|
if (isset($master->DESCRIPTION)) {
|
||||||
|
$override->DESCRIPTION = (string)$master->DESCRIPTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $override;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public atomic file write for use by CalDavClient sync.
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @param string $content
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function atomicWritePublic(string $filePath, string $content): bool
|
||||||
|
{
|
||||||
|
$dir = dirname($filePath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
return self::atomicWrite($filePath, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event in a local ICS file.
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData
|
||||||
|
* @return string The UID of the created event, or empty string on failure
|
||||||
|
*/
|
||||||
|
public static function createEvent(string $filePath, array $eventData): string
|
||||||
|
{
|
||||||
|
if ($filePath === '') return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$calendar = null;
|
||||||
|
if (is_file($filePath) && is_readable($filePath)) {
|
||||||
|
$raw = @file_get_contents($filePath);
|
||||||
|
if (is_string($raw) && trim($raw) !== '') {
|
||||||
|
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($calendar instanceof VCalendar)) {
|
||||||
|
$calendar = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($calendar === null) {
|
||||||
|
$calendar = new VCalendar();
|
||||||
|
$calendar->PRODID = '-//LuxTools DokuWiki Plugin//EN';
|
||||||
|
}
|
||||||
|
|
||||||
|
$uid = self::generateUid();
|
||||||
|
$props = [
|
||||||
|
'UID' => $uid,
|
||||||
|
'SUMMARY' => $eventData['summary'] ?? '',
|
||||||
|
'DTSTAMP' => gmdate('Ymd\THis\Z'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($eventData['location'])) {
|
||||||
|
$props['LOCATION'] = $eventData['location'];
|
||||||
|
}
|
||||||
|
if (!empty($eventData['description'])) {
|
||||||
|
$props['DESCRIPTION'] = $eventData['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateIso = $eventData['date'] ?? '';
|
||||||
|
$allDay = !empty($eventData['allDay']);
|
||||||
|
|
||||||
|
if ($allDay) {
|
||||||
|
$props['DTSTART'] = str_replace('-', '', $dateIso);
|
||||||
|
$vevent = $calendar->add('VEVENT', $props);
|
||||||
|
$vevent->DTSTART['VALUE'] = 'DATE';
|
||||||
|
} else {
|
||||||
|
$startTime = $eventData['startTime'] ?? '00:00';
|
||||||
|
$endTime = $eventData['endTime'] ?? '';
|
||||||
|
$props['DTSTART'] = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00';
|
||||||
|
$vevent = $calendar->add('VEVENT', $props);
|
||||||
|
if ($endTime !== '') {
|
||||||
|
$vevent->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $calendar->serialize();
|
||||||
|
if (!self::atomicWritePublic($filePath, $output)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uid;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit an existing event in a local ICS file.
|
||||||
|
*
|
||||||
|
* Scope controls how recurring event edits are applied:
|
||||||
|
* - 'all': modify the master event directly
|
||||||
|
* - 'this': create/update an occurrence override
|
||||||
|
* - 'future': truncate the master's RRULE before this date and create a new series
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $recurrenceId
|
||||||
|
* @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData
|
||||||
|
* @param string $scope 'all', 'this', or 'future'
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function editEvent(string $filePath, string $uid, string $recurrenceId, array $eventData, string $scope = 'all'): bool
|
||||||
|
{
|
||||||
|
if ($filePath === '' || $uid === '') return false;
|
||||||
|
if (!is_file($filePath) || !is_writable($filePath)) return false;
|
||||||
|
|
||||||
|
$raw = @file_get_contents($filePath);
|
||||||
|
if (!is_string($raw) || trim($raw) === '') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($calendar instanceof VCalendar)) return false;
|
||||||
|
|
||||||
|
$dateIso = $eventData['date'] ?? '';
|
||||||
|
$allDay = !empty($eventData['allDay']);
|
||||||
|
|
||||||
|
if ($scope === 'future' && $recurrenceId !== '') {
|
||||||
|
// "This and future": truncate master RRULE, then create a new standalone event
|
||||||
|
$edited = self::editFutureOccurrences($calendar, $uid, $dateIso, $eventData);
|
||||||
|
if (!$edited) return false;
|
||||||
|
} elseif ($scope === 'all') {
|
||||||
|
// "All occurrences": find and edit the master event directly
|
||||||
|
$master = self::findMasterByUid($calendar, $uid);
|
||||||
|
if ($master === null) return false;
|
||||||
|
self::applyEventData($master, $eventData);
|
||||||
|
} else {
|
||||||
|
// "This occurrence" (or non-recurring): find/create occurrence override
|
||||||
|
$target = null;
|
||||||
|
|
||||||
|
// Find matching component
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
|
||||||
|
$target = $component;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For recurring events without an existing override, create one
|
||||||
|
if ($target === null && $recurrenceId !== '') {
|
||||||
|
$target = self::createEditOccurrenceOverride($calendar, $uid, $recurrenceId, $dateIso, $eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target === null) return false;
|
||||||
|
self::applyEventData($target, $eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $calendar->serialize();
|
||||||
|
return self::atomicWrite($filePath, $output);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the master VEVENT (the one with RRULE or without RECURRENCE-ID) by UID.
|
||||||
|
*/
|
||||||
|
private static function findMasterByUid(VCalendar $calendar, string $uid): ?VEvent
|
||||||
|
{
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
$componentUid = trim((string)($component->UID ?? ''));
|
||||||
|
if ($componentUid !== $uid) continue;
|
||||||
|
// Master = has no RECURRENCE-ID
|
||||||
|
if (!isset($component->{'RECURRENCE-ID'})) {
|
||||||
|
return $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an occurrence override VEVENT for a recurring event.
|
||||||
|
*/
|
||||||
|
private static function createEditOccurrenceOverride(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso, array $eventData): ?VEvent
|
||||||
|
{
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
$componentUid = trim((string)($component->UID ?? ''));
|
||||||
|
if ($componentUid !== $uid) continue;
|
||||||
|
if (!isset($component->RRULE)) continue;
|
||||||
|
|
||||||
|
$overrideProps = [
|
||||||
|
'UID' => $uid,
|
||||||
|
'SUMMARY' => $eventData['summary'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$isAllDayMaster = strtoupper((string)($component->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||||
|
if ($isAllDayMaster) {
|
||||||
|
$recurrenceValue = str_replace('-', '', $dateIso);
|
||||||
|
$overrideProps['RECURRENCE-ID'] = $recurrenceValue;
|
||||||
|
} else {
|
||||||
|
$masterStart = $component->DTSTART->getDateTime();
|
||||||
|
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
|
||||||
|
$tz = $masterStart->getTimezone();
|
||||||
|
if ($tz && $tz->getName() !== 'UTC') {
|
||||||
|
$overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue);
|
||||||
|
} else {
|
||||||
|
$overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue) . 'Z';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $calendar->add('VEVENT', $overrideProps);
|
||||||
|
if ($isAllDayMaster) {
|
||||||
|
$target->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
|
||||||
|
} else {
|
||||||
|
$masterStart = $component->DTSTART->getDateTime();
|
||||||
|
$tz = $masterStart->getTimezone();
|
||||||
|
if ($tz && $tz->getName() !== 'UTC') {
|
||||||
|
$target->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply event data to a VEVENT component (shared by all edit scopes).
|
||||||
|
*/
|
||||||
|
private static function applyEventData(VEvent $target, array $eventData): void
|
||||||
|
{
|
||||||
|
$dateIso = $eventData['date'] ?? '';
|
||||||
|
$allDay = !empty($eventData['allDay']);
|
||||||
|
|
||||||
|
$target->SUMMARY = $eventData['summary'] ?? '';
|
||||||
|
|
||||||
|
if ($allDay) {
|
||||||
|
$target->DTSTART = str_replace('-', '', $dateIso);
|
||||||
|
$target->DTSTART['VALUE'] = 'DATE';
|
||||||
|
unset($target->DTEND);
|
||||||
|
} else {
|
||||||
|
$startTime = $eventData['startTime'] ?? '00:00';
|
||||||
|
$endTime = $eventData['endTime'] ?? '';
|
||||||
|
$target->DTSTART = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00';
|
||||||
|
if ($endTime !== '') {
|
||||||
|
if (isset($target->DTEND)) {
|
||||||
|
$target->DTEND = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00';
|
||||||
|
} else {
|
||||||
|
$target->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($eventData['location'])) {
|
||||||
|
$target->LOCATION = $eventData['location'];
|
||||||
|
} else {
|
||||||
|
unset($target->LOCATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($eventData['description'])) {
|
||||||
|
$target->DESCRIPTION = $eventData['description'];
|
||||||
|
} else {
|
||||||
|
unset($target->DESCRIPTION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle "this and future" edits: truncate master RRULE before dateIso,
|
||||||
|
* then create a new standalone event with the edited data.
|
||||||
|
*/
|
||||||
|
private static function editFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso, array $eventData): bool
|
||||||
|
{
|
||||||
|
$master = self::findMasterByUid($calendar, $uid);
|
||||||
|
if ($master === null) return false;
|
||||||
|
|
||||||
|
// Truncate master RRULE to end before this date
|
||||||
|
self::deleteFutureOccurrences($calendar, $uid, $dateIso);
|
||||||
|
|
||||||
|
// Create a new standalone event with the edited data and a new UID
|
||||||
|
$newProps = [
|
||||||
|
'UID' => self::generateUid(),
|
||||||
|
'SUMMARY' => $eventData['summary'] ?? '',
|
||||||
|
'DTSTAMP' => gmdate('Ymd\THis\Z'),
|
||||||
|
];
|
||||||
|
$newEvent = $calendar->add('VEVENT', $newProps);
|
||||||
|
self::applyEventData($newEvent, $eventData);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an event from a local ICS file.
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $recurrenceId
|
||||||
|
* @param string $dateIso
|
||||||
|
* @param string $scope 'all', 'this', or 'future'
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function deleteEvent(string $filePath, string $uid, string $recurrenceId, string $dateIso, string $scope = 'all'): bool
|
||||||
|
{
|
||||||
|
if ($filePath === '' || $uid === '') return false;
|
||||||
|
if (!is_file($filePath) || !is_writable($filePath)) return false;
|
||||||
|
|
||||||
|
$raw = @file_get_contents($filePath);
|
||||||
|
if (!is_string($raw) || trim($raw) === '') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||||
|
if (!($calendar instanceof VCalendar)) return false;
|
||||||
|
|
||||||
|
if ($scope === 'all') {
|
||||||
|
// Remove all components with this UID
|
||||||
|
self::removeComponentsByUid($calendar, $uid);
|
||||||
|
} elseif ($scope === 'this') {
|
||||||
|
// For a single occurrence, add EXDATE to master or remove the override
|
||||||
|
self::deleteOccurrence($calendar, $uid, $recurrenceId, $dateIso);
|
||||||
|
} elseif ($scope === 'future') {
|
||||||
|
// Modify RRULE UNTIL on the master
|
||||||
|
self::deleteFutureOccurrences($calendar, $uid, $dateIso);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $calendar->serialize();
|
||||||
|
return self::atomicWrite($filePath, $output);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all VEVENT components with a given UID.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $uid
|
||||||
|
*/
|
||||||
|
protected static function removeComponentsByUid(VCalendar $calendar, string $uid): void
|
||||||
|
{
|
||||||
|
$toRemove = [];
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (trim((string)($component->UID ?? '')) === $uid) {
|
||||||
|
$toRemove[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($toRemove as $component) {
|
||||||
|
$calendar->remove($component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single occurrence of a recurring event.
|
||||||
|
*
|
||||||
|
* If the occurrence has an override component, remove it and add EXDATE.
|
||||||
|
* If not, just add EXDATE to the master.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $recurrenceId
|
||||||
|
* @param string $dateIso
|
||||||
|
*/
|
||||||
|
protected static function deleteOccurrence(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso): void
|
||||||
|
{
|
||||||
|
// Remove any existing override for this occurrence
|
||||||
|
$toRemove = [];
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (trim((string)($component->UID ?? '')) !== $uid) continue;
|
||||||
|
if (!isset($component->{'RECURRENCE-ID'})) continue;
|
||||||
|
|
||||||
|
$rid = $component->{'RECURRENCE-ID'}->getDateTime();
|
||||||
|
if ($rid !== null && $rid->format('Y-m-d') === $dateIso) {
|
||||||
|
$toRemove[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($toRemove as $component) {
|
||||||
|
$calendar->remove($component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add EXDATE to the master
|
||||||
|
$master = null;
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (trim((string)($component->UID ?? '')) !== $uid) continue;
|
||||||
|
if (isset($component->{'RECURRENCE-ID'})) continue;
|
||||||
|
$master = $component;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($master !== null) {
|
||||||
|
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||||
|
if ($isAllDay) {
|
||||||
|
$exdateValue = str_replace('-', '', $dateIso);
|
||||||
|
$exdate = $master->add('EXDATE', $exdateValue);
|
||||||
|
$exdate['VALUE'] = 'DATE';
|
||||||
|
} else {
|
||||||
|
$masterStart = $master->DTSTART->getDateTime();
|
||||||
|
$exdateValue = str_replace('-', '', $dateIso) . 'T' . $masterStart->format('His');
|
||||||
|
$tz = $masterStart->getTimezone();
|
||||||
|
if ($tz && $tz->getName() !== 'UTC') {
|
||||||
|
$exdate = $master->add('EXDATE', $exdateValue);
|
||||||
|
$exdate['TZID'] = $tz->getName();
|
||||||
|
} else {
|
||||||
|
$master->add('EXDATE', $exdateValue . 'Z');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete this and all future occurrences by setting UNTIL on the master RRULE.
|
||||||
|
*
|
||||||
|
* Also removes any override components on or after the given date.
|
||||||
|
*
|
||||||
|
* @param VCalendar $calendar
|
||||||
|
* @param string $uid
|
||||||
|
* @param string $dateIso
|
||||||
|
*/
|
||||||
|
protected static function deleteFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso): void
|
||||||
|
{
|
||||||
|
// Remove overrides on or after dateIso
|
||||||
|
$toRemove = [];
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (trim((string)($component->UID ?? '')) !== $uid) continue;
|
||||||
|
if (!isset($component->{'RECURRENCE-ID'})) continue;
|
||||||
|
|
||||||
|
$rid = $component->{'RECURRENCE-ID'}->getDateTime();
|
||||||
|
if ($rid !== null && $rid->format('Y-m-d') >= $dateIso) {
|
||||||
|
$toRemove[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($toRemove as $component) {
|
||||||
|
$calendar->remove($component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set UNTIL on the master RRULE to the day before
|
||||||
|
$master = null;
|
||||||
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
|
if (!($component instanceof VEvent)) continue;
|
||||||
|
if (trim((string)($component->UID ?? '')) !== $uid) continue;
|
||||||
|
if (isset($component->{'RECURRENCE-ID'})) continue;
|
||||||
|
$master = $component;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($master !== null && isset($master->RRULE)) {
|
||||||
|
$rrule = (string)$master->RRULE;
|
||||||
|
// Remove existing UNTIL or COUNT
|
||||||
|
$rrule = preg_replace('/;?(UNTIL|COUNT)=[^;]*/i', '', $rrule);
|
||||||
|
// Calculate the day before
|
||||||
|
try {
|
||||||
|
$until = new \DateTimeImmutable($dateIso . ' 00:00:00', new \DateTimeZone('UTC'));
|
||||||
|
$until = $until->sub(new \DateInterval('P1D'));
|
||||||
|
$rrule .= ';UNTIL=' . $until->format('Ymd') . 'T235959Z';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$master->RRULE = $rrule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique UID for a new calendar event.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function generateUid(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%s-%s@luxtools',
|
||||||
|
gmdate('Ymd-His'),
|
||||||
|
bin2hex(random_bytes(8))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic file write using a temp file and rename.
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @param string $content
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected static function atomicWrite(string $filePath, string $content): bool
|
||||||
|
{
|
||||||
|
$dir = dirname($filePath);
|
||||||
|
$tmpFile = $dir . '/.luxtools_tmp_' . getmypid() . '_' . mt_rand();
|
||||||
|
|
||||||
|
if (@file_put_contents($tmpFile, $content, LOCK_EX) === false) {
|
||||||
|
@unlink($tmpFile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@rename($tmpFile, $filePath)) {
|
||||||
|
@unlink($tmpFile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
437
style.css
437
style.css
@@ -1,3 +1,5 @@
|
|||||||
|
/* Dialog infrastructure styles are in dialog.css, loaded via CSS_STYLES_INCLUDED hook in action.php */
|
||||||
|
|
||||||
/* luxtools plugin styles
|
/* luxtools plugin styles
|
||||||
* Keep this minimal and scoped to the plugin container.
|
* Keep this minimal and scoped to the plugin container.
|
||||||
*/
|
*/
|
||||||
@@ -7,7 +9,6 @@ div.luxtools-plugin table thead tr:hover > * {
|
|||||||
background-color: @ini_background_alt !important;
|
background-color: @ini_background_alt !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* "Open Location" row above the header should be visually smaller. */
|
/* "Open Location" row above the header should be visually smaller. */
|
||||||
div.luxtools-plugin table thead tr.luxtools-openlocation-row td {
|
div.luxtools-plugin table thead tr.luxtools-openlocation-row td {
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
@@ -229,8 +230,12 @@ div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block > br {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit,
|
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
|
||||||
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="number"].edit,
|
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 {
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -249,7 +254,10 @@ div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Checkbox controls: keep them in the control column, left-aligned. */
|
/* Checkbox controls: keep them in the control column, left-aligned. */
|
||||||
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block input[type="checkbox"] {
|
div.plugin_luxtools_admin
|
||||||
|
form.plugin_luxtools_admin_form
|
||||||
|
label.block
|
||||||
|
input[type="checkbox"] {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@@ -265,8 +273,12 @@ div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block input[type
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit,
|
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
|
||||||
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="number"].edit,
|
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 {
|
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -360,6 +372,10 @@ html.luxtools-noscroll body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.luxtools-lightbox.luxtools-lightbox-no-nav button.luxtools-lightbox-zone {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.luxtools-lightbox button.luxtools-lightbox-zone-prev {
|
.luxtools-lightbox button.luxtools-lightbox-zone-prev {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
@@ -382,12 +398,12 @@ html.luxtools-noscroll body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.luxtools-lightbox button.luxtools-lightbox-zone-prev::after {
|
.luxtools-lightbox button.luxtools-lightbox-zone-prev::after {
|
||||||
content: '‹';
|
content: "‹";
|
||||||
left: 0.35em;
|
left: 0.35em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.luxtools-lightbox button.luxtools-lightbox-zone-next::after {
|
.luxtools-lightbox button.luxtools-lightbox-zone-next::after {
|
||||||
content: '›';
|
content: "›";
|
||||||
right: 0.35em;
|
right: 0.35em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +428,7 @@ html.luxtools-noscroll body {
|
|||||||
|
|
||||||
.luxtools-lightbox button.luxtools-lightbox-close:hover,
|
.luxtools-lightbox button.luxtools-lightbox-close:hover,
|
||||||
.luxtools-lightbox button.luxtools-lightbox-close:focus-visible {
|
.luxtools-lightbox button.luxtools-lightbox-close:focus-visible {
|
||||||
background: rgba(0, 0, 0, 0.60);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +444,10 @@ html.luxtools-noscroll body {
|
|||||||
|
|
||||||
.luxtools-grouping {
|
.luxtools-grouping {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--luxtools-grouping-cols, 2), minmax(0, 1fr));
|
grid-template-columns: repeat(
|
||||||
|
var(--luxtools-grouping-cols, 2),
|
||||||
|
minmax(0, 1fr)
|
||||||
|
);
|
||||||
gap: var(--luxtools-grouping-gap, 0);
|
gap: var(--luxtools-grouping-gap, 0);
|
||||||
justify-content: var(--luxtools-grouping-justify, start);
|
justify-content: var(--luxtools-grouping-justify, start);
|
||||||
align-items: var(--luxtools-grouping-align, start);
|
align-items: var(--luxtools-grouping-align, start);
|
||||||
@@ -599,7 +618,11 @@ div.luxtools-calendar td.luxtools-calendar-day-today {
|
|||||||
background-color: @ini_highlight;
|
background-color: @ini_highlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a {
|
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -612,27 +635,77 @@ div.luxtools-calendar td.luxtools-calendar-day a {
|
|||||||
padding: 0.1em 0;
|
padding: 0.1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a.wikilink2:link,
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a.wikilink2:visited {
|
td.luxtools-calendar-day
|
||||||
|
> a.wikilink2:link,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> a.wikilink2:visited,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a.wikilink2:link,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a.wikilink2:visited {
|
||||||
color: @ini_missing;
|
color: @ini_missing;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a:hover,
|
td.luxtools-calendar-day
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a:focus,
|
> a:hover,
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a:active,
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
div.luxtools-calendar td.luxtools-calendar-day a:visited {
|
td.luxtools-calendar-day
|
||||||
|
> a:focus,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> a:active,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> a:visited,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:hover,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:focus,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:active,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:visited {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.luxtools-calendar td.luxtools-calendar-day span.curid > a,
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:visited,
|
td.luxtools-calendar-day
|
||||||
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:hover,
|
> span.curid
|
||||||
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:focus,
|
> a,
|
||||||
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:active {
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:visited,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:hover,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:focus,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-small
|
||||||
|
td.luxtools-calendar-day
|
||||||
|
> span.curid
|
||||||
|
> a:active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
@@ -643,6 +716,320 @@ div.luxtools-calendar td.luxtools-calendar-day:hover {
|
|||||||
background-color: @ini_background_alt;
|
background-color: @ini_background_alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
|
div.luxtools-calendar
|
||||||
|
td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
|
||||||
background-color: @ini_highlight;
|
background-color: @ini_highlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Calendar Widget Indicators
|
||||||
|
* Colored corner markers showing which slots have events on a day.
|
||||||
|
* Positions: general=top-left, slot2=top-right,
|
||||||
|
* slot3=bottom-right, slot4=bottom-left (clockwise)
|
||||||
|
* ============================================================ */
|
||||||
|
div.luxtools-calendar td.luxtools-calendar-day {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
table.luxtools-calendar-table
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day {
|
||||||
|
height: 8.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
td.luxtools-calendar-day-empty {
|
||||||
|
height: 8.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-frame {
|
||||||
|
min-height: 8.25em;
|
||||||
|
padding: 0.35em 0.4em 0.4em 0.4em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
> a,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
> span.curid
|
||||||
|
> a,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
span.curid
|
||||||
|
> a {
|
||||||
|
display: inline;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
> a.wikilink2:link,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
> a.wikilink2:visited,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
span.curid
|
||||||
|
> a.wikilink2:link,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-number
|
||||||
|
span.curid
|
||||||
|
> a.wikilink2:visited {
|
||||||
|
color: @ini_missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-events {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
ul.luxtools-calendar-event-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35em;
|
||||||
|
margin: 0 0 0.2em 0;
|
||||||
|
padding: 0.1em 0.2em 0.1em 0.35em;
|
||||||
|
border-left: 3px solid var(--luxtools-slot-color, @ini_border);
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
li.luxtools-calendar-event:hover {
|
||||||
|
background-color: @ini_highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-event-time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-event-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
li.luxtools-calendar-event-more {
|
||||||
|
border-left-color: @ini_border;
|
||||||
|
justify-content: flex-end;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
td.luxtools-calendar-day-empty,
|
||||||
|
div.luxtools-calendar.luxtools-calendar-size-large
|
||||||
|
.luxtools-calendar-day-frame {
|
||||||
|
height: 7em;
|
||||||
|
min-height: 7em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-calendar-indicators {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-calendar-indicator {
|
||||||
|
position: absolute;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-indicator-top-left {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-indicator-top-right {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-indicator-bottom-right {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-indicator-bottom-left {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
clip-path: polygon(0 0, 0 100%, 100% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Chronological Events on Day Pages
|
||||||
|
* ============================================================ */
|
||||||
|
div.luxtools-chronological-events ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-chronological-events li {
|
||||||
|
padding: 0.35em 0.5em;
|
||||||
|
margin: 0.25em 0;
|
||||||
|
border-left: 3px solid @ini_border;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-chronological-events li:hover {
|
||||||
|
background-color: @ini_background_alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.luxtools-chronological-events li[data-luxtools-event] .luxtools-event-time {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Event Popup (content-specific styles – structural dialog
|
||||||
|
* styles live in dialog.css)
|
||||||
|
* ============================================================ */
|
||||||
|
.luxtools-event-popup-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-event-popup-slot {
|
||||||
|
margin-top: 1em;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-recurrence-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day popup */
|
||||||
|
.luxtools-day-popup-events {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-day-popup-event-item {
|
||||||
|
padding: 0.3em 0.4em;
|
||||||
|
margin: 0.2em 0;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-day-popup-event-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-day-popup-empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event form */
|
||||||
|
.luxtools-event-form .luxtools-event-form-field {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-event-form .luxtools-event-form-field label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-event-form .luxtools-event-form-field input[type="text"],
|
||||||
|
.luxtools-event-form .luxtools-event-form-field input[type="date"],
|
||||||
|
.luxtools-event-form .luxtools-event-form-field input[type="time"],
|
||||||
|
.luxtools-event-form .luxtools-event-form-field textarea,
|
||||||
|
.luxtools-event-form .luxtools-event-form-field select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-event-form-time-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-event-form-time-fields .luxtools-event-form-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar sync widget (syntax) */
|
||||||
|
.luxtools-calendar-sync-widget {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-calendar-sync-status {
|
||||||
|
margin-left: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable day cells */
|
||||||
|
td.luxtools-calendar-day[data-luxtools-day] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Notifications (fallback)
|
||||||
|
* ============================================================ */
|
||||||
|
.luxtools-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 1em;
|
||||||
|
right: 1em;
|
||||||
|
z-index: 10001;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-notification-error {
|
||||||
|
background: #c0392b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luxtools-notification-warning {
|
||||||
|
background: #e67e22;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use dokuwiki\Extension\SyntaxPlugin;
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
use dokuwiki\plugin\luxtools\CalendarService;
|
||||||
|
use dokuwiki\plugin\luxtools\CalendarSlot;
|
||||||
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
|
||||||
|
|
||||||
require_once(__DIR__ . '/../autoload.php');
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
@@ -62,6 +64,8 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
|
|||||||
'year' => $resolved['year'],
|
'year' => $resolved['year'],
|
||||||
'month' => $resolved['month'],
|
'month' => $resolved['month'],
|
||||||
'base' => $baseNs,
|
'base' => $baseNs,
|
||||||
|
'size' => ChronologicalCalendarWidget::normalizeSize((string)($params['size'] ?? 'large')),
|
||||||
|
'show_times' => ChronologicalCalendarWidget::normalizeShowTimes($params['show_times'] ?? null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +88,69 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
|
|||||||
$year = (int)$data['year'];
|
$year = (int)$data['year'];
|
||||||
$month = (int)$data['month'];
|
$month = (int)$data['month'];
|
||||||
$baseNs = (string)$data['base'];
|
$baseNs = (string)$data['base'];
|
||||||
|
$size = ChronologicalCalendarWidget::normalizeSize((string)($data['size'] ?? 'large'));
|
||||||
|
$showTimes = (bool)($data['show_times'] ?? true);
|
||||||
|
|
||||||
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs);
|
if ($size === 'small') {
|
||||||
|
$resolved = $this->resolveMonthFromCookie(
|
||||||
|
'luxtools_calendar_month_' . preg_replace('/[^a-zA-Z0-9]/', '_', $baseNs)
|
||||||
|
) ?? $this->resolveMonthFromCookie('luxtools_client_month');
|
||||||
|
if ($resolved !== null) {
|
||||||
|
$year = $resolved['year'];
|
||||||
|
$month = $resolved['month'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$slots = CalendarSlot::loadEnabled($this);
|
||||||
|
$widgetSlots = CalendarSlot::filterWidgetVisible($slots);
|
||||||
|
$indicators = [];
|
||||||
|
$dayEvents = [];
|
||||||
|
if ($size === 'large') {
|
||||||
|
$widgetData = CalendarService::monthWidgetData($widgetSlots, $year, $month);
|
||||||
|
$indicators = $widgetData['indicators'];
|
||||||
|
$dayEvents = $widgetData['events'];
|
||||||
|
} else {
|
||||||
|
$indicators = CalendarService::monthIndicators($widgetSlots, $year, $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotColors = [];
|
||||||
|
$slotDisplays = [];
|
||||||
|
foreach ($widgetSlots as $slot) {
|
||||||
|
$color = $slot->getColor();
|
||||||
|
if ($color !== '') {
|
||||||
|
$slotColors[$slot->getKey()] = $color;
|
||||||
|
}
|
||||||
|
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays, [
|
||||||
|
'size' => $size,
|
||||||
|
'showTimes' => $showTimes,
|
||||||
|
'dayEvents' => $dayEvents,
|
||||||
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $cookieName
|
||||||
|
* @return array{year:int,month:int}|null
|
||||||
|
*/
|
||||||
|
protected function resolveMonthFromCookie(string $cookieName): ?array
|
||||||
|
{
|
||||||
|
$raw = $_COOKIE[$cookieName] ?? null;
|
||||||
|
if ($raw === null) return null;
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) return null;
|
||||||
|
|
||||||
|
$year = (int)($decoded['year'] ?? 0);
|
||||||
|
$month = (int)($decoded['month'] ?? 0);
|
||||||
|
if ($year < 1 || $month < 1 || $month > 12) return null;
|
||||||
|
|
||||||
|
return ['year' => $year, 'month' => $month];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $flags
|
* @param string $flags
|
||||||
* @return array<string,string>
|
* @return array<string,string>
|
||||||
|
|||||||
79
syntax/calendarsync.php
Normal file
79
syntax/calendarsync.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
|
||||||
|
require_once(__DIR__ . '/../autoload.php');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Calendar sync button syntax.
|
||||||
|
*
|
||||||
|
* Renders a manual calendar sync button on any wiki page.
|
||||||
|
* Only visible to admins. The actual sync is handled by the
|
||||||
|
* existing AJAX endpoint (luxtools_calendar_sync).
|
||||||
|
*
|
||||||
|
* Syntax:
|
||||||
|
* - {{calendar_sync>}}
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_calendarsync extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
return 225;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addSpecialPattern('\{\{calendar_sync>\}\}', $mode, 'plugin_luxtools_calendarsync');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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;
|
||||||
|
|
||||||
|
$renderer->nocache();
|
||||||
|
|
||||||
|
// Only render for authenticated users
|
||||||
|
if (empty($_SERVER['REMOTE_USER'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
|
||||||
|
$sectok = function_exists('getSecurityToken') ? getSecurityToken() : '';
|
||||||
|
|
||||||
|
$buttonLabel = (string)$this->getLang('calendar_sync_button');
|
||||||
|
if ($buttonLabel === '') $buttonLabel = 'Sync Calendars';
|
||||||
|
|
||||||
|
$renderer->doc .= '<div class="luxtools-plugin luxtools-calendar-sync-widget">';
|
||||||
|
$renderer->doc .= '<button type="button" class="button luxtools-calendar-sync-btn"'
|
||||||
|
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
|
||||||
|
. ' data-luxtools-sectok="' . hsc($sectok) . '"'
|
||||||
|
. '>' . hsc($buttonLabel) . '</button>';
|
||||||
|
$renderer->doc .= '<span class="luxtools-calendar-sync-status"></span>';
|
||||||
|
$renderer->doc .= '</div>';
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
syntax/moviemarker.php
Normal file
60
syntax/moviemarker.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use dokuwiki\Extension\SyntaxPlugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* luxtools Plugin: Movie marker syntax.
|
||||||
|
*
|
||||||
|
* Matches the <!-- BEGIN MOVIE --> and <!-- END MOVIE --> markers inserted
|
||||||
|
* by the movie-import toolbar button and renders them as invisible output.
|
||||||
|
* The markers remain in the page source for reliable find-and-replace on
|
||||||
|
* repeated imports.
|
||||||
|
*/
|
||||||
|
class syntax_plugin_luxtools_moviemarker extends SyntaxPlugin
|
||||||
|
{
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getType()
|
||||||
|
{
|
||||||
|
return 'substition';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getPType()
|
||||||
|
{
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function getSort()
|
||||||
|
{
|
||||||
|
return 319;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function connectTo($mode)
|
||||||
|
{
|
||||||
|
$this->Lexer->addSpecialPattern(
|
||||||
|
'<!-- BEGIN MOVIE -->',
|
||||||
|
$mode,
|
||||||
|
'plugin_luxtools_moviemarker'
|
||||||
|
);
|
||||||
|
$this->Lexer->addSpecialPattern(
|
||||||
|
'<!-- END MOVIE -->',
|
||||||
|
$mode,
|
||||||
|
'plugin_luxtools_moviemarker'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function handle($match, $state, $pos, Doku_Handler $handler)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
public function render($format, Doku_Renderer $renderer, $data)
|
||||||
|
{
|
||||||
|
// Render nothing — markers are source-level only.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
task.prompt.md
Normal file
177
task.prompt.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Calendar Improvements
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Improve the calendar feature so that calendar sync is easier to trigger, day and event popups are more useful, and calendar events can be created, edited, and deleted from the UI.
|
||||||
|
|
||||||
|
This prompt is intended for an implementation agent. Before changing code, inspect the current implementation and keep changes aligned with existing Dokuwiki and plugin conventions.
|
||||||
|
|
||||||
|
## Relevant Code Areas
|
||||||
|
|
||||||
|
- `admin/main.php`: admin page that already exposes a manual calendar sync button
|
||||||
|
- `action.php`: currently contains calendar AJAX handlers, including manual sync
|
||||||
|
- `js/event-popup.js`: current popup behavior for calendar events
|
||||||
|
- `syntax/calendar.php`: existing calendar syntax implementation and parsing style
|
||||||
|
- `src/ChronologicalCalendarWidget.php`: server-rendered calendar widget HTML and event/day markup
|
||||||
|
- `README.md`: should be updated if user-facing syntax or behavior changes
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- Manual calendar sync already exists, but only through the admin page.
|
||||||
|
- Event popups currently open when clicking an event element, not when clicking empty space in a day cell.
|
||||||
|
- Event popup currently shows the date/time block.
|
||||||
|
- Calendar sync is currently handled in `action.php`.
|
||||||
|
- Sync is currently one-directional: remote CalDAV -> local `.ics` file.
|
||||||
|
- The README already notes that automatic sync is not implemented and would likely require cron.
|
||||||
|
|
||||||
|
## Requested Work
|
||||||
|
|
||||||
|
### 1. Add a Manual Sync Syntax
|
||||||
|
|
||||||
|
Add a new wiki syntax that renders a manual sync control on normal pages, so sync is not limited to the admin page.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Reuse the existing sync behavior instead of duplicating logic.
|
||||||
|
- Keep permission checks strict. The current sync endpoint is admin-only; preserve that unless a strong reason emerges to change it.
|
||||||
|
- Follow the existing syntax plugin style used in `syntax/calendar.php`.
|
||||||
|
- Update `README.md` with syntax usage and any permission limitations.
|
||||||
|
|
||||||
|
Preferred implementation direction:
|
||||||
|
|
||||||
|
- Move sync logic out of `action.php` into a dedicated class/service if that simplifies reuse and maintainability.
|
||||||
|
- The syntax should only provide UI/rendering; the actual sync work should remain centralized.
|
||||||
|
|
||||||
|
Clarification needed:
|
||||||
|
|
||||||
|
- The exact syntax name and parameters are not specified. If no better convention exists in the codebase, choose a minimal, explicit syntax and document it.
|
||||||
|
-> Use the syntax name `calendar_sync` with no parameters.
|
||||||
|
|
||||||
|
### 2. Improve Event Popups
|
||||||
|
|
||||||
|
Update the event popup behavior in `js/event-popup.js` and related server-rendered markup.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Do not show the date in the popup when the popup is opened from a specific day context.
|
||||||
|
- Clicking empty space inside a calendar day cell should open a popup for that day.
|
||||||
|
- That day-level popup should list all events for the clicked day.
|
||||||
|
- Preserve support for clicking an individual event to inspect that specific event.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Inspect how day cells and event items are rendered in `src/ChronologicalCalendarWidget.php` and related output in `action.php`.
|
||||||
|
- Prefer attaching structured data to the rendered day cell rather than scraping text from the DOM.
|
||||||
|
- Keep the popup accessible: overlay close, escape key, and explicit close button should continue to work.
|
||||||
|
|
||||||
|
Clarification needed:
|
||||||
|
|
||||||
|
- The desired behavior when a day has no events is not specified. Reasonable default: show a popup with a short "no events" message plus the create action if event creation is implemented.
|
||||||
|
-> yes, show a "no events" message with the create action if implemented.
|
||||||
|
|
||||||
|
### 3. Add Event Creation
|
||||||
|
|
||||||
|
Add a `Create Event` action to the day popup.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- The action should create a new calendar event for the selected day.
|
||||||
|
- After successful creation, the UI should reflect the change without requiring the user to manually refresh unrelated state.
|
||||||
|
- Keep the implementation maintainable and explicit.
|
||||||
|
|
||||||
|
Clarification needed:
|
||||||
|
|
||||||
|
- The request does not specify the input UI for creation. The agent should either:
|
||||||
|
- implement a simple popup form, or
|
||||||
|
- stop and ask for clarification if the appropriate form fields are unclear after inspecting the data model.
|
||||||
|
- The target calendar slot for newly created events is not specified.
|
||||||
|
- It is not specified whether creation should be admin-only, authenticated-user-only, or available to all viewers.
|
||||||
|
- It is not specified whether newly created events must be written only to the local `.ics` file or also to remote CalDAV immediately.
|
||||||
|
|
||||||
|
-> Implement a simple input ui as a popup with the same styling as the event popup, with fields for summary, date/time, location, and description. Show a dropdown to select the calendar slot, preselecting the first one. Restrict event creation to authenticated users. Write newly created events to the local `.ics` file only, after that perform the event creation via CalDAV if implemented to prevent a full re-sync.
|
||||||
|
|
||||||
|
### 4. Add Event Editing
|
||||||
|
|
||||||
|
Add an `Edit` action for existing events.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- The action should allow modification of an existing event from the popup.
|
||||||
|
- The correct event instance must be edited, including enough identifier data to distinguish recurring occurrences if necessary.
|
||||||
|
- After successful editing, refresh the affected day/event UI.
|
||||||
|
|
||||||
|
Clarification needed:
|
||||||
|
|
||||||
|
- The editable field set is not defined. At minimum, summary, date/time, location, and description appear relevant, but confirm against the current event model before implementing.
|
||||||
|
- Recurring event editing semantics are not defined: edit one occurrence vs. entire series.
|
||||||
|
- Remote write-back expectations are not defined.
|
||||||
|
|
||||||
|
-> re-use the same input UI as creation, but pre-populate fields with the existing event data.
|
||||||
|
When trying to save a recurring event, ask the user if they want to edit just the selected occurrence or the entire series, or all future occurrences. The changes should be written to the local `.ics` file first, then if CalDAV write-back is implemented, perform the edit via CalDAV to prevent a full re-sync.
|
||||||
|
|
||||||
|
### 5. Add Event Deletion
|
||||||
|
|
||||||
|
Add a `Delete` action for existing events.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Deletion should be available from the event popup.
|
||||||
|
- Use an explicit confirmation step to avoid accidental deletion.
|
||||||
|
- After deletion, update the UI and related calendar data.
|
||||||
|
|
||||||
|
Clarification needed:
|
||||||
|
|
||||||
|
- Recurring event deletion semantics are not defined: delete one occurrence vs. entire series.
|
||||||
|
- Remote write-back expectations are not defined.
|
||||||
|
|
||||||
|
-> When trying to delete a recurring event, ask the user if they want to delete just the selected occurrence or the entire series, or all future occurrences. Perform deletion on the local `.ics` file first, then if CalDAV write-back is implemented, perform the deletion via CalDAV to prevent a full re-sync.
|
||||||
|
|
||||||
|
### 6. Automatic Sync
|
||||||
|
|
||||||
|
Do not treat this as a mandatory coding task unless the implementation path is already obvious and safe.
|
||||||
|
|
||||||
|
Required output:
|
||||||
|
|
||||||
|
- Provide a short implementation proposal for automatic sync.
|
||||||
|
- Compare at least these approaches:
|
||||||
|
- cron invoking a Dokuwiki entry point or plugin-specific script
|
||||||
|
- cron calling the existing AJAX endpoint
|
||||||
|
- Recommend the most maintainable option for a long-lived personal Dokuwiki plugin.
|
||||||
|
|
||||||
|
Important context:
|
||||||
|
|
||||||
|
- The current README already suggests cron as the likely approach.
|
||||||
|
- A direct web or AJAX trigger from cron may work, but a CLI-oriented entry point may be more robust and easier to secure.
|
||||||
|
|
||||||
|
### 7. Direct Sync of Changed Events
|
||||||
|
|
||||||
|
When an event is created, edited, or deleted, the calendar should update promptly so the UI and stored data stay in sync.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- The local calendar data and rendered calendar state should reflect the change immediately after a successful mutation.
|
||||||
|
- If remote CalDAV write-back is implemented for create, edit, or delete, trigger it as part of the mutation flow rather than relying on a later full sync.
|
||||||
|
- Avoid introducing a design where a later full remote -> local sync silently overwrites recent local edits without warning.
|
||||||
|
|
||||||
|
Clarification needed:
|
||||||
|
|
||||||
|
- The current system only guarantees remote -> local full sync, with limited immediate remote write-back behavior for maintenance task completion. Create, edit, and delete may require a broader sync design decision before implementation.
|
||||||
|
|
||||||
|
-> Implement immediate local updates to the `.ics` file and calendar state for create, edit, and delete actions. If remote CalDAV write-back is implemented, perform that action immediately after the local update within the same user flow to ensure consistency. Avoid a design where local edits are at risk of being overwritten by a later remote sync without user awareness.
|
||||||
|
|
||||||
|
## Refactoring Requirement
|
||||||
|
|
||||||
|
If calendar sync code is touched, strongly prefer moving reusable sync logic out of `action.php` into a dedicated class under `src/`.
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
- `action.php` currently mixes hook registration, rendering-related behavior, and calendar sync handling.
|
||||||
|
- Reusable sync and event mutation logic will be easier to test, reuse, and extend from a dedicated class.
|
||||||
|
|
||||||
|
## Expected Deliverables
|
||||||
|
|
||||||
|
1. Implement the clearly defined items that can be completed safely from the current codebase.
|
||||||
|
2. Mark any blocked items with precise clarification questions instead of guessing.
|
||||||
|
3. Update `README.md` for any new syntax, permissions, or workflow changes.
|
||||||
|
4. Run `php -l` on changed PHP files.
|
||||||
|
5. Summarize remaining design decisions, especially around permissions, recurring events, and remote CalDAV write-back.
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user