Compare commits

..

33 Commits

Author SHA1 Message Date
f1ac693fe8 Add the Chronological 2026-02-16 13:39:26 +01:00
c091ed1371 Add grouping feature 2026-02-13 13:14:11 +01:00
164df2f770 fix data-src in image macro 2026-02-13 10:24:18 +01:00
232802b0ce Add explcit aliases for open links 2026-02-13 10:10:57 +01:00
4dae370deb Refine Page Linking workflow 2026-02-09 09:23:04 +01:00
a5c44e106e Allow downloading the pagelink file directly 2026-02-04 09:53:43 +01:00
70a9f30336 Extract Pagelink functionality 2026-02-03 08:00:09 +01:00
1b6df4a9e4 Improved Page lik handling 2026-02-02 22:36:38 +01:00
47a8bfa50a Unlinking 2026-02-02 21:55:35 +01:00
e1102d9f06 V1 2026-02-02 21:13:51 +01:00
af0ca29131 Refine spec 2026-02-02 20:55:35 +01:00
c203fe6397 Add spec 2026-02-02 20:39:02 +01:00
487e96b588 Add date fixer functions 2026-01-30 11:12:50 +01:00
47889c7d4c No white background for favicons 2026-01-28 20:39:25 +01:00
a5b33c1b8d Show favicons for external links 2026-01-28 16:41:08 +01:00
e6d6ad3c7b Rework lazy loading as js controlled fetches 2026-01-28 13:20:35 +01:00
80e3aa95d8 Imporve image lazy loading 2026-01-28 12:15:08 +01:00
43fc752535 Ensure CSS updates after deploy 2026-01-27 11:09:20 +01:00
7a4ce8609c Allow hotlinking images 2026-01-26 09:39:34 +01:00
16e80f81c0 Update temp-input-colors.css
remove `!important`
2026-01-22 22:01:40 +01:00
5ce4c1daef Adjust scratchpad css 2026-01-22 21:50:01 +01:00
30b85b2257 Fix settings page textarea displays 2026-01-22 21:35:36 +01:00
e306226ac8 Temp fix for edit styling 2026-01-22 21:20:09 +01:00
4eaec5c7b1 Make location link color customizable 2026-01-22 20:54:50 +01:00
912f9dcac6 Clean up unused syntax 2026-01-22 20:27:47 +01:00
351485efb1 Use thumbnail/placeholder logic for imagebox 2026-01-21 11:03:41 +01:00
e1d24c6627 Update copilot instrucitons 2026-01-21 07:51:29 +01:00
56e51b1c3c Add button for code blocks 2026-01-20 07:53:37 +01:00
d3e087ad6e Add item count for folders 2026-01-19 09:38:47 +01:00
8aa022feff Add single image display. 2026-01-19 09:16:02 +01:00
95a0e94b4a add dokuwiki sources for dev 2026-01-15 20:40:33 +01:00
dd74499339 Split js into separate files 2026-01-15 20:24:16 +01:00
8a97197f3e Refactor js 2026-01-15 20:05:41 +01:00
203 changed files with 29805 additions and 1089 deletions

View File

@@ -19,3 +19,8 @@
- Favor well-known solutions over new or exotic solutions - Favor well-known solutions over new or exotic solutions
- Favor documented solutions over undocumented solutions - Favor documented solutions over undocumented solutions
- Favor built-in solutions over external dependencies - Favor built-in solutions over external dependencies
# Conduct and user interactions
- The user is a professional software developer, but unfamiliar with Dokuwiki internals, PHP and JavaScript best practices
- When the user gives specific instructions regarding implementation details, check wether those details fit PHP and Dokuwiki best practices.
- If the user instructions conflict with best practices, point out the conflict and suggest alternatives

1
.gitignore vendored Normal file
View File

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

3
.gitmodules vendored Normal file
View File

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

26
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
// Developer quality-of-life:
// If you add a DokuWiki checkout as ./_dokuwiki (see README), Intelephense will
// index it and resolve DokuWiki base classes (ActionPlugin, SyntaxPlugin, etc.).
"intelephense.environment.includePaths": [
"./_dokuwiki",
"./_dokuwiki/inc",
"./_dokuwiki/lib",
"./_dokuwiki/vendor"
],
// DokuWiki replaces @ini_* placeholders server-side.
// VS Code's CSS validator doesn't understand those tokens, but LESS does.
"files.associations": {
"style.css": "less",
"temp-input-colors.css": "less"
},
// Keep the file explorer tidy when the optional DokuWiki checkout exists.
"files.exclude": {
"**/_dokuwiki/.git": true,
"**/_dokuwiki/data": true,
"**/_dokuwiki/conf": true,
"**/_dokuwiki/cache": true
}
}

320
README.md
View File

@@ -21,11 +21,14 @@ It is likely unsuited for wider adoption without modification.
luxtools provides DokuWiki syntax that: luxtools provides DokuWiki syntax that:
- Lists files from configured host filesystem roots (glob pattern) - Lists a directory's direct children (files + folders) or files matching a glob pattern
- Lists a directory's direct children (files + folders)
- Renders an image thumbnail gallery (with lightbox) - Renders an image thumbnail gallery (with lightbox)
- Groups multiple `{{image>...}}` blocks in compact grid/flex layouts
- Provides "open this folder/path" links for local workflows - Provides "open this folder/path" links for local workflows
- Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions) - Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions)
- Links a page to a media folder via a UUID (.pagelink), enabling a `blobs/` alias
- Adds a Page ID download link in the page info area to fetch a `.pagelink` file
- Renders a basic calendar widget with clickable day links to chronological pages
It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used
to deliver files and generate cached thumbnails. to deliver files and generate cached thumbnails.
@@ -47,6 +50,13 @@ If you install this plugin manually, make sure it is installed in:
If the folder is called differently, DokuWiki will not load it. If the folder is called differently, DokuWiki will not load it.
This plugin uses Composer dependencies shipped inside `vendor/`.
If dependencies are missing in your local checkout, run:
```bash
php composer.phar install
```
## Project structure (developer notes) ## Project structure (developer notes)
@@ -55,6 +65,37 @@ This repository follows DokuWiki's plugin conventions at the top level (e.g. `sy
Reusable PHP code lives in `src/` and is loaded via `autoload.php`. Reusable PHP code lives in `src/` and is loaded via `autoload.php`.
When adding new internal classes under the `dokuwiki\plugin\luxtools\` namespace, place them in `src/<ClassName>.php`. When adding new internal classes under the `dokuwiki\plugin\luxtools\` namespace, place them in `src/<ClassName>.php`.
JavaScript is split into small modules under `js/` and registered via `action.php` so DokuWiki loads them in order.
## IDE support (developer notes)
This plugin extends and uses DokuWiki core classes (for example `dokuwiki\Extension\ActionPlugin`, `dokuwiki\Extension\SyntaxPlugin`, renderers, handlers). If you only open the plugin folder in your IDE, those types may show as “unknown”.
DokuWiki does not currently ship an official PHP “SDK”/stub package for IDEs. The most reliable way to get full type navigation and autocomplete is to have the DokuWiki sources available in your workspace.
Two recommended setups:
### Option A: Add DokuWiki as a git submodule (recommended for a single-folder workspace)
From the plugin root:
```bash
git submodule add https://github.com/dokuwiki/dokuwiki.git _dokuwiki
git submodule update --init --recursive
```
The repository includes a VS Code config in `.vscode/settings.json` that points Intelephense at `./_dokuwiki/*` so the classes resolve.
`deploy.sh` excludes `_dokuwiki/` to avoid deploying the dev-only checkout.
### Option B: Use a separate DokuWiki checkout next to the plugin (recommended if you dont want submodules)
- Clone DokuWiki into a sibling folder (outside this repo)
- Open a multi-root VS Code workspace with both folders
This avoids changing the git state of the plugin repo, but still gives the IDE access to DokuWikis class definitions.
## Configuration ## Configuration
@@ -66,15 +107,20 @@ Key settings:
- **paths** - **paths**
Allowed base filesystem roots (one per line). Each root can be followed by: Allowed base filesystem roots (one per line). Each root can be followed by:
- `A> /Alias/` (optional) alias used in wiki syntax and open links - `A> Alias` (optional) alias used in wiki syntax and open links
Example: Example:
``` ```
/srv/share/Datascape/ /srv/share/Datascape/
A> /Scape/ A> Scape
``` ```
Notes:
- Wiki syntax accepts aliases in path form (for example `Scape/sub/folder`).
- Open links sent to the local client service are emitted as `Alias>relative/path`
(for example `Scape>sub/folder`) so each client can resolve its own local root.
luxtools links use the plugin endpoint: luxtools links use the plugin endpoint:
`lib/plugins/luxtools/file.php?root=...&file=...` `lib/plugins/luxtools/file.php?root=...&file=...`
@@ -111,19 +157,168 @@ Key settings:
URL of a local client service used by `{{open>...}}` and directory links. URL of a local client service used by `{{open>...}}` and directory links.
See luxtools-client. See luxtools-client.
- **image_base_path**
Base filesystem path used for chronological photo integration.
On canonical day pages (`chronological:YYYY:MM:DD`), files that start with
`YYYY-MM-DD` are listed automatically.
If a yearly subfolder exists (for example `.../2026/`), it is preferred.
- **calendar_ics_files**
Local calendar `.ics` files (one absolute file path per line).
Events are parsed by `sabre/vobject` and shown on matching chronological day pages.
Recurrence and exclusions from the ICS are respected. For timed entries, the
page stores the original timestamp and renders the visible time in the
browser's local timezone.
Multi-day events appear on each overlapping day.
- **pagelink_search_depth**
Maximum directory depth for `.pagelink` discovery under each configured root.
`0` means only the root directory itself is checked.
### Template style settings
The `{{open>...}}` links and directory “open” links use a dedicated color
placeholder so they can be customized in **Template Style Settings**.
- **Location Links** (`__luxtools_locationlink__`)
Default: `#b57d35`
To be able to customize the color via the UI add the following to your local template
style file at `conf/tpl/<your-template>/style.ini` under the
`[replacements]` section:
```
__luxtools_locationlink__ = "#b57d35" ; @ini_luxtools_locationlink
```
### Temporary global input styling
Because the target template is not ready yet, the plugin currently ships a
temporary stylesheet that applies `@ini_text`, `@ini_background`, and
`@ini_border` to all `input`, `textarea`, and `select` elements site-wide.
This file is explicitly marked as a temporary fix and should be removed once
the template provides proper form control styles.
Temporary file: [temp-input-colors.css](temp-input-colors.css)
Developer note: DokuWiki serves a combined stylesheet via `lib/exe/css.php` and caches it.
Cache invalidation is based on the mtimes of the source CSS/LESS files.
If you deploy into a mounted/remote filesystem with a different clock, preserving mtimes can prevent
automatic invalidation (making it look like your CSS changes don't load until you purge cache).
`deploy.sh` avoids preserving mtimes by default to make CSS iteration smoother. If you explicitly want
to preserve mtimes, use:
```bash
./deploy.sh --preserve-times
```
## Features and usage ## Features and usage
### 1) List files by glob pattern ### 0) Editor toolbar: Code block button
The plugin adds a custom button to the DokuWiki editor toolbar for quickly inserting `<code>` blocks.
When editing a page, click the code block button (angle brackets icon `<>`) in the toolbar to wrap selected text in `<code></code>` tags, or to insert an empty code block at the cursor position.
This complements DokuWiki's built-in monospace formatting (`''`) by providing quick access to HTML code blocks.
### 0.1) Editor toolbar: Date Fix
The plugin adds two toolbar buttons for normalizing timestamps while editing:
- **Date Fix**: Converts the selected timestamp to `YYYY-MM-DD` (or `YYYY-MM-DD HH:MM:SS` if time is included).
- **Date Fix (All)**: Scans the whole page and normalizes any recognizable timestamps.
Supported input examples include:
- `2026-01-30`
- `30.01.2026`
- `30 Jan 2026`
- `Jan 30, 2026`
- `2026-01-30 13:45`
- `2026-01-30T13:45:00`
### 0.2) Page Link: link a page to a folder
Page linking uses a page-scoped UUID stored in page metadata. This UUID is used
to link the page to a folder that contains a `.pagelink` file with the same UUID.
The Page Link workflow is driven by the **Page ID link** in the page info area
(page footer, `.docInfo`):
1. **Link Page** (page has no UUID yet)
Creates the UUID and downloads a `.pagelink` file.
2. **Download Link File** (page has UUID, but no linked folder found)
Downloads the `.pagelink` file.
3. **Unlink Page** (page is linked)
Prompts for confirmation, removes the `.pagelink` file from the linked folder
(if found), removes the UUID from the page, and refreshes the page.
After downloading the `.pagelink` file, place it into the folder you want to
link (within your configured `paths` roots). Once DokuWiki can discover it,
the page becomes “linked”.
Once linked, you can use `blobs/` as an alias in luxtools syntax on that page,
for example:
``` ```
{{files>/Scape/projects/*&style=list}} {{images>blobs/*.png}}
{{files>/Scape/projects/*&style=table&tableheader=1&showsize=1&showdate=1}} {{directory>blobs/&recursive=1}}
{{files>/Scape/projects/*&recursive=1&sort=mtime&order=desc}} ```
### 0.3) Calendar widget
Render a basic monthly calendar that links each day to canonical chronological pages:
```
{{calendar>}}
{{calendar>2024-10}}
```
Notes:
- `{{calendar>}}` renders the current month.
- `{{calendar>YYYY-MM}}` renders a specific month.
- Day links target `chronological:YYYY:MM:DD`.
- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`.
- Prev/next month buttons update the widget in-place without a full page reload.
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
### 0.4) Virtual chronological day pages
When a canonical day page (for example `chronological:2026:02:13`) does not yet
exist, luxtools renders a virtual page in normal show mode instead of the
default "page does not exist" output.
The virtual page includes:
- a German-formatted heading (for example `Freitag, 13. Februar 2026`)
- matching local calendar events from configured `.ics` files (when available)
- matching day photos (via existing `{{images>...}}` rendering) when available
The page is only created once you edit and save actual content.
### 1) List files by glob pattern
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
```
{{directory>/Scape/projects/*}}
{{directory>/Scape/projects/*&tableheader=1&showsize=1&showdate=1}}
{{directory>/Scape/projects/*&recursive=1&sort=mtime&order=desc}}
```
Or using the legacy `files` keyword (same behavior):
```
{{files>/Scape/projects/*}}
{{files>/Scape/projects/*&tableheader=1&showsize=1&showdate=1}}
``` ```
Notes: Notes:
- Pattern matching is performed per-directory (safe glob via fnmatch). - Pattern matching is performed per-directory (safe glob via fnmatch).
- Always renders as a table.
- A directory can have a title file (default: `_title.txt`) to override the displayed folder name. - A directory can have a title file (default: `_title.txt`) to override the displayed folder name.
### 2) List a directory (folders + files) as a table ### 2) List a directory (folders + files) as a table
@@ -143,7 +338,79 @@ This always renders as a table. It includes an "Open Location" link above the ta
Clicking a thumbnail opens a lightbox viewer. Thumbnails are generated and cached via the plugin endpoint. Clicking a thumbnail opens a lightbox viewer. Thumbnails are generated and cached via the plugin endpoint.
### 4) Open a local path/folder (best-effort) ### 4) Single image with caption (imagebox)
```
{{image>/Scape/photos/picture.jpg|This is the caption}}
{{image>/Scape/photos/picture.jpg|Caption|400}}
{{image>/Scape/photos/picture.jpg|Caption|400x300}}
{{image>/Scape/photos/picture.jpg|Caption|left}}
{{image>/Scape/photos/picture.jpg|Caption|400&center}}
{{image>https://example.com/images/picture.jpg|Remote image caption}}
{{image>https://example.com/images/picture.jpg|Remote caption|400x300&left}}
```
Renders a Wikipedia-style image box with optional caption. The syntax uses pipe-separated parts:
- `{{image>path|caption}}` Image with caption (uses defaults)
- `{{image>path|caption|options}}` Image with caption and options
Options (in the third part, separated by `&`):
- Size: `200` (width) or `200x150` (width × height)
- Alignment: `left`, `right` (default), or `center`
- Combined: `400&left` or `400x300&center`
The image links to the full-size version when clicked.
Remote images (HTTP/HTTPS URLs) are linked directly without proxying or thumbnailing.
### 5) Group multiple image boxes compactly
Use `<grouping> ... </grouping>` to arrange multiple `{{image>...}}` entries in less vertical space.
```text
<grouping>
{{image>/Scape/photos/1.jpg|One|300}}
{{image>/Scape/photos/2.jpg|Two|300}}
{{image>/Scape/photos/3.jpg|Three|300}}
{{image>/Scape/photos/4.jpg|Four|300}}
</grouping>
<grouping layout="flex" gap="0" justify="start" align="start">
{{image>/Scape/photos/1.jpg|One|220}}
{{image>/Scape/photos/2.jpg|Two|220}}
{{image>/Scape/photos/3.jpg|Three|220}}
</grouping>
<grouping layout="grid" cols="3" gap="0.4rem">
{{image>/Scape/photos/1.jpg|One|260}}
{{image>/Scape/photos/2.jpg|Two|260}}
{{image>/Scape/photos/3.jpg|Three|260}}
</grouping>
<grouping layout="flex" gap="0.5rem" justify="space-between" align="center">
{{image>/Scape/photos/1.jpg|One|220}}
{{image>/Scape/photos/2.jpg|Two|220}}
{{image>/Scape/photos/3.jpg|Three|220}}
</grouping>
```
Supported attributes on the opening tag:
- `layout`: `flex` (default) or `grid`
- `cols`: integer >= 1 (default `2`, used by `grid`)
- `gap`: CSS length token such as `0`, `0.6rem`, `8px` (default `0`)
- `justify`: `start`, `center`, `end`, `space-between`, `space-around`, `space-evenly` (default `start`)
- `align`: `start`, `center`, `end`, `stretch`, `baseline` (default `start`)
Notes:
- The wrapper only controls layout. It adds no own border/background/frame.
- Invalid values silently fall back to defaults.
- Unknown attributes render a small warning string, e.g. `[grouping: unknown option(s): gpa]`.
- Existing standalone `{{image>...}}` behavior is unchanged outside `<grouping>`.
### 6) Open a local path/folder (best-effort)
``` ```
{{open>/Scape/projects|Open projects folder}} {{open>/Scape/projects|Open projects folder}}
@@ -155,7 +422,7 @@ Behaviour:
- Prefer calling the configured local client service (open_service_url). - Prefer calling the configured local client service (open_service_url).
- Fall back to opening a file:// URL in a new tab (often blocked by browsers). - Fall back to opening a file:// URL in a new tab (often blocked by browsers).
### 5) Scratchpads (shared, file-backed, no page revisions) ### 7) Scratchpads (shared, file-backed, no page revisions)
``` ```
{{scratchpad>start}} {{scratchpad>start}}
@@ -163,8 +430,21 @@ Behaviour:
Scratchpads render the referenced file as wikitext and (when you have edit rights on the host page) provide an inline editor that saves directly to the backing file. Scratchpads render the referenced file as wikitext and (when you have edit rights on the host page) provide an inline editor that saves directly to the backing file.
### 8) Link Favicons (automatic)
## Inline options reference (files/images/directory) External links automatically display the favicon of the linked website. This feature:
- Uses DuckDuckGo's favicon service (`icons.duckduckgo.com`)
- Works on all external links (class `urlextern`)
- Shows grayscale icons that become colored on hover
- Browser handles caching; no server-side storage needed
No configuration required. The feature is enabled by default for all external links.
Based on the [linkfavicon plugin](https://github.com/shaoyanmin/linkfavicon) by Shao Yanmin.
## Inline options reference (directory/images)
The listing syntaxes accept options appended with &key=value: The listing syntaxes accept options appended with &key=value:
@@ -179,17 +459,21 @@ The listing syntaxes accept options appended with &key=value:
| randlinks | 0\|1 | Adds a cache-busting query parameter based on mtime. | | randlinks | 0\|1 | Adds a cache-busting query parameter based on mtime. |
| showsize | 0\|1 | Show file size (where supported). | | showsize | 0\|1 | Show file size (where supported). |
| showdate | 0\|1 | Show last modified date (where supported). | | showdate | 0\|1 | Show last modified date (where supported). |
| listsep | ", " | Separator used in list-style rendering for extra fields. |
| maxheight | 500 | Container max-height in pixels; -1 disables scroll container. | | maxheight | 500 | Container max-height in pixels; -1 disables scroll container. |
Additionally for `{{files>...}}`:
| Option | Values | Notes |
|---|---|---|
| style | list\|olist\|table | Output style. |
| tableheader | 0\|1 | Render table header row. | | tableheader | 0\|1 | Render table header row. |
## Admin settings
The admin settings page includes a **default_tablecolumns** option that lets you specify which columns are displayed by default in table-style listings. This is a comma-separated list of column names:
- `name` File/folder name (always shown)
- `size` File size
- `date` Last modified date
Example: `name,size,date` shows all columns by default.
## Credits / upstream ## Credits / upstream
luxtools is a fork of the [DokuWiki Filelist plugin](https://www.dokuwiki.org/plugin:filelist). luxtools is a fork of the [DokuWiki Filelist plugin](https://www.dokuwiki.org/plugin:filelist).

1
_dokuwiki Submodule

Submodule _dokuwiki added at 47e4d6995f

138
_test/ChronoIDTest.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace dokuwiki\plugin\luxtools\test;
use dokuwiki\plugin\luxtools\ChronoID;
use DokuWikiTest;
require_once(__DIR__ . '/../autoload.php');
/**
* Chronological ID helper tests.
*
* @group plugin_luxtools
* @group plugins
*/
class ChronoIDTest extends DokuWikiTest
{
protected function assertBool(bool $expected, bool $actual, string $message): void
{
if ($expected !== $actual) {
throw new \Exception($message);
}
}
protected function assertStringOrNull(?string $expected, ?string $actual, string $message): void
{
if ($expected !== $actual) {
throw new \Exception($message . ' expected=' . var_export($expected, true) . ' actual=' . var_export($actual, true));
}
}
public function testIsIsoDateValidCases(): void
{
$valid = [
'2024-10-24',
'2024-02-29',
];
foreach ($valid as $date) {
$this->assertBool(true, ChronoID::isIsoDate($date), 'Expected valid ISO date: ' . $date);
}
}
public function testIsIsoDateInvalidCases(): void
{
$invalid = [
'2023-02-29',
'2024-13-01',
'2024-00-10',
'24-10-2024',
'2024/10/24',
'2024-10-24 12:00:00',
'2024-10-24T12:00:00',
'0000-01-01',
];
foreach ($invalid as $date) {
$this->assertBool(false, ChronoID::isIsoDate($date), 'Expected invalid ISO date: ' . $date);
}
}
public function testDateToDayId(): void
{
$this->assertStringOrNull('chronological:2024:10:24', ChronoID::dateToDayId('2024-10-24'), 'dateToDayId failed');
$this->assertStringOrNull('journal:chrono:2024:10:24', ChronoID::dateToDayId('2024-10-24', 'journal:chrono'), 'dateToDayId with custom namespace failed');
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24T12:00:00'), 'datetime should be rejected');
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-13-01'), 'invalid month should be rejected');
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24', ''), 'empty namespace should be rejected');
$this->assertStringOrNull(null, ChronoID::dateToDayId('2024-10-24', 'bad namespace!'), 'invalid namespace should be rejected');
}
public function testCanonicalIdChecks(): void
{
$this->assertBool(true, ChronoID::isDayId('chronological:2024:10:24'), 'valid day ID should be accepted');
$this->assertBool(true, ChronoID::isMonthId('chronological:2024:10'), 'valid month ID should be accepted');
$this->assertBool(true, ChronoID::isYearId('chronological:2024'), 'valid year ID should be accepted');
$this->assertBool(false, ChronoID::isDayId('2024:10:24'), 'missing namespace should be rejected as day ID');
$this->assertBool(false, ChronoID::isDayId('chronological:2024-10-24'), 'hyphen date in ID should be rejected as day ID');
$this->assertBool(false, ChronoID::isDayId('chronological:2023:02:29'), 'invalid Gregorian day should be rejected');
$this->assertBool(false, ChronoID::isMonthId('chronological:2024:13'), 'invalid month 13 should be rejected');
$this->assertBool(false, ChronoID::isMonthId('chronological:2024:00'), 'invalid month 00 should be rejected');
$this->assertBool(false, ChronoID::isMonthId('chronological:2024-10'), 'invalid month format should be rejected');
$this->assertBool(false, ChronoID::isYearId('chronological:0000'), 'year 0000 should be rejected');
$this->assertBool(false, ChronoID::isYearId('chronological:24'), 'short year should be rejected');
$this->assertBool(false, ChronoID::isYearId('chronological:2024:10'), 'month ID should not pass as year ID');
}
public function testConversions(): void
{
$this->assertStringOrNull('chronological:2024:10', ChronoID::dayIdToMonthId('chronological:2024:10:24'), 'dayIdToMonthId failed');
$this->assertStringOrNull('chronological:2024', ChronoID::monthIdToYearId('chronological:2024:10'), 'monthIdToYearId failed');
$this->assertStringOrNull(null, ChronoID::dayIdToMonthId('chronological:2024:13:24'), 'invalid day ID should map to null month ID');
$this->assertStringOrNull(null, ChronoID::dayIdToMonthId('chronological:2024:10'), 'month ID should not map via dayIdToMonthId');
$this->assertStringOrNull(null, ChronoID::monthIdToYearId('chronological:2024:13'), 'invalid month ID should map to null year ID');
$this->assertStringOrNull(null, ChronoID::monthIdToYearId('chronological:2024:10:24'), 'day ID should not map via monthIdToYearId');
}
/**
* Integration-style smoke test for canonical ID matrix acceptance/rejection.
*/
public function testCanonicalPageIdSmokeMatrix(): void
{
$accepted = [
['day', 'chronological:2024:10:24'],
['month', 'chronological:2024:10'],
['year', 'chronological:2024'],
];
foreach ($accepted as [$kind, $id]) {
if ($kind === 'day') {
$this->assertBool(true, ChronoID::isDayId($id), 'Expected accepted day ID: ' . $id);
} elseif ($kind === 'month') {
$this->assertBool(true, ChronoID::isMonthId($id), 'Expected accepted month ID: ' . $id);
} else {
$this->assertBool(true, ChronoID::isYearId($id), 'Expected accepted year ID: ' . $id);
}
}
$rejected = [
'2024:10:24',
'chronological:2024-10-24',
'chronological:2024:13:01',
'chronological:2024:00',
'chronological:0000',
];
foreach ($rejected as $id) {
$this->assertBool(false, ChronoID::isDayId($id), 'Unexpected day ID acceptance: ' . $id);
$this->assertBool(false, ChronoID::isMonthId($id), 'Unexpected month ID acceptance: ' . $id);
$this->assertBool(false, ChronoID::isYearId($id), 'Unexpected year ID acceptance: ' . $id);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace dokuwiki\plugin\luxtools\test;
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
use DokuWikiTest;
require_once(__DIR__ . '/../autoload.php');
/**
* Tests for extracted chronological auto-linker.
*
* @group plugin_luxtools
* @group plugins
*/
class ChronologicalDateAutoLinkerTest extends DokuWikiTest
{
public function testLinksPlainTextDate(): void
{
$html = '<p>Meeting on 2024-10-24</p>';
$out = ChronologicalDateAutoLinker::linkHtml($html);
$decoded = urldecode($out);
if (strpos($decoded, 'chronological:2024:10:24') === false) {
throw new \Exception('Expected canonical link target not found');
}
}
public function testSkipsCodeContent(): void
{
$html = '<p>Outside 2024-10-25</p><code>Inside 2024-10-24</code>';
$out = ChronologicalDateAutoLinker::linkHtml($html);
$decoded = urldecode($out);
if (strpos($decoded, 'chronological:2024:10:25') === false) {
throw new \Exception('Expected outside date link not found');
}
if (strpos($decoded, 'chronological:2024:10:24') !== false) {
throw new \Exception('Date inside code block should not be auto-linked');
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace dokuwiki\plugin\luxtools\test;
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
use DokuWikiTest;
require_once(__DIR__ . '/../autoload.php');
/**
* Tests for German day template generation.
*
* @group plugin_luxtools
* @group plugins
*/
class ChronologicalDayTemplateTest extends DokuWikiTest
{
public function testBuildForDayIdGermanHeading(): void
{
$tpl = ChronologicalDayTemplate::buildForDayId('chronological:2026:02:13');
if (!is_string($tpl) || $tpl === '') {
throw new \Exception('Expected non-empty day template');
}
if (strpos($tpl, '====== Freitag, 13. Februar 2026 ======') === false) {
throw new \Exception('Expected German formatted heading not found');
}
}
public function testBuildForDayIdRejectsInvalid(): void
{
$tpl = ChronologicalDayTemplate::buildForDayId('chronological:2026:02');
if ($tpl !== null) {
throw new \Exception('Expected null for non-day ID');
}
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace dokuwiki\plugin\luxtools\test;
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
use DokuWikiTest;
require_once(__DIR__ . '/../autoload.php');
/**
* Tests for local ICS event parsing.
*
* @group plugin_luxtools
* @group plugins
*/
class ChronologicalIcsEventsTest extends DokuWikiTest
{
public function testEventsForDateParsesAllDayAndTimedEntries(): void
{
$dir = TMP_DIR . '/chrono_ics/' . uniqid('case_', true);
@mkdir($dir, 0777, true);
$ics = $dir . '/calendar.ics';
$content = "BEGIN:VCALENDAR\n"
. "BEGIN:VEVENT\n"
. "DTSTART;VALUE=DATE:20260216\n"
. "SUMMARY:Ganztag Event\n"
. "END:VEVENT\n"
. "BEGIN:VEVENT\n"
. "DTSTART:20260216T134500\n"
. "SUMMARY:Termin A\n"
. "END:VEVENT\n"
. "BEGIN:VEVENT\n"
. "DTSTART:20260217T090000\n"
. "SUMMARY:Anderer Tag\n"
. "END:VEVENT\n"
. "END:VCALENDAR\n";
@file_put_contents($ics, $content);
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
if (count($events) !== 2) {
throw new \Exception('Expected 2 events for 2026-02-16, got ' . count($events));
}
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
if (!in_array('Ganztag Event', $summaries, true)) {
throw new \Exception('Missing all-day event summary');
}
if (!in_array('Termin A', $summaries, true)) {
throw new \Exception('Missing timed event summary');
}
$timed = null;
foreach ($events as $event) {
if ((string)($event['summary'] ?? '') === 'Termin A') {
$timed = $event;
break;
}
}
if (!is_array($timed)) {
throw new \Exception('Timed event payload missing');
}
if (trim((string)($timed['startIso'] ?? '')) === '') {
throw new \Exception('Timed event should expose startIso for client-side timezone conversion');
}
}
public function testEventsForDateReadsMultipleConfiguredFiles(): void
{
$dir = TMP_DIR . '/chrono_ics/' . uniqid('multi_', true);
@mkdir($dir, 0777, true);
$ics1 = $dir . '/one.ics';
$ics2 = $dir . '/two.ics';
@file_put_contents($ics1, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T100000\nSUMMARY:Eins\nEND:VEVENT\nEND:VCALENDAR\n");
@file_put_contents($ics2, "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260218T110000\nSUMMARY:Zwei\nEND:VEVENT\nEND:VCALENDAR\n");
$config = $ics1 . "\n" . $ics2;
$events = ChronologicalIcsEvents::eventsForDate($config, '2026-02-18');
if (count($events) !== 2) {
throw new \Exception('Expected 2 events from two files, got ' . count($events));
}
}
public function testEventsForDateSupportsWeeklyRecurrence(): void
{
$dir = TMP_DIR . '/chrono_ics/' . uniqid('rrule_', true);
@mkdir($dir, 0777, true);
$ics = $dir . '/recurring.ics';
$content = "BEGIN:VCALENDAR\n"
. "BEGIN:VEVENT\n"
. "UID:weekly-1\n"
. "DTSTART:20260205T090000\n"
. "RRULE:FREQ=WEEKLY;INTERVAL=1\n"
. "SUMMARY:Wiederkehrender Termin\n"
. "END:VEVENT\n"
. "END:VCALENDAR\n";
@file_put_contents($ics, $content);
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12');
if (count($events) < 1) {
throw new \Exception('Expected recurring event on 2026-02-12, got none');
}
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
if (!in_array('Wiederkehrender Termin', $summaries, true)) {
throw new \Exception('Recurring summary not found on matching date');
}
}
public function testEventsForDateRespectsExdateForRecurringEvent(): void
{
$dir = TMP_DIR . '/chrono_ics/' . uniqid('exdate_', true);
@mkdir($dir, 0777, true);
$ics = $dir . '/recurring-exdate.ics';
$content = "BEGIN:VCALENDAR\n"
. "BEGIN:VEVENT\n"
. "UID:weekly-2\n"
. "DTSTART:20260205T090000\n"
. "RRULE:FREQ=WEEKLY;COUNT=4\n"
. "EXDATE:20260212T090000\n"
. "SUMMARY:Termin mit Ausnahme\n"
. "END:VEVENT\n"
. "END:VCALENDAR\n";
@file_put_contents($ics, $content);
$events = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-12');
$summaries = array_map(static fn(array $e): string => (string)$e['summary'], $events);
if (in_array('Termin mit Ausnahme', $summaries, true)) {
throw new \Exception('Recurring event with EXDATE should not appear on excluded day');
}
}
public function testEventsForDateKeepsUtcDateAndTimeAsIs(): void
{
$previousTimezone = date_default_timezone_get();
date_default_timezone_set('Europe/Berlin');
try {
$dir = TMP_DIR . '/chrono_ics/' . uniqid('tz_', true);
@mkdir($dir, 0777, true);
$ics = $dir . '/timezone.ics';
$content = "BEGIN:VCALENDAR\n"
. "BEGIN:VEVENT\n"
. "UID:utc-shift\n"
. "DTSTART:20260216T233000Z\n"
. "SUMMARY:UTC Spaet\n"
. "END:VEVENT\n"
. "END:VCALENDAR\n";
@file_put_contents($ics, $content);
$eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
$eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17');
$summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16);
$summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17);
if (!in_array('UTC Spaet', $summaries16, true)) {
throw new \Exception('UTC event should stay on its own UTC date');
}
if (in_array('UTC Spaet', $summaries17, true)) {
throw new \Exception('UTC event should not be shifted to next day by server timezone');
}
$utcEvent = null;
foreach ($eventsOn16 as $entry) {
if ((string)($entry['summary'] ?? '') === 'UTC Spaet') {
$utcEvent = $entry;
break;
}
}
if (!is_array($utcEvent)) {
throw new \Exception('UTC event payload missing after day match');
}
if ((string)($utcEvent['time'] ?? '') !== '23:30') {
throw new \Exception('UTC event time should remain unchanged (expected 23:30)');
}
} finally {
date_default_timezone_set($previousTimezone);
}
}
public function testEventsForDateShowsMultiDayAllDayEventOnOverlappingDays(): void
{
$dir = TMP_DIR . '/chrono_ics/' . uniqid('multiday_', true);
@mkdir($dir, 0777, true);
$ics = $dir . '/multiday.ics';
$content = "BEGIN:VCALENDAR\n"
. "BEGIN:VEVENT\n"
. "UID:multi-day-1\n"
. "DTSTART;VALUE=DATE:20260216\n"
. "DTEND;VALUE=DATE:20260218\n"
. "SUMMARY:Mehrtagesereignis\n"
. "END:VEVENT\n"
. "END:VCALENDAR\n";
@file_put_contents($ics, $content);
$eventsOn16 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-16');
$eventsOn17 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-17');
$eventsOn18 = ChronologicalIcsEvents::eventsForDate($ics, '2026-02-18');
$summaries16 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn16);
$summaries17 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn17);
$summaries18 = array_map(static fn(array $e): string => (string)$e['summary'], $eventsOn18);
if (!in_array('Mehrtagesereignis', $summaries16, true)) {
throw new \Exception('Multi-day all-day event should appear on start day');
}
if (!in_array('Mehrtagesereignis', $summaries17, true)) {
throw new \Exception('Multi-day all-day event should appear on overlapping day');
}
if (in_array('Mehrtagesereignis', $summaries18, true)) {
throw new \Exception('Multi-day all-day event should respect exclusive DTEND day');
}
}
}

View File

@@ -115,9 +115,20 @@ EOT
public function testMapToAliasPath() public function testMapToAliasPath()
{ {
$mapped = $this->path->mapToAliasPath('/linux/another/path/some/folder'); $mapped = $this->path->mapToAliasPath('/linux/another/path/some/folder');
$this->assertEquals('alias/some/folder', $mapped); $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'); $unmapped = $this->path->mapToAliasPath('/linux/file/path/example.txt');
$this->assertEquals('/linux/file/path/example.txt', $unmapped); $this->assertEquals('/linux/file/path/example.txt', $unmapped);
} }
public function testMapToAliasPathLegacyAliasStyle()
{
$path = new Path("/srv/share/Datascape/\nA> /Scape/\n");
$mapped = $path->mapToAliasPath('/srv/share/Datascape/projects/demo');
$this->assertEquals('Scape>projects/demo', $mapped);
}
} }

View File

@@ -71,13 +71,14 @@ class plugin_luxtools_test extends DokuWikiTest
/** /**
* This function checks that all files are listed in not recursive mode. * This function checks that all files are listed in not recursive mode.
* Uses {{files>...}} syntax for backwards compatibility (now handled by directory syntax).
*/ */
public function test_not_recursive() public function test_not_recursive()
{ {
global $conf; global $conf;
// Render filelist // Render filelist using files syntax (now handled by directory plugin)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1}}'); $instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&direct=1}}');
$xhtml = p_render('xhtml', $instructions, $info); $xhtml = p_render('xhtml', $instructions, $info);
// We should find: // We should find:
@@ -91,11 +92,12 @@ class plugin_luxtools_test extends DokuWikiTest
/** /**
* This function checks that all files are listed in recursive mode. * This function checks that all files are listed in recursive mode.
* Uses {{files>...}} syntax for backwards compatibility (now handled by directory syntax).
*/ */
public function test_recursive() public function test_recursive()
{ {
// Render filelist // Render filelist using files syntax (now handled by directory plugin)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}'); $instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&direct=1&recursive=1}}');
$xhtml = p_render('xhtml', $instructions, $info); $xhtml = p_render('xhtml', $instructions, $info);
// We should find: // We should find:
@@ -114,54 +116,46 @@ class plugin_luxtools_test extends DokuWikiTest
} }
/** /**
* This function checks that the unordered list mode * This function checks the rendering when style=list is explicitly specified.
* generates the expected XHTML structure. * Note: The files syntax is now handled by directory syntax and always renders as table.
* This test is kept for backwards compatibility testing but expects table structure.
*/ */
public function testUnorderedList() public function testUnorderedList()
{ {
// Render filelist // Render filelist with explicit style=list (now ignored, renders as table)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}'); $instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1&recursive=1}}');
$xhtml = p_render('xhtml', $instructions, $info); $xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document(); $doc = new Document();
$doc->html($xhtml); $doc->html($xhtml);
// Now renders as a table instead of list
$structure = [ $structure = [
'div.luxtools-plugin' => 1, 'div.luxtools-plugin' => 1,
'div.luxtools-plugin > ul' => 1, 'div.luxtools-plugin table' => 1,
'div.luxtools-plugin > ul > li' => 3,
'div.luxtools-plugin > ul > li:nth-child(1)' => 1,
'div.luxtools-plugin > ul > li:nth-child(1) a' => 'example.txt',
'div.luxtools-plugin > ul > li:nth-child(2) ul' => 1,
'div.luxtools-plugin > ul > li:nth-child(2) ul > li' => 1,
'div.luxtools-plugin > ul > li:nth-child(2) ul > li a' => 'example2.txt',
]; ];
$this->structureCheck($doc, $structure); $this->structureCheck($doc, $structure);
} }
/** /**
* This function checks that the ordered list mode * This function checks the rendering when style=olist is explicitly specified.
* generates the expected XHTML structure. * Note: The files syntax is now handled by directory syntax and always renders as table.
* This test is kept for backwards compatibility testing but expects table structure.
*/ */
public function testOrderedList() public function testOrderedList()
{ {
// Render filelist // Render filelist with explicit style=olist (now ignored, renders as table)
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=olist&direct=1&recursive=1}}'); $instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=olist&direct=1&recursive=1}}');
$xhtml = p_render('xhtml', $instructions, $info); $xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document(); $doc = new Document();
$doc->html($xhtml); $doc->html($xhtml);
// Now renders as a table instead of ordered list
$structure = [ $structure = [
'div.luxtools-plugin' => 1, 'div.luxtools-plugin' => 1,
'div.luxtools-plugin > ol' => 1, 'div.luxtools-plugin table' => 1,
'div.luxtools-plugin > ol > li' => 3,
'div.luxtools-plugin > ol > li:nth-child(1)' => 1,
'div.luxtools-plugin > ol > li:nth-child(1) a' => 'example.txt',
'div.luxtools-plugin > ol > li:nth-child(2) ol' => 1,
'div.luxtools-plugin > ol > li:nth-child(2) ol > li' => 1,
'div.luxtools-plugin > ol > li:nth-child(2) ol > li a' => 'example2.txt',
]; ];
$this->structureCheck($doc, $structure); $this->structureCheck($doc, $structure);
@@ -245,6 +239,101 @@ class plugin_luxtools_test extends DokuWikiTest
$this->assertStringContainsString('height="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 * Ensure the built-in file endpoint includes the host page id so file.php can
* enforce per-page ACL. * enforce per-page ACL.
@@ -315,4 +404,121 @@ class plugin_luxtools_test extends DokuWikiTest
// Directory row should trigger the same behaviour as {{open>...}} for that folder // Directory row should trigger the same behaviour as {{open>...}} for that folder
$this->assertStringContainsString('data-path="/Scape/exampledir"', $xhtml); $this->assertStringContainsString('data-path="/Scape/exampledir"', $xhtml);
} }
/**
* Strict ISO dates in plain text should be auto-linked to canonical day IDs.
*/
public function test_auto_link_iso_date_plain_text()
{
$instructions = p_get_instructions('Meeting with John on 2024-10-24.');
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, '>2024-10-24</a>') === false) {
throw new \Exception('Auto-link text for 2024-10-24 not found');
}
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') === false) {
throw new \Exception('Auto-link target chronological:2024:10:24 not found');
}
}
/**
* Auto-linking must not run inside code blocks.
*/
public function test_auto_link_skips_code_blocks()
{
$syntax = 'Outside date 2024-10-25.' . "\n\n" . '<code>Inside code 2024-10-24</code>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, '>2024-10-25</a>') === false) {
throw new \Exception('Outside date 2024-10-25 was not auto-linked');
}
if (strpos(urldecode($xhtml), 'chronological:2024:10:25') === false) {
throw new \Exception('Outside auto-link target chronological:2024:10:25 not found');
}
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') !== false) {
throw new \Exception('Date inside code block was incorrectly auto-linked');
}
if (strpos($xhtml, 'Inside code 2024-10-24') === false) {
throw new \Exception('Code block content was unexpectedly altered');
}
}
/**
* Calendar widget should render links to canonical day IDs.
*/
public function test_calendar_widget_links_canonical_day_ids()
{
$instructions = p_get_instructions('{{calendar>2024-10}}');
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, 'luxtools-calendar') === false) {
throw new \Exception('Calendar container not rendered');
}
$decoded = urldecode($xhtml);
if (strpos($decoded, 'chronological:2024:10:01') === false) {
throw new \Exception('Expected canonical day link for 2024-10-01 not found');
}
if (strpos($decoded, 'chronological:2024:10:31') === false) {
throw new \Exception('Expected canonical day link for 2024-10-31 not found');
}
if (strpos($decoded, 'chronological:2024:10') === false) {
throw new \Exception('Expected month link chronological:2024:10 not found in header');
}
if (strpos($decoded, 'chronological:2024') === false) {
throw new \Exception('Expected year link chronological:2024 not found in header');
}
if (strpos($decoded, 'chronological:2024:09') === false) {
throw new \Exception('Expected previous month canonical ID chronological:2024:09 not found');
}
if (strpos($decoded, 'chronological:2024:11') === false) {
throw new \Exception('Expected next month canonical ID chronological:2024:11 not found');
}
if (strpos($xhtml, 'luxtools-calendar-nav') === false) {
throw new \Exception('Calendar navigation container not rendered');
}
if (strpos($xhtml, 'luxtools-calendar-nav-button') === false) {
throw new \Exception('Calendar navigation buttons not rendered');
}
if (strpos($xhtml, 'data-luxtools-calendar="1"') === false) {
throw new \Exception('Calendar JS state attribute not rendered');
}
if (strpos($xhtml, 'data-luxtools-ajax-url=') === false) {
throw new \Exception('Calendar AJAX endpoint metadata not rendered');
}
if (strpos($xhtml, 'luxtools-calendar-day') === false || strpos($xhtml, '<td class="luxtools-calendar-day') === false) {
throw new \Exception('Calendar day cells not rendered as expected');
}
}
/**
* Empty calendar target should default to current month rendering.
*/
public function test_calendar_widget_defaults_to_current_month()
{
$instructions = p_get_instructions('{{calendar>}}');
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, 'luxtools-calendar-table') === false) {
throw new \Exception('Calendar table not rendered for default month');
}
$today = date('Y-m-d');
$parts = explode('-', $today);
$expected = 'chronological:' . $parts[0] . ':' . $parts[1] . ':' . $parts[2];
if (strpos(urldecode($xhtml), $expected) === false) {
throw new \Exception('Expected canonical link for current date not found: ' . $expected);
}
}
} }

581
action.php Normal file
View File

@@ -0,0 +1,581 @@
<?php
use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\Event;
use dokuwiki\Extension\EventHandler;
use dokuwiki\plugin\luxtools\ChronoID;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
use dokuwiki\plugin\luxtools\ChronologicalDateAutoLinker;
use dokuwiki\plugin\luxtools\ChronologicalDayTemplate;
use dokuwiki\plugin\luxtools\ChronologicalIcsEvents;
require_once(__DIR__ . '/autoload.php');
/**
* luxtools action plugin: register JS assets.
*/
class action_plugin_luxtools extends ActionPlugin
{
/** @var bool Guard to prevent postprocess appenders during internal renders */
protected static $internalRenderInProgress = false;
/** @inheritdoc */
public function register(EventHandler $controller)
{
$controller->register_hook(
"TPL_METAHEADER_OUTPUT",
"BEFORE",
$this,
"addScripts",
);
$controller->register_hook(
"RENDERER_CONTENT_POSTPROCESS",
"BEFORE",
$this,
"autoLinkChronologicalDates",
);
$controller->register_hook(
"RENDERER_CONTENT_POSTPROCESS",
"BEFORE",
$this,
"appendChronologicalDayEvents",
);
$controller->register_hook(
"RENDERER_CONTENT_POSTPROCESS",
"BEFORE",
$this,
"appendChronologicalDayPhotos",
);
$controller->register_hook(
"COMMON_PAGETPL_LOAD",
"BEFORE",
$this,
"prefillChronologicalDayTemplate",
);
$controller->register_hook(
"TPL_ACT_RENDER",
"BEFORE",
$this,
"renderVirtualChronologicalDayPage",
);
$controller->register_hook(
"CSS_STYLES_INCLUDED",
"BEFORE",
$this,
"addTemporaryInputStyles",
);
$controller->register_hook(
"AJAX_CALL_UNKNOWN",
"BEFORE",
$this,
"handleCalendarWidgetAjax",
);
$controller->register_hook(
"TOOLBAR_DEFINE",
"AFTER",
$this,
"addToolbarButton",
);
}
/**
* Add plugin JavaScript files in a deterministic order.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function addScripts(Event $event, $param)
{
$plugin = $this->getPluginName();
$base = DOKU_BASE . "lib/plugins/$plugin/js/";
$scripts = [
"lightbox.js",
"gallery-thumbnails.js",
"open-service.js",
"scratchpads.js",
"date-fix.js",
"page-link.js",
"linkfavicon.js",
"calendar-widget.js",
"main.js",
];
foreach ($scripts as $script) {
$event->data["script"][] = [
"type" => "text/javascript",
"src" => $base . $script,
];
}
}
/**
* Serve server-rendered calendar widget HTML for month navigation.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function handleCalendarWidgetAjax(Event $event, $param)
{
if ($event->data !== 'luxtools_calendar_month') return;
$event->preventDefault();
$event->stopPropagation();
global $INPUT;
$year = (int)$INPUT->int('year');
$month = (int)$INPUT->int('month');
$baseNs = trim((string)$INPUT->str('base'));
if ($baseNs === '') {
$baseNs = 'chronological';
}
if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) {
http_status(400);
echo 'Invalid month';
return;
}
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs);
if ($html === '') {
http_status(500);
echo 'Calendar rendering failed';
return;
}
header('Content-Type: text/html; charset=utf-8');
echo $html;
}
/**
* Include temporary global input styling via css.php so @ini_* placeholders resolve.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function addTemporaryInputStyles(Event $event, $param)
{
if (!isset($event->data['mediatype']) || $event->data['mediatype'] !== 'screen') {
return;
}
if (!isset($event->data['files']) || !is_array($event->data['files'])) {
return;
}
$plugin = $this->getPluginName();
$event->data['files'][DOKU_PLUGIN . $plugin . '/temp-input-colors.css'] = DOKU_BASE . 'lib/plugins/' . $plugin . '/';
}
/**
* Auto-link strict ISO dates (YYYY-MM-DD) in rendered XHTML text nodes.
*
* Excludes content inside tags where links should not be altered.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function autoLinkChronologicalDates(Event $event, $param)
{
if (!is_array($event->data)) return;
$mode = (string)($event->data[0] ?? '');
if ($mode !== 'xhtml') return;
$doc = $event->data[1] ?? null;
if (!is_string($doc) || $doc === '') return;
if (!preg_match('/\d{4}-\d{2}-\d{2}/', $doc)) return;
$event->data[1] = ChronologicalDateAutoLinker::linkHtml($doc);
}
/**
* Prefill new chronological day pages with a German date headline.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function prefillChronologicalDayTemplate(Event $event, $param)
{
if (!is_array($event->data)) return;
$id = (string)($event->data['id'] ?? '');
if ($id === '') return;
if (function_exists('cleanID')) {
$id = (string)cleanID($id);
}
if ($id === '') return;
if (!ChronoID::isDayId($id)) return;
$template = ChronologicalDayTemplate::buildForDayId($id);
if ($template === null || $template === '') return;
$event->data['tpl'] = $template;
$event->data['tplfile'] = '';
$event->data['doreplace'] = false;
}
/**
* Append matching date-prefixed photos to chronological day page output.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function appendChronologicalDayPhotos(Event $event, $param)
{
if (self::$internalRenderInProgress) return;
if (!is_array($event->data)) return;
$mode = (string)($event->data[0] ?? '');
if ($mode !== 'xhtml') return;
global $ACT;
if (!is_string($ACT) || $ACT !== 'show') return;
$doc = $event->data[1] ?? null;
if (!is_string($doc)) return;
if (str_contains($doc, 'luxtools-chronological-photos')) return;
global $ID;
$id = is_string($ID) ? $ID : '';
if ($id === '') return;
if (function_exists('cleanID')) {
$id = (string)cleanID($id);
}
if ($id === '') return;
$parts = ChronoID::parseDayId($id);
if ($parts === null) return;
if (!function_exists('page_exists') || !page_exists($id)) return;
$basePath = trim((string)$this->getConf('image_base_path'));
if ($basePath === '') return;
$dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']);
if (!$this->hasAnyChronologicalPhotos($dateIso)) return;
$photosHtml = $this->renderChronologicalPhotosMacro($dateIso);
if ($photosHtml === '') return;
$event->data[1] = $doc . $photosHtml;
}
/**
* Append local calendar events to existing chronological day pages.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function appendChronologicalDayEvents(Event $event, $param)
{
static $appendInProgress = false;
if ($appendInProgress) return;
if (self::$internalRenderInProgress) return;
if (!is_array($event->data)) return;
$mode = (string)($event->data[0] ?? '');
if ($mode !== 'xhtml') return;
global $ACT;
if (!is_string($ACT) || $ACT !== 'show') return;
$doc = $event->data[1] ?? null;
if (!is_string($doc)) return;
if (str_contains($doc, 'luxtools-chronological-events')) return;
global $ID;
$id = is_string($ID) ? $ID : '';
if ($id === '') return;
if (function_exists('cleanID')) {
$id = (string)cleanID($id);
}
if ($id === '') return;
$parts = ChronoID::parseDayId($id);
if ($parts === null) return;
if (!function_exists('page_exists') || !page_exists($id)) return;
$dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']);
$appendInProgress = true;
try {
$eventsHtml = $this->renderChronologicalEventsHtml($dateIso);
} finally {
$appendInProgress = false;
}
if ($eventsHtml === '') return;
$event->data[1] = $doc . $eventsHtml;
}
/**
* Render chronological day photos using existing {{images>...}} syntax.
*
* @param string $dateIso
* @return string
*/
protected function renderChronologicalPhotosMacro(string $dateIso): string
{
$syntax = $this->buildChronologicalImagesSyntax($dateIso);
if ($syntax === '') return '';
if (self::$internalRenderInProgress) return '';
self::$internalRenderInProgress = true;
try {
$info = ['cache' => false];
$instructions = p_get_instructions($syntax);
$galleryHtml = (string)p_render('xhtml', $instructions, $info);
} finally {
self::$internalRenderInProgress = false;
}
if ($galleryHtml === '') return '';
$title = (string)$this->getLang('chronological_photos_title');
if ($title === '') $title = 'Photos';
return '<div class="luxtools-plugin luxtools-chronological-photos">'
. '<h2>' . hsc($title) . '</h2>'
. $galleryHtml
. '</div>';
}
/**
* Build {{images>...}} syntax for a given day.
*
* @param string $dateIso
* @return string
*/
protected function buildChronologicalImagesSyntax(string $dateIso): string
{
$basePath = trim((string)$this->getConf('image_base_path'));
if ($basePath === '') return '';
$base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath);
if (!is_dir($base) || !is_readable($base)) return '';
$yearDir = rtrim($base, '/') . '/' . substr($dateIso, 0, 4) . '/';
$targetDir = (is_dir($yearDir) && is_readable($yearDir)) ? $yearDir : $base;
return '{{images>' . $targetDir . $dateIso . '*&recursive=0}}';
}
/**
* Render a virtual day page for missing chronological day IDs.
*
* Shows a German date heading and existing day photos (if any) without creating the page.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function renderVirtualChronologicalDayPage(Event $event, $param)
{
if (!is_string($event->data) || $event->data !== 'show') return;
global $ID;
$id = is_string($ID) ? $ID : '';
if ($id === '') return;
if (function_exists('cleanID')) {
$id = (string)cleanID($id);
}
if ($id === '') return;
if (!ChronoID::isDayId($id)) return;
if (function_exists('page_exists') && page_exists($id)) return;
$wikiText = ChronologicalDayTemplate::buildForDayId($id) ?? '';
if ($wikiText === '') return;
$parts = ChronoID::parseDayId($id);
$extraHtml = '';
if ($parts !== null) {
$dateIso = sprintf('%04d-%02d-%02d', $parts['year'], $parts['month'], $parts['day']);
$eventsHtml = $this->renderChronologicalEventsHtml($dateIso);
if ($eventsHtml !== '') {
$extraHtml .= $eventsHtml;
}
if ($this->hasAnyChronologicalPhotos($dateIso)) {
$photosHtml = $this->renderChronologicalPhotosMacro($dateIso);
if ($photosHtml !== '') {
$extraHtml .= $photosHtml;
}
}
}
$editUrl = function_exists('wl') ? (string)wl($id, ['do' => 'edit']) : '';
$createLinkHtml = '';
if ($editUrl !== '') {
$label = (string)$this->getLang('btn_create');
if ($label === '') $label = 'Create this page';
$createLinkHtml = '<p><a href="' . hsc($editUrl) . '">✎ ' . hsc($label) . '</a></p>';
}
$info = ['cache' => false];
$instructions = p_get_instructions($wikiText);
$html = (string)p_render('xhtml', $instructions, $info);
echo $html . $createLinkHtml . $extraHtml;
$event->preventDefault();
$event->stopPropagation();
}
/**
* Check if there is at least one date-prefixed image for the given day.
*
* @param string $dateIso
* @return bool
*/
protected function hasAnyChronologicalPhotos(string $dateIso): bool
{
if (!ChronoID::isIsoDate($dateIso)) return false;
$basePath = trim((string)$this->getConf('image_base_path'));
if ($basePath === '') return false;
$base = \dokuwiki\plugin\luxtools\Path::cleanPath($basePath);
if (!is_dir($base) || !is_readable($base)) return false;
$yearDir = rtrim($base, '/') . '/' . substr($dateIso, 0, 4) . '/';
$targetDir = (is_dir($yearDir) && is_readable($yearDir)) ? $yearDir : $base;
$pattern = rtrim($targetDir, '/') . '/' . $dateIso . '*';
$matches = glob($pattern) ?: [];
foreach ($matches as $match) {
if (!is_file($match)) continue;
$ext = strtolower(pathinfo($match, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
return true;
}
}
return false;
}
/**
* Render local calendar events section for a given date.
*
* @param string $dateIso
* @return string
*/
protected function renderChronologicalEventsHtml(string $dateIso): string
{
$icsConfig = (string)$this->getConf('calendar_ics_files');
if (trim($icsConfig) === '') return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
if ($events === []) return '';
$title = (string)$this->getLang('chronological_events_title');
if ($title === '') $title = 'Events';
$items = '';
foreach ($events as $entry) {
$summary = trim((string)($entry['summary'] ?? ''));
if ($summary === '') $summary = '(ohne Titel)';
$time = trim((string)($entry['time'] ?? ''));
$startIso = trim((string)($entry['startIso'] ?? ''));
$isAllDay = (bool)($entry['allDay'] ?? false);
if ($isAllDay || $time === '') {
$items .= '<li>' . hsc($summary) . '</li>';
} else {
$timeHtml = '<span class="luxtools-event-time"';
if ($startIso !== '') {
$timeHtml .= ' data-luxtools-start="' . hsc($startIso) . '"';
}
$timeHtml .= '>' . hsc($time) . '</span>';
$items .= '<li>' . $timeHtml . ' - ' . hsc($summary) . '</li>';
}
}
if ($items === '') return '';
$html = '<ul>' . $items . '</ul>';
return '<div class="luxtools-plugin luxtools-chronological-events">'
. '<h2>' . hsc($title) . '</h2>'
. $html
. '</div>';
}
/**
* Build wiki bullet list for local calendar events.
*
* @param string $dateIso
* @return string
*/
protected function buildChronologicalEventsWiki(string $dateIso): string
{
$icsConfig = (string)$this->getConf('calendar_ics_files');
if (trim($icsConfig) === '') return '';
$events = ChronologicalIcsEvents::eventsForDate($icsConfig, $dateIso);
if ($events === []) return '';
$lines = [];
foreach ($events as $event) {
$summary = trim((string)($event['summary'] ?? ''));
if ($summary === '') $summary = '(ohne Titel)';
$summary = str_replace(["\n", "\r"], ' ', $summary);
$time = trim((string)($event['time'] ?? ''));
if ((bool)($event['allDay'] ?? false) || $time === '') {
$lines[] = ' * ' . $summary;
} else {
$lines[] = ' * ' . $time . ' - ' . $summary;
}
}
return implode("\n", $lines);
}
/**
* Add custom toolbar button for code blocks.
*
* @param Event $event
* @param mixed $param
* @return void
*/
public function addToolbarButton(Event $event, $param)
{
$event->data[] = [
"type" => "format",
"title" => $this->getLang("toolbar_code_title"),
"icon" => "../../plugins/luxtools/images/code.png",
"key" => "C",
"open" => "<code>",
"sample" => $this->getLang("toolbar_code_sample"),
"close" => "</code>",
"block" => false,
];
// Date Fix: normalize selected timestamp
$event->data[] = [
"type" => "LuxtoolsDatefix",
"title" => $this->getLang("toolbar_datefix_title"),
"icon" => "../../plugins/luxtools/images/date-fix.svg",
"key" => "t",
"block" => false,
];
// Date Fix All: normalize all timestamps on page
$event->data[] = [
"type" => "LuxtoolsDatefixAll",
"title" => $this->getLang("toolbar_datefix_all_title"),
"icon" => "../../plugins/luxtools/images/date-fix-all.svg",
"block" => false,
];
}
}

View File

@@ -12,11 +12,9 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
protected $configKeys = [ protected $configKeys = [
'paths', 'paths',
'scratchpad_paths', 'scratchpad_paths',
'allow_in_comments',
'extensions', 'extensions',
'default_sort', 'default_sort',
'default_order', 'default_order',
'default_style',
'default_tableheader', 'default_tableheader',
'default_foldersfirst', 'default_foldersfirst',
'default_recursive', 'default_recursive',
@@ -25,11 +23,14 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'default_randlinks', 'default_randlinks',
'default_showsize', 'default_showsize',
'default_showdate', 'default_showdate',
'default_listsep', 'default_tablecolumns',
'default_maxheight', 'default_maxheight',
'thumb_placeholder', 'thumb_placeholder',
'gallery_thumb_scale', 'gallery_thumb_scale',
'open_service_url', 'open_service_url',
'image_base_path',
'calendar_ics_files',
'pagelink_search_depth',
]; ];
public function getMenuText($language) public function getMenuText($language)
@@ -69,12 +70,10 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
$scratchpadPaths = str_replace(["\r\n", "\r"], "\n", $scratchpadPaths); $scratchpadPaths = str_replace(["\r\n", "\r"], "\n", $scratchpadPaths);
$newConf['scratchpad_paths'] = $scratchpadPaths; $newConf['scratchpad_paths'] = $scratchpadPaths;
$newConf['allow_in_comments'] = (int)$INPUT->bool('allow_in_comments');
$newConf['extensions'] = $INPUT->str('extensions'); $newConf['extensions'] = $INPUT->str('extensions');
$newConf['default_sort'] = $INPUT->str('default_sort'); $newConf['default_sort'] = $INPUT->str('default_sort');
$newConf['default_order'] = $INPUT->str('default_order'); $newConf['default_order'] = $INPUT->str('default_order');
$newConf['default_style'] = $INPUT->str('default_style');
$newConf['default_tableheader'] = (int)$INPUT->bool('default_tableheader'); $newConf['default_tableheader'] = (int)$INPUT->bool('default_tableheader');
$newConf['default_foldersfirst'] = (int)$INPUT->bool('default_foldersfirst'); $newConf['default_foldersfirst'] = (int)$INPUT->bool('default_foldersfirst');
$newConf['default_recursive'] = (int)$INPUT->bool('default_recursive'); $newConf['default_recursive'] = (int)$INPUT->bool('default_recursive');
@@ -83,12 +82,21 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
$newConf['default_randlinks'] = (int)$INPUT->bool('default_randlinks'); $newConf['default_randlinks'] = (int)$INPUT->bool('default_randlinks');
$newConf['default_showsize'] = (int)$INPUT->bool('default_showsize'); $newConf['default_showsize'] = (int)$INPUT->bool('default_showsize');
$newConf['default_showdate'] = (int)$INPUT->bool('default_showdate'); $newConf['default_showdate'] = (int)$INPUT->bool('default_showdate');
$newConf['default_listsep'] = $INPUT->str('default_listsep'); $newConf['default_tablecolumns'] = $INPUT->str('default_tablecolumns');
$newConf['default_maxheight'] = $INPUT->str('default_maxheight'); $newConf['default_maxheight'] = $INPUT->str('default_maxheight');
$newConf['thumb_placeholder'] = $INPUT->str('thumb_placeholder'); $newConf['thumb_placeholder'] = $INPUT->str('thumb_placeholder');
$newConf['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale'); $newConf['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale');
$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');
$icsFiles = $INPUT->str('calendar_ics_files');
$icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles);
$newConf['calendar_ics_files'] = $icsFiles;
$depth = (int)$INPUT->int('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$newConf['pagelink_search_depth'] = $depth;
if ($this->savePluginLocalConf($newConf)) { if ($this->savePluginLocalConf($newConf)) {
msg($this->getLang('saved'), 1); msg($this->getLang('saved'), 1);
@@ -115,23 +123,17 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<legend>' . hsc($this->getLang('legend')) . '</legend>'; echo '<legend>' . hsc($this->getLang('legend')) . '</legend>';
// paths: multiline textarea // paths: multiline textarea
$paths = (string)$this->getConf('paths'); $paths = $this->normalizeMultilineDisplay((string)$this->getConf('paths'), 'paths');
echo '<label class="block"><span>' . hsc($this->getLang('paths')) . '</span><br />'; echo '<label class="block"><span>' . hsc($this->getLang('paths')) . '</span><br />';
echo '<textarea name="paths" rows="8" cols="80" class="edit">' . hsc($paths) . '</textarea>'; echo '<textarea name="paths" rows="8" cols="80" class="edit">' . hsc($paths) . '</textarea>';
echo '</label><br />'; echo '</label><br />';
// scratchpad_paths: multiline textarea // scratchpad_paths: multiline textarea
$scratchpadPaths = (string)$this->getConf('scratchpad_paths'); $scratchpadPaths = $this->normalizeMultilineDisplay((string)$this->getConf('scratchpad_paths'), 'scratchpad_paths');
echo '<label class="block"><span>' . hsc($this->getLang('scratchpad_paths')) . '</span><br />'; echo '<label class="block"><span>' . hsc($this->getLang('scratchpad_paths')) . '</span><br />';
echo '<textarea name="scratchpad_paths" rows="6" cols="80" class="edit">' . hsc($scratchpadPaths) . '</textarea>'; echo '<textarea name="scratchpad_paths" rows="6" cols="80" class="edit">' . hsc($scratchpadPaths) . '</textarea>';
echo '</label><br />'; echo '</label><br />';
// allow_in_comments
$checked = $this->getConf('allow_in_comments') ? ' checked="checked"' : '';
echo '<label class="block"><span>' . hsc($this->getLang('allow_in_comments')) . '</span> ';
echo '<input type="checkbox" name="allow_in_comments" value="1"' . $checked . ' />';
echo '</label><br />';
// extensions // extensions
echo '<label class="block"><span>' . hsc($this->getLang('extensions')) . '</span> '; echo '<label class="block"><span>' . hsc($this->getLang('extensions')) . '</span> ';
echo '<input type="text" class="edit" name="extensions" value="' . hsc((string)$this->getConf('extensions')) . '" />'; echo '<input type="text" class="edit" name="extensions" value="' . hsc((string)$this->getConf('extensions')) . '" />';
@@ -161,17 +163,6 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '</select>'; echo '</select>';
echo '</label><br />'; echo '</label><br />';
// default_style
$defaultStyle = (string)$this->getConf('default_style');
echo '<label class="block"><span>' . hsc($this->getLang('default_style')) . '</span>';
echo '<select name="default_style" class="edit">';
foreach (['list', 'olist', 'table'] as $opt) {
$sel = ($defaultStyle === $opt) ? ' selected="selected"' : '';
echo '<option value="' . hsc($opt) . '"' . $sel . '>' . hsc($opt) . '</option>';
}
echo '</select>';
echo '</label><br />';
// default_tableheader // default_tableheader
$checked = $this->getConf('default_tableheader') ? ' checked="checked"' : ''; $checked = $this->getConf('default_tableheader') ? ' checked="checked"' : '';
echo '<label class="block"><span>' . hsc($this->getLang('default_tableheader')) . '</span> '; echo '<label class="block"><span>' . hsc($this->getLang('default_tableheader')) . '</span> ';
@@ -219,9 +210,9 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<input type="checkbox" name="default_showdate" value="1"' . $checked . ' />'; echo '<input type="checkbox" name="default_showdate" value="1"' . $checked . ' />';
echo '</label><br />'; echo '</label><br />';
// default_listsep // default_tablecolumns
echo '<label class="block"><span>' . hsc($this->getLang('default_listsep')) . '</span>'; echo '<label class="block"><span>' . hsc($this->getLang('default_tablecolumns')) . '</span>';
echo '<input type="text" class="edit" name="default_listsep" value="' . hsc((string)$this->getConf('default_listsep')) . '" />'; echo '<input type="text" class="edit" name="default_tablecolumns" value="' . hsc((string)$this->getConf('default_tablecolumns')) . '" />';
echo '</label><br />'; echo '</label><br />';
// default_maxheight // default_maxheight
@@ -244,6 +235,22 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<input type="text" class="edit" name="open_service_url" value="' . hsc((string)$this->getConf('open_service_url')) . '" />'; echo '<input type="text" class="edit" name="open_service_url" value="' . hsc((string)$this->getConf('open_service_url')) . '" />';
echo '</label><br />'; echo '</label><br />';
// image_base_path
echo '<label class="block"><span>' . hsc($this->getLang('image_base_path')) . '</span> ';
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
echo '</label><br />';
// calendar_ics_files
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files');
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />';
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>';
echo '</label><br />';
// pagelink_search_depth
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
echo '</label><br />';
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>'; echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
echo '</fieldset>'; echo '</fieldset>';
@@ -359,4 +366,24 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
return var_export($value, true); return var_export($value, true);
} }
/**
* Strip nowdoc markers from values when displaying in the admin form.
*
* @param string $value
* @param string $key
* @return string
*/
protected function normalizeMultilineDisplay(string $value, string $key): string
{
$marker = strtoupper('LUXTOOLS_' . preg_replace('/[^A-Z0-9_]/i', '_', $key) . '_EOT');
$prefix = "<<<'$marker'\n";
$suffix = "\n$marker";
if (str_starts_with($value, $prefix) && str_ends_with($value, $suffix)) {
return substr($value, strlen($prefix), -strlen($suffix));
}
return $value;
}
} }

View File

@@ -9,6 +9,11 @@
* This file registers a minimal autoloader for the plugin namespace. * This file registers a minimal autoloader for the plugin namespace.
*/ */
$composerAutoload = __DIR__ . '/vendor/autoload.php';
if (is_file($composerAutoload)) {
require_once $composerAutoload;
}
spl_autoload_register(static function ($class) { spl_autoload_register(static function ($class) {
$prefix = 'dokuwiki\\plugin\\luxtools\\'; $prefix = 'dokuwiki\\plugin\\luxtools\\';
$prefixLen = strlen($prefix); $prefixLen = strlen($prefix);

5
composer.json Normal file
View File

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

252
composer.lock generated Normal file
View File

@@ -0,0 +1,252 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "440454aa6bd2975652e94f60998e9adc",
"packages": [
{
"name": "sabre/uri",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2024-09-04T15:30:08+00:00"
},
{
"name": "sabre/vobject",
"version": "4.5.8",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2026-01-12T10:45:19+00:00"
},
{
"name": "sabre/xml",
"version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.4 || ^8.0",
"sabre/uri": ">=2.0,<4.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2024-09-06T08:00:55+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@@ -6,15 +6,13 @@
$conf['paths'] = ''; $conf['paths'] = '';
$conf['scratchpad_paths'] = ''; $conf['scratchpad_paths'] = '';
$conf['allow_in_comments'] = 0;
// Legacy (advanced): additional default flags in the same syntax as inline options. // Legacy (advanced): additional default flags in the same syntax as inline options.
$conf['defaults'] = ''; $conf['defaults'] = '';
$conf['extensions'] = ''; $conf['extensions'] = '';
// Listing defaults (applied to files/images/directory unless overridden inline) // Listing defaults (applied to directory/images unless overridden inline)
$conf['default_sort'] = 'name'; // name|iname|ctime|mtime|size $conf['default_sort'] = 'name'; // name|iname|ctime|mtime|size
$conf['default_order'] = 'asc'; // asc|desc $conf['default_order'] = 'asc'; // asc|desc
$conf['default_style'] = 'list'; // list|olist|table
$conf['default_tableheader'] = 0; // 0|1 $conf['default_tableheader'] = 0; // 0|1
$conf['default_foldersfirst'] = 0; // 0|1 $conf['default_foldersfirst'] = 0; // 0|1
$conf['default_recursive'] = 0; // 0|1 $conf['default_recursive'] = 0; // 0|1
@@ -23,7 +21,7 @@ $conf['default_cache'] = 0; // 0|1
$conf['default_randlinks'] = 0; // 0|1 $conf['default_randlinks'] = 0; // 0|1
$conf['default_showsize'] = 0; // 0|1 $conf['default_showsize'] = 0; // 0|1
$conf['default_showdate'] = 0; // 0|1 $conf['default_showdate'] = 0; // 0|1
$conf['default_listsep'] = ', '; $conf['default_tablecolumns'] = 'name'; // Comma-separated: name, size, date
$conf['default_maxheight'] = 500; // -1 disables scroll container $conf['default_maxheight'] = 500; // -1 disables scroll container
// MediaManager ID for gallery thumbnail placeholder // MediaManager ID for gallery thumbnail placeholder
@@ -35,3 +33,16 @@ $conf['gallery_thumb_scale'] = 1;
// Local client service used by {{open>...}}. // Local client service used by {{open>...}}.
$conf['open_service_url'] = 'http://127.0.0.1:8765'; $conf['open_service_url'] = 'http://127.0.0.1:8765';
// Base filesystem path for chronological photo integration.
$conf['image_base_path'] = '';
// Local calendar ICS files (one absolute file path per line).
$conf['calendar_ics_files'] = '';
// Maximum depth when searching for .pagelink files under allowed roots.
$conf['pagelink_search_depth'] = 3;
// Image syntax defaults
$conf['default_image_width'] = 250;
$conf['default_image_align'] = 'right'; // left|right|center

View File

@@ -68,6 +68,8 @@ RSYNC_ARGS=(
--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r
--exclude=deploy.sh --exclude=deploy.sh
--exclude=.git/ --exclude=.git/
--exclude=_dokuwiki/
--exclude=_agent-data/
--exclude=.github/ --exclude=.github/
--exclude=.vscode/ --exclude=.vscode/
--exclude=_test/ --exclude=_test/
@@ -77,6 +79,7 @@ RSYNC_ARGS=(
--exclude=.DS_Store --exclude=.DS_Store
) )
if ((DRY_RUN)); then if ((DRY_RUN)); then
RSYNC_ARGS+=(--dry-run) RSYNC_ARGS+=(--dry-run)
fi fi
@@ -90,4 +93,20 @@ echo "Deploying luxtools to: $TARGET/"
rsync "${RSYNC_ARGS[@]}" "$SRC_DIR/" "$TARGET/" rsync "${RSYNC_ARGS[@]}" "$SRC_DIR/" "$TARGET/"
# Invalidate DokuWiki cache by touching conf/local.php
# This forces DokuWiki to rebuild JavaScript/CSS bundles
CONF_LOCAL="$(dirname "$TARGET")/../../conf/local.php"
if [[ -f "$CONF_LOCAL" ]]; then
if ((DRY_RUN)); then
echo "(dry-run) Would touch $CONF_LOCAL to invalidate cache"
elif touch "$CONF_LOCAL" 2>/dev/null; then
echo "Cache invalidated (touched conf/local.php)"
else
echo "Note: Cannot touch conf/local.php (permission denied)."
echo " Run 'touch conf/local.php' on the server to clear cache."
fi
else
echo "Note: conf/local.php not found at expected path, skip cache invalidation."
fi
echo "Done." echo "Done."

View File

@@ -10,6 +10,15 @@ if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
if (!defined('DOKU_DISABLE_GZIP_OUTPUT')) define('DOKU_DISABLE_GZIP_OUTPUT', 1); // we gzip ourself here if (!defined('DOKU_DISABLE_GZIP_OUTPUT')) define('DOKU_DISABLE_GZIP_OUTPUT', 1); // we gzip ourself here
require_once(DOKU_INC . 'inc/init.php'); require_once(DOKU_INC . 'inc/init.php');
// Close the session early to prevent blocking concurrent requests.
// PHP sessions are locked by default - if we hold the lock during thumbnail
// generation, all other requests from this user (including page navigation)
// will be blocked until we finish. Since we only need session data for ACL
// checks (which happen before this point via init.php), we can safely close it.
if (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
global $INPUT; global $INPUT;
$syntax = plugin_load('syntax', 'luxtools'); $syntax = plugin_load('syntax', 'luxtools');
@@ -224,7 +233,7 @@ try {
echo 'Path not readable: ' . $pathInfo['path']; echo 'Path not readable: ' . $pathInfo['path'];
exit; exit;
} }
[$ext, $mime, $download] = mimetype($pathInfo['path'], false); [, $mime, $download] = mimetype($pathInfo['path'], false);
// Optional thumbnail mode: ?thumb=1&w=150&h=150 // Optional thumbnail mode: ?thumb=1&w=150&h=150
$thumb = (int)$INPUT->int('thumb'); $thumb = (int)$INPUT->int('thumb');

BIN
images/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

3
images/code.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#333" d="M5.5 4L2 8l3.5 4 1-1.5L4 8l2.5-2.5L5.5 4zm5 0l-1 1.5L12 8l-2.5 2.5 1 1.5L14 8l-3.5-4z"/>
</svg>

After

Width:  |  Height:  |  Size: 203 B

10
images/date-fix-all.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<rect x="2" y="3" width="12" height="11" rx="1" ry="1" fill="none" stroke="#000" stroke-width="1" />
<rect x="2" y="5" width="12" height="2" fill="#000" />
<rect x="4" y="1" width="2" height="4" fill="#000" />
<rect x="10" y="1" width="2" height="4" fill="#000" />
<path d="M4 9h8" stroke="#000" stroke-width="1" />
<path d="M4 11h8" stroke="#000" stroke-width="1" />
<circle cx="13" cy="13" r="2.2" fill="#000" />
<path d="M12.2 13l0.6 0.6 1-1" stroke="#fff" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 665 B

8
images/date-fix.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<rect x="2" y="3" width="12" height="11" rx="1" ry="1" fill="none" stroke="#000" stroke-width="1" />
<rect x="2" y="5" width="12" height="2" fill="#000" />
<rect x="4" y="1" width="2" height="4" fill="#000" />
<rect x="10" y="1" width="2" height="4" fill="#000" />
<path d="M5 9h6" stroke="#000" stroke-width="1" />
<path d="M5 11h4" stroke="#000" stroke-width="1" />
</svg>

After

Width:  |  Height:  |  Size: 490 B

5
images/pagelink.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 7l-1.5 1.5" />
<path d="M14 11a5 5 0 0 1 0 7l-2 2a5 5 0 0 1-7-7L6.5 11.5" />
<path d="M8 12h8" />
</svg>

After

Width:  |  Height:  |  Size: 341 B

5
images/placeholder.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

129
js/calendar-widget.js Normal file
View File

@@ -0,0 +1,129 @@
/* global window, document, fetch, URLSearchParams */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
function findCalendarRoot(target) {
var el = target;
while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-calendar') && el.getAttribute('data-luxtools-calendar') === '1') {
return el;
}
el = el.parentNode;
}
return null;
}
function getNextMonth(year, month, direction) {
var cursor = new Date(year, month - 1, 1);
cursor.setMonth(cursor.getMonth() + direction);
return {
year: cursor.getFullYear(),
month: cursor.getMonth() + 1
};
}
function parseCalendarFromHtml(html) {
if (!html) return null;
var wrapper = document.createElement('div');
wrapper.innerHTML = html;
return wrapper.querySelector('div.luxtools-calendar[data-luxtools-calendar="1"]');
}
function setCalendarBusy(calendar, busy) {
if (!calendar) return;
if (busy) {
calendar.setAttribute('data-luxtools-loading', '1');
} else {
calendar.removeAttribute('data-luxtools-loading');
}
var buttons = calendar.querySelectorAll('button.luxtools-calendar-nav-button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].disabled = !!busy;
}
}
function fetchCalendarMonth(calendar, year, month) {
var ajaxUrl = calendar.getAttribute('data-luxtools-ajax-url') || '';
if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url'));
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
var params = new URLSearchParams({
call: 'luxtools_calendar_month',
year: String(year),
month: String(month),
base: baseNs
});
var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString();
return fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (response) {
if (!response.ok) {
throw new Error('Calendar request failed: ' + response.status);
}
return response.text();
});
}
function navigateCalendarMonth(calendar, direction) {
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
if (!year || !month) return;
var next = getNextMonth(year, month, direction);
setCalendarBusy(calendar, true);
fetchCalendarMonth(calendar, next.year, next.month)
.then(function (html) {
var replacement = parseCalendarFromHtml(html);
if (!replacement) {
throw new Error('Calendar markup missing in response');
}
calendar.replaceWith(replacement);
})
.catch(function () {
var fallbackLink = calendar.querySelector('a.luxtools-calendar-month-link');
if (fallbackLink && fallbackLink.href) {
window.location.href = fallbackLink.href;
}
})
.finally(function () {
setCalendarBusy(calendar, false);
});
}
function onCalendarClick(event) {
var target = event.target;
if (!target || !target.classList || !target.classList.contains('luxtools-calendar-nav-button')) return;
var calendar = findCalendarRoot(target);
if (!calendar) return;
var direction = parseInt(target.getAttribute('data-luxtools-dir') || '0', 10);
if (direction !== -1 && direction !== 1) return;
event.preventDefault();
navigateCalendarMonth(calendar, direction);
}
function initCalendarWidgets() {
document.addEventListener('click', onCalendarClick, false);
}
Luxtools.CalendarWidget = {
init: initCalendarWidgets
};
})();

235
js/date-fix.js Normal file
View File

@@ -0,0 +1,235 @@
/* global window, document, DWgetSelection, DWsetSelection, pasteText */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
var DateFix = Luxtools.DateFix || (Luxtools.DateFix = {});
// Month name patterns for regex (English and German)
var MONTH_PATTERN = '(?:jan|feb|m[aä]r|apr|ma[iy]|jun|jul|aug|sep|sept|okt|oct|nov|de[cz])[a-z]*\\.?';
// Regex to find date candidates in text for "fix all" feature
var CANDIDATE_REGEX = new RegExp(
'\\b(?:' +
'\\d{4}[-\\/.][\\d]{1,2}[-\\/.][\\d]{1,2}|' + // YYYY-MM-DD
'\\d{1,2}[-\\/.][\\d]{1,2}[-\\/.][\\d]{4}|' + // DD-MM-YYYY
MONTH_PATTERN + '\\s+\\d{1,2}(?:st|nd|rd|th)?[,]?\\s+\\d{4}|' + // Month DD, YYYY
'\\d{1,2}(?:st|nd|rd|th)?\\.?\\s+' + MONTH_PATTERN + '\\s+\\d{4}|' + // DD Month YYYY
'\\d{4}\\s+' + MONTH_PATTERN + '\\s+\\d{1,2}' + // YYYY Month DD
')(?:[T\\s]+\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\s*(?:am|pm))?)?\\b',
'gi'
);
// Map month names (English and German) to month numbers (1-12)
var MONTH_MAP = {
'jan': 1, 'januar': 1, 'january': 1,
'feb': 2, 'februar': 2, 'february': 2,
'mar': 3, 'mär': 3, 'märz': 3, 'march': 3, 'maerz': 3,
'apr': 4, 'april': 4,
'may': 5, 'mai': 5,
'jun': 6, 'juni': 6, 'june': 6,
'jul': 7, 'juli': 7, 'july': 7,
'aug': 8, 'august': 8,
'sep': 9, 'sept': 9, 'september': 9,
'oct': 10, 'okt': 10, 'oktober': 10, 'october': 10,
'nov': 11, 'november': 11,
'dec': 12, 'dez': 12, 'dezember': 12, 'december': 12
};
function pad2(n) {
return n < 10 ? '0' + n : String(n);
}
/**
* Look up a month name (English or German) and return its number (1-12).
* Returns null if not found.
*/
function parseMonthName(name) {
if (!name) return null;
var key = name.toLowerCase().replace(/\.$/, '');
return MONTH_MAP[key] || null;
}
/**
* Preprocess input to make it parseable by Date.
* Handles formats Date.parse() doesn't understand natively.
*/
function preprocess(input) {
var s = input
.replace(/^\s*[([{"'`]+|[)\]}",'`]+\s*$/g, '') // strip surrounding brackets/quotes
.replace(/\s*(Z|[+-]\d{2}:?\d{2})$/i, '') // strip timezone
.replace(/(\d)(st|nd|rd|th)\b/gi, '$1') // strip ordinal suffixes
.replace(/,/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// Handle month names (English and German) - convert to YYYY-MM-DD format
// Pattern: DD Month YYYY or DD. Month YYYY
var monthMatch = s.match(/^(\d{1,2})\.?\s+([a-zäö]+)\.?\s+(\d{4})(.*)$/i);
if (monthMatch) {
var day = parseInt(monthMatch[1], 10);
var monthNum = parseMonthName(monthMatch[2]);
var year = monthMatch[3];
var rest = monthMatch[4] || '';
if (monthNum) {
return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
}
}
// Pattern: Month DD, YYYY
monthMatch = s.match(/^([a-zäö]+)\.?\s+(\d{1,2})\s+(\d{4})(.*)$/i);
if (monthMatch) {
var monthNum = parseMonthName(monthMatch[1]);
var day = parseInt(monthMatch[2], 10);
var year = monthMatch[3];
var rest = monthMatch[4] || '';
if (monthNum) {
return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
}
}
// Pattern: YYYY Month DD
monthMatch = s.match(/^(\d{4})\s+([a-zäö]+)\.?\s+(\d{1,2})(.*)$/i);
if (monthMatch) {
var year = monthMatch[1];
var monthNum = parseMonthName(monthMatch[2]);
var day = parseInt(monthMatch[3], 10);
var rest = monthMatch[4] || '';
if (monthNum) {
return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
}
}
// DD.MM.YYYY or DD/MM/YYYY -> rearrange to YYYY-MM-DD for Date
var match = s.match(/^(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{4})(.*)$/);
if (match) {
var a = parseInt(match[1], 10);
var b = parseInt(match[2], 10);
var year = match[3];
var rest = match[4] || '';
// Disambiguate: if first > 12, it must be day (DMY); else assume DMY
var day = a > 12 ? a : (b > 12 ? b : a);
var month = a > 12 ? b : (b > 12 ? a : b);
s = year + '-' + pad2(month) + '-' + pad2(day) + rest;
}
return s;
}
/**
* Try to parse a date/time string into a Date object.
* Returns null if parsing fails or produces an invalid date.
*/
function tryParse(input) {
if (!input || typeof input !== 'string') return null;
var preprocessed = preprocess(input);
if (!preprocessed) return null;
// Try native parsing
var ts = Date.parse(preprocessed);
if (!isNaN(ts)) return new Date(ts);
// Fallback: replace dots/slashes with dashes and try again
var normalized = preprocessed.replace(/[\/\.]/g, '-');
ts = Date.parse(normalized);
if (!isNaN(ts)) return new Date(ts);
return null;
}
/**
* Check if the original input contained a time component.
*/
function hasTimeComponent(input) {
return /\d{1,2}:\d{2}/.test(input);
}
/**
* Format a Date object to YYYY-MM-DD or YYYY-MM-DD HH:MM:SS.
*/
function formatDate(date, includeTime) {
var out = date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
if (includeTime) {
out += ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
}
return out;
}
/**
* Normalize a timestamp string to YYYY-MM-DD (or YYYY-MM-DD HH:MM:SS if time present).
* Returns null if the input cannot be parsed as a valid date.
*/
function normalizeTimestamp(input) {
var date = tryParse(input);
if (!date) return null;
return formatDate(date, hasTimeComponent(input));
}
function getEditor(edid) {
return document.getElementById(edid);
}
function fixSelection(edid) {
if (typeof DWgetSelection !== 'function' || typeof pasteText !== 'function') return false;
var textarea = getEditor(edid);
if (!textarea) return false;
var selection = DWgetSelection(textarea);
var text = selection.getText();
if (!text || !text.trim()) return false;
var normalized = normalizeTimestamp(text);
if (!normalized) return false;
pasteText(selection, normalized, { nosel: true });
return false;
}
function fixAll(edid) {
var textarea = getEditor(edid);
if (!textarea) return false;
var selection = typeof DWgetSelection === 'function' ? DWgetSelection(textarea) : null;
var original = textarea.value;
var replaced = original.replace(CANDIDATE_REGEX, function (match) {
var normalized = normalizeTimestamp(match);
return normalized || match;
});
if (replaced !== original) {
textarea.value = replaced;
if (selection && typeof DWsetSelection === 'function') {
selection.start = Math.min(selection.start, textarea.value.length);
selection.end = Math.min(selection.end, textarea.value.length);
DWsetSelection(selection);
}
}
return false;
}
DateFix.normalize = normalizeTimestamp;
DateFix.fixSelection = fixSelection;
DateFix.fixAll = fixAll;
// Toolbar button action handlers
// DokuWiki toolbar looks for addBtnAction<Type> functions for custom button types.
// The buttons are registered via PHP (TOOLBAR_DEFINE hook in action.php).
window.addBtnActionLuxtoolsDatefix = function ($btn, props, edid) {
$btn.on('click', function () {
fixSelection(edid);
return false;
});
return 'luxtools-datefix';
};
window.addBtnActionLuxtoolsDatefixAll = function ($btn, props, edid) {
$btn.on('click', function () {
fixAll(edid);
return false;
});
return 'luxtools-datefix-all';
};
})();

112
js/gallery-thumbnails.js Normal file
View File

@@ -0,0 +1,112 @@
/* global window, document */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
// ============================================================
// Gallery Thumbnails Module
// ============================================================
// Uses fetch() with AbortController to load thumbnails.
// This allows true HTTP request cancellation on navigation,
// unlike native loading="lazy" where queued requests block.
Luxtools.GalleryThumbnails = (function () {
var controller = null;
var maxConcurrent = 4;
var activeCount = 0;
var queue = [];
function abortAll() {
if (controller) {
controller.abort();
controller = null;
}
queue = [];
activeCount = 0;
}
function processQueue() {
if (!controller) return;
while (activeCount < maxConcurrent && queue.length > 0) {
var img = queue.shift();
loadThumb(img);
}
}
function loadThumb(img) {
if (!controller) return;
var src = img.getAttribute('data-src');
if (!src) {
processQueue();
return;
}
activeCount++;
fetch(src, { signal: controller.signal })
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.blob();
})
.then(function (blob) {
img.src = URL.createObjectURL(blob);
img.removeAttribute('data-src');
})
.catch(function (err) {
// Aborted or failed - ignore
if (err.name !== 'AbortError') {
// Keep data-src for potential retry, just log
}
})
.finally(function () {
activeCount--;
processQueue();
});
}
function queueThumb(img) {
if (!controller) return;
if (!img.getAttribute('data-src')) return;
if (img.getAttribute('data-queued') === '1') return;
img.setAttribute('data-queued', '1');
queue.push(img);
processQueue();
}
function init() {
var imgs = document.querySelectorAll(
'div.luxtools-gallery img.luxtools-thumb[data-src], div.luxtools-imagebox img[data-src]'
);
if (!imgs || !imgs.length) return;
// Create abort controller for all requests
controller = new AbortController();
// Abort all pending requests on navigation
window.addEventListener('beforeunload', abortAll);
window.addEventListener('pagehide', abortAll);
// Use IntersectionObserver to trigger loading
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
queueThumb(entry.target);
io.unobserve(entry.target);
});
}, { rootMargin: '200px' });
imgs.forEach(function (img) { io.observe(img); });
} else {
// Fallback: queue all
imgs.forEach(queueThumb);
}
}
return {
init: init
};
})();
})();

265
js/lightbox.js Normal file
View File

@@ -0,0 +1,265 @@
/* global window, document */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
// ============================================================
// Lightbox Module
// ============================================================
Luxtools.Lightbox = (function () {
var lb = null;
var img = null;
var cap = null;
var items = [];
var index = 0;
// Zoom/pan state
var scale = 1;
var panX = 0;
var panY = 0;
var minScale = 1;
var maxScale = 5;
var isPanning = false;
var panStartX = 0;
var panStartY = 0;
// History state
var pushedHistory = false;
var closingFromPopstate = false;
function ensureElement() {
if (lb) return;
lb = document.createElement('div');
lb.className = 'luxtools-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-hidden', 'true');
lb.innerHTML =
'<div class="luxtools-lightbox-backdrop" data-luxtools-action="close"></div>' +
'<div class="luxtools-lightbox-stage">' +
'<button type="button" class="luxtools-lightbox-close" data-luxtools-action="close" aria-label="Close">×</button>' +
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-prev" data-luxtools-action="prev" aria-label="Previous"></button>' +
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-next" data-luxtools-action="next" aria-label="Next"></button>' +
'<div class="luxtools-lightbox-media">' +
'<img class="luxtools-lightbox-img" alt="" />' +
'</div>' +
'<div class="luxtools-lightbox-caption"></div>' +
'</div>';
document.body.appendChild(lb);
img = lb.querySelector('img.luxtools-lightbox-img');
cap = lb.querySelector('.luxtools-lightbox-caption');
lb.addEventListener('click', onClick);
}
function clampIndex(n) {
if (n < 0) return items.length - 1;
if (n >= items.length) return 0;
return n;
}
function applyTransform() {
if (scale <= 1 && panX === 0 && panY === 0) {
img.style.transform = '';
} else {
img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)';
}
img.style.cursor = scale > 1 ? 'grab' : '';
}
function resetZoom() {
scale = 1;
panX = 0;
panY = 0;
applyTransform();
}
function render() {
var it = items[index];
img.src = it.full;
img.setAttribute('data-luxtools-index', String(index));
if (cap) cap.textContent = (it.name || '').trim();
resetZoom();
}
function next() {
index = clampIndex(index + 1);
render();
}
function prev() {
index = clampIndex(index - 1);
render();
}
// Event handlers
function onWheel(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? -0.15 : 0.15;
scale = Math.max(minScale, Math.min(maxScale, scale + delta));
if (scale <= 1) { panX = 0; panY = 0; }
applyTransform();
}
function onDblClick(e) {
e.preventDefault();
if (scale > 1) {
scale = 1;
panX = 0;
panY = 0;
} else {
scale = 2.5;
}
applyTransform();
}
function onMouseDown(e) {
if (scale > 1 && e.button === 0) {
isPanning = true;
panStartX = e.clientX - panX * scale;
panStartY = e.clientY - panY * scale;
img.style.cursor = 'grabbing';
e.preventDefault();
}
}
function onMouseMove(e) {
if (isPanning && scale > 1) {
panX = (e.clientX - panStartX) / scale;
panY = (e.clientY - panStartY) / scale;
applyTransform();
img.style.cursor = 'grabbing';
}
}
function onMouseUp() {
isPanning = false;
img.style.cursor = scale > 1 ? 'grab' : '';
}
function onKeyDown(e) {
if (!lb || !lb.classList.contains('is-open')) return;
var key = e.key || '';
if (key === 'Escape') {
e.preventDefault();
close();
} else if (key === 'ArrowRight') {
e.preventDefault();
next();
} else if (key === 'ArrowLeft') {
e.preventDefault();
prev();
}
}
function onPopState() {
if (!lb || !lb.classList.contains('is-open')) return;
closingFromPopstate = true;
try { close(); } finally { closingFromPopstate = false; }
}
function onClick(e) {
var t = e.target;
if (!t || !t.getAttribute) return;
var action = t.getAttribute('data-luxtools-action') || '';
if (action === 'close') { e.preventDefault(); close(); return; }
if (action === 'next') { e.preventDefault(); next(); return; }
if (action === 'prev') { e.preventDefault(); prev(); return; }
if (t.closest && t.closest('button.luxtools-lightbox-zone')) return;
if (t.closest && t.closest('img.luxtools-lightbox-img')) return;
e.preventDefault();
close();
}
function attachListeners() {
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('popstate', onPopState, true);
img.addEventListener('wheel', onWheel, { passive: false });
img.addEventListener('dblclick', onDblClick);
img.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function detachListeners() {
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('popstate', onPopState, true);
img.removeEventListener('wheel', onWheel);
img.removeEventListener('dblclick', onDblClick);
img.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
function open(galleryEl, startEl) {
var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]');
items = [];
links.forEach(function (a) {
var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || '';
var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || '';
if (!full) return;
items.push({ el: a, full: full, name: name });
});
if (!items.length) return;
index = 0;
for (var i = 0; i < items.length; i++) {
if (items[i].el === startEl) { index = i; break; }
}
ensureElement();
pushedHistory = false;
closingFromPopstate = false;
lb.classList.add('is-open');
lb.setAttribute('aria-hidden', 'false');
try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {}
try { document.body.style.overflow = 'hidden'; } catch (e) {}
attachListeners();
try {
if (window.history && window.history.pushState) {
window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href);
pushedHistory = true;
}
} catch (e) {}
render();
}
function close() {
if (!lb) return;
lb.classList.remove('is-open');
lb.setAttribute('aria-hidden', 'true');
try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {}
try { document.body.style.overflow = ''; } catch (e) {}
img.src = '';
resetZoom();
detachListeners();
if (pushedHistory && !closingFromPopstate) {
try {
if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) {
window.history.back();
}
} catch (e) {}
}
items = [];
}
return {
open: open,
close: close
};
})();
})();

116
js/linkfavicon.js Normal file
View File

@@ -0,0 +1,116 @@
/* global document */
/**
* Link Favicon Module
*
* Displays favicons for external links using DuckDuckGo's favicon service.
* Based on the linkfavicon plugin by Shao Yanmin.
*
* Note: DDG returns a grey placeholder icon for domains without favicons.
* Detecting this placeholder client-side is not reliably possible due to
* CORS restrictions preventing canvas pixel inspection.
*/
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
// Cache states for favicon URLs
var ICON_NOT_FOUND = -1;
var ICON_LOADING = 0;
var ICON_LOADED = 1;
var faviconCache = {};
/**
* Preload an image to verify it loads.
* @param {string} src - Image URL to load
* @returns {Promise} Resolves on load, rejects on error
*/
function loadImage(src) {
return new Promise(function (resolve, reject) {
var image = new Image();
image.addEventListener('load', resolve);
image.addEventListener('error', reject);
image.src = src;
});
}
/**
* Apply the favicon as background image to all matching links.
* @param {string} faviconUrl - The favicon URL to apply
*/
function applyFavicon(faviconUrl) {
if (faviconCache[faviconUrl] !== ICON_LOADED) return;
var links = document.querySelectorAll('[data-linkfavicon="' + faviconUrl + '"]');
for (var i = 0; i < links.length; i++) {
links[i].classList.add('linkfavicon');
links[i].style.backgroundImage = 'url(' + faviconUrl + ')';
}
}
/**
* Get domain from URL.
* @param {string} url - Full URL
* @returns {string|null} Domain or null if invalid
*/
function getDomain(url) {
try {
var parsed = new URL(url);
return parsed.hostname;
} catch (e) {
return null;
}
}
/**
* Initialize favicons for all external links on the page.
*/
function init() {
// Find all external links (DokuWiki marks them with class 'urlextern')
var links = document.querySelectorAll('a.urlextern');
for (var i = 0; i < links.length; i++) {
var link = links[i];
var href = link.getAttribute('href');
if (!href) continue;
var domain = getDomain(href);
if (!domain) continue;
// DuckDuckGo favicon service URL
var faviconUrl = 'https://icons.duckduckgo.com/ip3/' + domain + '.ico';
// Mark the link with the favicon URL for later reference
link.setAttribute('data-linkfavicon', faviconUrl);
// Load and cache the favicon if not already processed
if (faviconCache[faviconUrl] === undefined) {
faviconCache[faviconUrl] = ICON_LOADING;
(function (url) {
loadImage(url)
.then(function () {
faviconCache[url] = ICON_LOADED;
applyFavicon(url);
})
.catch(function () {
faviconCache[url] = ICON_NOT_FOUND;
});
})(faviconUrl);
} else if (faviconCache[faviconUrl] === ICON_LOADED) {
// Already loaded, apply immediately
link.classList.add('linkfavicon');
link.style.backgroundImage = 'url(' + faviconUrl + ')';
}
}
}
// Export for potential external use
Luxtools.LinkFavicon = {
init: init
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', init, false);
})();

130
js/main.js Normal file
View File

@@ -0,0 +1,130 @@
/* global window, document */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
var Lightbox = Luxtools.Lightbox;
var OpenService = Luxtools.OpenService;
var GalleryThumbnails = Luxtools.GalleryThumbnails;
var Scratchpads = Luxtools.Scratchpads;
var CalendarWidget = Luxtools.CalendarWidget;
// ============================================================
// Click Handlers
// ============================================================
function findOpenElement(target) {
var el = target;
while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-open')) return el;
el = el.parentNode;
}
return null;
}
function findGalleryItem(target) {
var el = target;
while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-gallery-item')) return el;
el = el.parentNode;
}
return null;
}
function onClick(event) {
// Image gallery lightbox: intercept clicks so we don't navigate away.
var galleryItem = findGalleryItem(event.target);
if (galleryItem && Lightbox && Lightbox.open) {
var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null;
if (gallery) {
event.preventDefault();
Lightbox.open(gallery, galleryItem);
return;
}
}
var el = findOpenElement(event.target);
if (!el) return;
// {{open>...}} renders as a link; avoid jumping to '#'.
if (el.tagName && el.tagName.toLowerCase() === 'a') {
event.preventDefault();
}
var raw = el.getAttribute('data-path') || '';
if (!raw) return;
if (!OpenService || !OpenService.openViaService) return;
// Prefer local client service.
OpenService.openViaService(el, raw)
.catch(function (err) {
// If the browser blocks the request before it reaches localhost (mixed-content,
// extensions, stricter CORS handling), fall back to a no-CORS GET ping.
if (OpenService && OpenService.pingOpenViaImage) {
OpenService.pingOpenViaImage(el, raw);
}
// Fallback to old behavior (often blocked in modern browsers).
var url = OpenService && OpenService.normalizeToFileUrl ? OpenService.normalizeToFileUrl(raw) : '';
if (!url) return;
console.warn('Local client service failed, falling back to file:// navigation:', err);
try {
window.open(url, '_blank', 'noopener');
} catch (e) {
try {
window.location.href = url;
} catch (e2) {
console.error('Failed to open file URL:', e2);
}
}
});
}
function initChronologicalEventTimes() {
var nodes = document.querySelectorAll('.luxtools-event-time[data-luxtools-start]');
if (!nodes || nodes.length === 0) return;
var formatter;
try {
formatter = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
formatter = null;
}
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
var raw = node.getAttribute('data-luxtools-start') || '';
if (!raw) continue;
var date = new Date(raw);
if (isNaN(date.getTime())) continue;
var label;
if (formatter) {
label = formatter.format(date);
} else {
var hh = String(date.getHours()).padStart(2, '0');
var mm = String(date.getMinutes()).padStart(2, '0');
label = hh + ':' + mm;
}
node.textContent = label;
}
}
// ============================================================
// Initialize
// ============================================================
document.addEventListener('click', onClick, false);
document.addEventListener('DOMContentLoaded', function () {
if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init();
initChronologicalEventTimes();
if (CalendarWidget && CalendarWidget.init) CalendarWidget.init();
}, false);
document.addEventListener('DOMContentLoaded', function () {
if (Scratchpads && Scratchpads.init) Scratchpads.init();
}, false);
})();

94
js/open-service.js Normal file
View File

@@ -0,0 +1,94 @@
/* global window */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
// ============================================================
// Open Service Module (file:// links)
// ============================================================
Luxtools.OpenService = (function () {
function getServiceUrl(el) {
var url = el.getAttribute('data-service-url') || '';
url = (url || '').trim();
if (!url) return '';
// strip trailing slashes
return url.replace(/\/+$/, '');
}
function pingOpenViaImage(el, rawPath) {
var baseUrl = getServiceUrl(el);
if (!baseUrl) return;
var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath);
// Fire-and-forget without CORS.
try {
var img = new window.Image();
img.src = url;
} catch (e) {
// ignore
}
}
function openViaService(el, rawPath) {
var baseUrl = getServiceUrl(el);
if (!baseUrl) return Promise.reject(new Error('No client service configured'));
var headers = {
'Content-Type': 'application/json'
};
return window.fetch(baseUrl + '/open', {
method: 'POST',
mode: 'cors',
credentials: 'omit',
headers: headers,
body: JSON.stringify({ path: rawPath })
}).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return null; }).then(function (body) {
var msg = (body && body.message) ? body.message : ('HTTP ' + res.status);
throw new Error(msg);
});
}
return res.json().catch(function () { return { ok: true }; });
});
}
function normalizeToFileUrl(path) {
if (!path) return '';
// already a file URL
if (/^file:\/\//i.test(path)) return path;
// UNC path: \\server\share\path
if (/^\\\\/.test(path)) {
var p = path.replace(/^\\\\/, '');
p = p.replace(/\\/g, '/');
return 'file://///' + p;
}
// Windows drive: C:\path\to\file
if (/^[a-zA-Z]:\\/.test(path)) {
var drive = path[0].toUpperCase();
var rest = path.slice(2).replace(/\\/g, '/');
return 'file:///' + drive + ':' + rest;
}
// POSIX absolute: /home/user/file
if (path[0] === '/') {
return 'file://' + path;
}
// Fall back to using the provided string.
return path;
}
return {
openViaService: openViaService,
pingOpenViaImage: pingOpenViaImage,
normalizeToFileUrl: normalizeToFileUrl
};
})();
})();

180
js/page-link.js Normal file
View File

@@ -0,0 +1,180 @@
/* global window, document */
(function () {
'use strict';
function getSectok() {
try {
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
} catch (e) {}
try {
var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
if (inp && inp.value) return String(inp.value);
} catch (e2) {}
return '';
}
function getPageId() {
try {
if (window.JSINFO && window.JSINFO.id) return String(window.JSINFO.id);
} catch (e) {}
try {
var input = document.querySelector('input[name="id"]');
if (input && input.value) return String(input.value);
} catch (e2) {}
return '';
}
function getBaseUrl() {
try {
if (window.DOKU_BASE) return String(window.DOKU_BASE);
} catch (e) {}
try {
if (window.JSINFO && window.JSINFO.base) return String(window.JSINFO.base);
} catch (e2) {}
return '/';
}
function requestPageLink(cmd, params) {
var pageId = getPageId();
if (!pageId) return Promise.reject(new Error('missing page id'));
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
var payload = new window.URLSearchParams();
payload.set('cmd', cmd);
payload.set('id', pageId);
if (params && typeof params === 'object') {
Object.keys(params).forEach(function (key) {
payload.set(key, String(params[key]));
});
}
return window.fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: payload.toString()
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (body) {
if (!res.ok || !body || body.ok !== true) {
throw new Error('request failed');
}
return body;
});
});
}
function ensurePageLink() {
return requestPageLink('ensure', { sectok: getSectok() });
}
function unlinkPageLink() {
return requestPageLink('unlink', { sectok: getSectok() });
}
function triggerDownload(pageId) {
try {
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
var href = endpoint + '?cmd=download&id=' + encodeURIComponent(pageId);
var a = document.createElement('a');
a.href = href;
a.download = '.pagelink';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (e) {
// ignore
}
}
function fetchPageLinkInfo(pageId) {
if (!pageId) return Promise.reject(new Error('missing page id'));
var endpoint = getBaseUrl() + 'lib/plugins/luxtools/pagelink.php';
var query = endpoint + '?cmd=info&id=' + encodeURIComponent(pageId);
return window.fetch(query, {
method: 'GET',
credentials: 'same-origin'
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (body) {
if (!res.ok || !body || body.ok !== true) {
throw new Error('request failed');
}
return body;
});
});
}
function attachDocInfoLink() {
var container = document.querySelector('.docInfo');
if (!container || !container.getAttribute) return;
if (container.getAttribute('data-luxtools-pagelink-docinfo') === '1') return;
container.setAttribute('data-luxtools-pagelink-docinfo', '1');
var pageId = getPageId();
if (!pageId) return;
fetchPageLinkInfo(pageId).then(function (info) {
var link = document.createElement('a');
link.href = '#';
if (!info || !info.uuid) {
link.textContent = 'Link Page';
link.addEventListener('click', function (e) {
e.preventDefault();
ensurePageLink().then(function (res) {
if (!res || !res.uuid) throw new Error('no uuid');
triggerDownload(pageId);
}).catch(function (err) {
if (window.console && window.console.warn) {
window.console.warn('PageLink ensure failed:', err);
}
});
});
} else if (info.linked) {
link.textContent = 'Unlink Page';
link.addEventListener('click', function (e) {
e.preventDefault();
if (!window.confirm('Unlink page?')) return;
unlinkPageLink().then(function () {
window.setTimeout(function () {
try { window.location.reload(); } catch (e2) {}
}, 400);
}).catch(function (err) {
if (window.console && window.console.warn) {
window.console.warn('PageLink unlink failed:', err);
}
});
});
} else {
link.textContent = 'Download Link File';
link.addEventListener('click', function (e) {
e.preventDefault();
triggerDownload(pageId);
});
}
var first = container.firstChild;
container.insertBefore(link, first);
if (first) {
container.insertBefore(document.createTextNode(' · '), first);
}
}).catch(function () {
// ignore failures
});
}
document.addEventListener('DOMContentLoaded', function () {
attachDocInfoLink();
}, false);
})();

187
js/scratchpads.js Normal file
View File

@@ -0,0 +1,187 @@
/* global window, document */
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
// ============================================================
// Scratchpads Module
// ============================================================
Luxtools.Scratchpads = (function () {
function setEditMode(root, isEditing) {
if (!root || !root.classList) return;
var view = root.querySelector('.luxtools-scratchpad-view');
var editor = root.querySelector('.luxtools-scratchpad-editor');
if (isEditing) {
root.classList.add('is-editing');
if (view) view.hidden = true;
if (editor) editor.hidden = false;
} else {
root.classList.remove('is-editing');
if (view) view.hidden = false;
if (editor) editor.hidden = true;
}
}
function setStatus(root, msg) {
var el = root.querySelector('.luxtools-scratchpad-status');
if (!el) return;
el.textContent = msg || '';
}
function getSectok(root) {
// Prefer a token embedded with the rendered scratchpad.
try {
if (root && root.getAttribute) {
var t = String(root.getAttribute('data-sectok') || '').trim();
if (t) return t;
}
} catch (e) {}
// Fall back to DokuWiki's global JSINFO.
try {
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
} catch (e) {}
// Last resort: find any security token input on the page.
try {
var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
if (inp && inp.value) return String(inp.value);
} catch (e2) {}
return '';
}
function loadPad(root) {
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
var pad = (root.getAttribute('data-pad') || '').trim();
var pageId = (root.getAttribute('data-pageid') || '').trim();
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
var url = endpoint + '?cmd=load&pad=' + encodeURIComponent(pad) + '&id=' + encodeURIComponent(pageId);
return window.fetch(url, {
method: 'GET',
credentials: 'same-origin'
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (body) {
if (!res.ok || !body || body.ok !== true) {
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
throw new Error(msg);
}
return body.text || '';
});
});
}
function savePad(root, text) {
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
var pad = (root.getAttribute('data-pad') || '').trim();
var pageId = (root.getAttribute('data-pageid') || '').trim();
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
var params = new window.URLSearchParams();
params.set('cmd', 'save');
params.set('pad', pad);
params.set('id', pageId);
params.set('text', text || '');
params.set('sectok', getSectok(root));
return window.fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: params.toString()
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (body) {
if (!res.ok || !body || body.ok !== true) {
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
throw new Error(msg);
}
return true;
});
});
}
function openEditor(root) {
var editor = root.querySelector('.luxtools-scratchpad-editor');
var textarea = root.querySelector('textarea.luxtools-scratchpad-text');
if (!editor || !textarea) return;
setEditMode(root, true);
setStatus(root, 'Loading…');
textarea.disabled = true;
loadPad(root).then(function (text) {
textarea.value = text;
textarea.disabled = false;
setStatus(root, '');
textarea.focus();
}).catch(function (e) {
textarea.disabled = false;
setStatus(root, 'Load failed: ' + (e && e.message ? e.message : 'error'));
});
}
function closeEditor(root) {
var editor = root.querySelector('.luxtools-scratchpad-editor');
if (!editor) return;
setEditMode(root, false);
setStatus(root, '');
}
function onClick(e) {
var t = e.target;
if (!t) return;
var edit = t.closest ? t.closest('a.luxtools-scratchpad-edit') : null;
if (edit) {
var root = edit.closest('div.luxtools-scratchpad');
if (!root) return;
e.preventDefault();
openEditor(root);
return;
}
var save = t.closest ? t.closest('button.luxtools-scratchpad-save') : null;
if (save) {
var rootS = save.closest('div.luxtools-scratchpad');
if (!rootS) return;
e.preventDefault();
var textareaS = rootS.querySelector('textarea.luxtools-scratchpad-text');
if (!textareaS) return;
textareaS.disabled = true;
setStatus(rootS, 'Saving…');
savePad(rootS, textareaS.value).then(function () {
setStatus(rootS, 'Saved. Reloading…');
try { window.location.reload(); } catch (err) {}
}).catch(function (err) {
textareaS.disabled = false;
setStatus(rootS, 'Save failed: ' + (err && err.message ? err.message : 'error'));
});
return;
}
var cancel = t.closest ? t.closest('button.luxtools-scratchpad-cancel') : null;
if (cancel) {
var rootC = cancel.closest('div.luxtools-scratchpad');
if (!rootC) return;
e.preventDefault();
closeEditor(rootC);
}
}
function init() {
var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]');
if (!pads || !pads.length) return;
document.addEventListener('click', onClick, true);
}
return {
init: init
};
})();
})();

View File

@@ -7,54 +7,81 @@
* @author Gina Haeussge <osd@foosel.net> * @author Gina Haeussge <osd@foosel.net>
*/ */
$lang['filename'] = 'Dateiname'; $lang["filename"] = "Dateiname";
$lang['filesize'] = 'Dateigröße'; $lang["filesize"] = "Dateigröße";
$lang['lastmodified'] = 'Letzte Änderung'; $lang["lastmodified"] = "Letzte Änderung";
$lang['openlocation'] = 'Ort öffnen'; $lang["openlocation"] = "Ort öffnen";
$lang['error_nomatch'] = 'Keine Treffer'; $lang["error_nomatch"] = "Keine Treffer";
$lang['error_outsidejail'] = 'Zugriff verweigert'; $lang["error_outsidejail"] = "Zugriff verweigert";
$lang['empty_files'] = 'Keine Dateien'; $lang["empty_files"] = "Keine Dateien";
$lang['empty_images'] = 'Keine Bilder'; $lang["empty_images"] = "Keine Bilder";
$lang['menu'] = 'luxtools'; $lang["menu"] = "luxtools";
$lang['settings'] = 'luxtools-Einstellungen'; $lang["settings"] = "luxtools-Einstellungen";
$lang['legend'] = 'Einstellungen'; $lang["legend"] = "Einstellungen";
$lang['btn_save'] = 'Speichern'; $lang["btn_save"] = "Speichern";
$lang['saved'] = 'Einstellungen gespeichert.'; $lang["saved"] = "Einstellungen gespeichert.";
$lang['err_save'] = 'Einstellungen konnten nicht gespeichert werden. Bitte Schreibrechte für conf/local.php prüfen.'; $lang["err_save"] =
$lang['err_security'] = 'Sicherheits-Token ungültig. Bitte erneut versuchen.'; "Einstellungen konnten nicht gespeichert werden. Bitte Schreibrechte für conf/local.php prüfen.";
$lang["err_security"] = "Sicherheits-Token ungültig. Bitte erneut versuchen.";
$lang["paths"] =
"Erlaubte Basis-Pfade (eine pro Zeile). Optional: Pfad mit A>-Alias ergaenzen.";
$lang["scratchpad_paths"] =
"Scratchpad-Dateien (eine pro Zeile). Jeder Dateipfad muss eine Erweiterung enthalten. Mit einer folgenden A>-Zeile wird der Pad-Name gesetzt, der im Wiki verwendet wird.";
$lang["extensions"] = "Kommagetrennte Liste erlaubter Dateiendungen.";
$lang['paths'] = 'Erlaubte Basis-Pfade (eine pro Zeile). Optional: Pfad mit A>-Alias ergaenzen.'; $lang["listing_defaults"] = "Listen-Standardwerte";
$lang['scratchpad_paths'] = 'Scratchpad-Dateien (eine pro Zeile). Jeder Dateipfad muss eine Erweiterung enthalten. Mit einer folgenden A>-Zeile wird der Pad-Name gesetzt, der im Wiki verwendet wird.'; $lang["default_sort"] =
$lang['allow_in_comments'] = 'luxtools-Syntax (files/images/directory/scratchpad) in Kommentaren erlauben.'; "Standard-Sortierschlüssel (name|iname|ctime|mtime|size).";
$lang['extensions'] = 'Kommagetrennte Liste erlaubter Dateiendungen.'; $lang["default_order"] = "Standard-Sortierreihenfolge (asc|desc).";
$lang["default_style"] = "Standard-Ausgabeformat (list|olist|table).";
$lang["default_tableheader"] = "Tabellenkopf standardmäßig anzeigen.";
$lang["default_foldersfirst"] = "Ordner standardmäßig vor Dateien sortieren.";
$lang["default_recursive"] = "Unterverzeichnisse standardmäßig einbeziehen.";
$lang["default_titlefile"] = "Standard-Name der Titeldatei (z.B. _title.txt).";
$lang["default_cache"] =
"Seiten-Caching standardmäßig aktivieren (0 deaktiviert Caching).";
$lang["default_randlinks"] =
"Standardmäßig Cache-Busting-Parameter auf Basis mtime hinzufügen.";
$lang["default_showsize"] =
"Dateigröße standardmäßig anzeigen (falls unterstützt).";
$lang["default_showdate"] =
"Änderungsdatum standardmäßig anzeigen (falls unterstützt).";
$lang["default_listsep"] =
'Standard-Trennzeichen für Listenausgabe (z.B. ", ").';
$lang["default_maxheight"] =
"Standard max-height in px für Scroll-Container (-1 deaktiviert).";
$lang['listing_defaults'] = 'Listen-Standardwerte'; $lang["defaults"] =
$lang['default_sort'] = 'Standard-Sortierschlüssel (name|iname|ctime|mtime|size).'; "Legacy-Standardoptionen (fortgeschritten). Vorhandene Werte werden weiterhin berücksichtigt, sind aber nicht mehr über die UI konfigurierbar.";
$lang['default_order'] = 'Standard-Sortierreihenfolge (asc|desc).'; $lang["thumb_placeholder"] =
$lang['default_style'] = 'Standard-Ausgabeformat (list|olist|table).'; "MediaManager-ID für den Platzhalter der Galerie-Thumbnails.";
$lang['default_tableheader'] = 'Tabellenkopf standardmäßig anzeigen.'; $lang["gallery_thumb_scale"] =
$lang['default_foldersfirst'] = 'Ordner standardmäßig vor Dateien sortieren.'; "Skalierungsfaktor für Galerie-Thumbnails. 2 erzeugt schärfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150×150).";
$lang['default_recursive'] = 'Unterverzeichnisse standardmäßig einbeziehen.'; $lang["open_service_url"] =
$lang['default_titlefile'] = 'Standard-Name der Titeldatei (z.B. _title.txt).'; "URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
$lang['default_cache'] = 'Seiten-Caching standardmäßig aktivieren (0 deaktiviert Caching).'; $lang["image_base_path"] =
$lang['default_randlinks'] = 'Standardmäßig Cache-Busting-Parameter auf Basis mtime hinzufügen.'; "Basis-Dateisystempfad für die chronologische Foto-Integration.";
$lang['default_showsize'] = 'Dateigröße standardmäßig anzeigen (falls unterstützt).'; $lang["calendar_ics_files"] =
$lang['default_showdate'] = 'Änderungsdatum standardmäßig anzeigen (falls unterstützt).'; "Lokale Kalender-.ics-Dateien (ein absoluter Dateipfad pro Zeile).";
$lang['default_listsep'] = 'Standard-Trennzeichen für Listenausgabe (z.B. ", ").'; $lang["pagelink_search_depth"] =
$lang['default_maxheight'] = 'Standard max-height in px für Scroll-Container (-1 deaktiviert).'; "Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
$lang['defaults'] = 'Legacy-Standardoptionen (fortgeschritten). Vorhandene Werte werden weiterhin berücksichtigt, sind aber nicht mehr über die UI konfigurierbar.'; $lang["scratchpad_edit"] = "Scratchpad bearbeiten";
$lang['thumb_placeholder'] = 'MediaManager-ID für den Platzhalter der Galerie-Thumbnails.'; $lang["scratchpad_save"] = "Speichern";
$lang['gallery_thumb_scale'] = 'Skalierungsfaktor für Galerie-Thumbnails. 2 erzeugt schärfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150×150).'; $lang["scratchpad_cancel"] = "Abbrechen";
$lang['open_service_url'] = 'URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).'; $lang["scratchpad_err_nopath"] = "Scratchpad-Pfad fehlt";
$lang["scratchpad_err_badpath"] = "Ungültiger Scratchpad-Pfad";
$lang["scratchpad_err_unknown"] = "Unbekannter Scratchpad-Name";
$lang["scratchpad_err_unreadable"] = "Scratchpad-Datei ist nicht lesbar";
$lang['scratchpad_edit'] = 'Scratchpad bearbeiten'; $lang["toolbar_code_title"] = "Code-Block";
$lang['scratchpad_save'] = 'Speichern'; $lang["toolbar_code_sample"] = "Ihr Code hier";
$lang['scratchpad_cancel'] = 'Abbrechen'; $lang["toolbar_datefix_title"] = "Datums-Fix";
$lang['scratchpad_err_nopath'] = 'Scratchpad-Pfad fehlt'; $lang["toolbar_datefix_all_title"] = "Datums-Fix (Alle)";
$lang['scratchpad_err_badpath'] = 'Ungültiger Scratchpad-Pfad'; $lang["pagelink_unlinked"] = "Seite nicht verknüpft";
$lang['scratchpad_err_unknown'] = 'Unbekannter Scratchpad-Name'; $lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
$lang['scratchpad_err_unreadable'] = 'Scratchpad-Datei ist nicht lesbar'; $lang["chronological_photos_title"] = "Fotos";
$lang["chronological_events_title"] = "Termine";

View File

@@ -2,7 +2,6 @@
$lang["paths"] = "Erlaubte Basis-Pfade (eine pro Zeile). Optional: Pfad mit A>-Alias ergaenzen."; $lang["paths"] = "Erlaubte Basis-Pfade (eine pro Zeile). Optional: Pfad mit A>-Alias ergaenzen.";
$lang["scratchpad_paths"] = "Scratchpad-Dateien (eine pro Zeile). Jeder Dateipfad muss eine Erweiterung enthalten. Mit einer folgenden A>-Zeile wird der Pad-Name gesetzt, der im Wiki verwendet wird."; $lang["scratchpad_paths"] = "Scratchpad-Dateien (eine pro Zeile). Jeder Dateipfad muss eine Erweiterung enthalten. Mit einer folgenden A>-Zeile wird der Pad-Name gesetzt, der im Wiki verwendet wird.";
$lang["allow_in_comments"] = "luxtools-Syntax (files/images/directory/scratchpad) in Kommentaren erlauben.";
$lang["extensions"] = "Kommagetrennte Liste erlaubter Dateiendungen."; $lang["extensions"] = "Kommagetrennte Liste erlaubter Dateiendungen.";
$lang["listing_defaults"] = "Listen-Standardwerte"; $lang["listing_defaults"] = "Listen-Standardwerte";
@@ -27,3 +26,5 @@ $lang["thumb_placeholder"] = "MediaManager-ID fuer den Platzhalter der Galerie-T
$lang["gallery_thumb_scale"] = "Skalierungsfaktor fuer Galerie-Thumbnails. 2 erzeugt schaerfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150x150)."; $lang["gallery_thumb_scale"] = "Skalierungsfaktor fuer Galerie-Thumbnails. 2 erzeugt schaerfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150x150).";
$lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765)."; $lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765).";
$lang["pagelink_search_depth"] = "Maximale Verzeichnisebene fuer .pagelink-Suche (0 = nur Root).";

View File

@@ -7,54 +7,82 @@
* @author Gina Haeussge <osd@foosel.net> * @author Gina Haeussge <osd@foosel.net>
*/ */
$lang['filename'] = 'Filename'; $lang["filename"] = "Filename";
$lang['filesize'] = 'Filesize'; $lang["filesize"] = "Filesize";
$lang['lastmodified'] = 'Last modified'; $lang["lastmodified"] = "Last modified";
$lang['openlocation'] = 'Open Location'; $lang["openlocation"] = "Open Location";
$lang['error_nomatch'] = 'No match'; $lang["error_nomatch"] = "No match";
$lang['error_outsidejail'] = 'Access denied'; $lang["error_outsidejail"] = "Access denied";
$lang['empty_files'] = 'No Files'; $lang["empty_files"] = "No Files";
$lang['empty_images'] = 'No Images'; $lang["empty_images"] = "No Images";
$lang['menu'] = 'luxtools'; $lang["menu"] = "luxtools";
$lang['settings'] = 'luxtools settings'; $lang["settings"] = "luxtools settings";
$lang['legend'] = 'Settings'; $lang["legend"] = "Settings";
$lang['btn_save'] = 'Save'; $lang["btn_save"] = "Save";
$lang['saved'] = 'Settings saved.'; $lang["saved"] = "Settings saved.";
$lang['err_save'] = 'Could not save settings. Please check write permissions for conf/local.php.'; $lang["err_save"] =
$lang['err_security'] = 'Security token mismatch. Please retry.'; "Could not save settings. Please check write permissions for conf/local.php.";
$lang["err_security"] = "Security token mismatch. Please retry.";
$lang["paths"] =
"Allowed base paths (one per line). Optional: follow a path with A> alias.";
$lang["scratchpad_paths"] =
"Scratchpad files (one per line). Each file path must include the extension. Use a following A> line to set the pad name used in the wiki.";
$lang["extensions"] =
"Comma-separated list of allowed file extensions to list.";
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias.'; $lang["listing_defaults"] = "Listing defaults";
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension. Use a following A> line to set the pad name used in the wiki.'; $lang["default_sort"] = "Default sort key (name|iname|ctime|mtime|size).";
$lang['allow_in_comments'] = 'Whether to allow luxtools syntax (files/images/directory/scratchpad) to be used in comments.'; $lang["default_order"] = "Default sort order (asc|desc).";
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list.'; $lang["default_style"] = "Default output style (list|olist|table).";
$lang["default_tableheader"] = "Render table header row by default.";
$lang["default_foldersfirst"] = "Group folders before files by default.";
$lang["default_recursive"] = "Recurse into subdirectories by default.";
$lang["default_titlefile"] = "Default title file name (e.g. _title.txt).";
$lang["default_cache"] = "Enable page caching by default (0 disables caching).";
$lang["default_randlinks"] =
"Add cache-busting query parameter based on mtime by default.";
$lang["default_showsize"] = "Show file size by default (where supported).";
$lang["default_showdate"] =
"Show last modified date by default (where supported).";
$lang["default_tablecolumns"] =
'Default table columns (comma-separated). Available: name, size, date. Example: "name,size,date" shows all columns.';
$lang["default_listsep"] =
'Default separator used in list-style rendering (e.g. ", ").';
$lang["default_maxheight"] =
"Default max-height in px for scroll container (-1 disables).";
$lang['listing_defaults'] = 'Listing defaults'; $lang["defaults"] =
$lang['default_sort'] = 'Default sort key (name|iname|ctime|mtime|size).'; "Legacy default options string (advanced). Existing values are still honored, but this is no longer configurable via the UI.";
$lang['default_order'] = 'Default sort order (asc|desc).'; $lang["thumb_placeholder"] =
$lang['default_style'] = 'Default output style (list|olist|table).'; "MediaManager ID for the gallery thumbnail placeholder.";
$lang['default_tableheader'] = 'Render table header row by default.'; $lang["gallery_thumb_scale"] =
$lang['default_foldersfirst'] = 'Group folders before files by default.'; "Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).";
$lang['default_recursive'] = 'Recurse into subdirectories by default.'; $lang["open_service_url"] =
$lang['default_titlefile'] = 'Default title file name (e.g. _title.txt).'; "Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
$lang['default_cache'] = 'Enable page caching by default (0 disables caching).'; $lang["image_base_path"] =
$lang['default_randlinks'] = 'Add cache-busting query parameter based on mtime by default.'; "Base filesystem path for chronological photo integration.";
$lang['default_showsize'] = 'Show file size by default (where supported).'; $lang["calendar_ics_files"] =
$lang['default_showdate'] = 'Show last modified date by default (where supported).'; "Local calendar .ics files (one absolute file path per line).";
$lang['default_listsep'] = 'Default separator used in list-style rendering (e.g. ", ").'; $lang["pagelink_search_depth"] =
$lang['default_maxheight'] = 'Default max-height in px for scroll container (-1 disables).'; "Maximum directory depth for .pagelink search (0 = only root).";
$lang['defaults'] = 'Legacy default options string (advanced). Existing values are still honored, but this is no longer configurable via the UI.'; $lang["scratchpad_edit"] = "Edit scratchpad";
$lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placeholder.'; $lang["scratchpad_save"] = "Save";
$lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).'; $lang["scratchpad_cancel"] = "Cancel";
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).'; $lang["scratchpad_err_nopath"] = "Scratchpad path missing";
$lang["scratchpad_err_badpath"] = "Invalid scratchpad path";
$lang["scratchpad_err_unknown"] = "Unknown scratchpad pad name";
$lang["scratchpad_err_unreadable"] = "Scratchpad file is not readable";
$lang['scratchpad_edit'] = 'Edit scratchpad'; $lang["toolbar_code_title"] = "Code Block";
$lang['scratchpad_save'] = 'Save'; $lang["toolbar_code_sample"] = "your code here";
$lang['scratchpad_cancel'] = 'Cancel'; $lang["toolbar_datefix_title"] = "Date Fix";
$lang['scratchpad_err_nopath'] = 'Scratchpad path missing'; $lang["toolbar_datefix_all_title"] = "Date Fix (All)";
$lang['scratchpad_err_badpath'] = 'Invalid scratchpad path'; $lang["pagelink_unlinked"] = "Page not linked";
$lang['scratchpad_err_unknown'] = 'Unknown scratchpad pad name'; $lang["pagelink_multi_warning"] = "Multiple folders linked";
$lang['scratchpad_err_unreadable'] = 'Scratchpad file is not readable'; $lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
$lang["chronological_photos_title"] = "Photos";
$lang["chronological_events_title"] = "Events";

View File

@@ -2,7 +2,6 @@
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias.'; $lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias.';
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension; use a following A> line to set the pad name used in the wiki.'; $lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension; use a following A> line to set the pad name used in the wiki.';
$lang['allow_in_comments'] = 'Whether to allow luxtools syntax (files/images/directory/scratchpad) to be used in comments.';
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list'; $lang['extensions'] = 'Comma-separated list of allowed file extensions to list';
$lang['listing_defaults'] = 'Listing defaults'; $lang['listing_defaults'] = 'Listing defaults';
@@ -27,3 +26,5 @@ $lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placehol
$lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).'; $lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).';
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} link (e.g. http://127.0.0.1:8765).'; $lang['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).';

168
pagelink.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
use dokuwiki\plugin\luxtools\PageLink;
require_once(__DIR__ . '/autoload.php');
if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../../');
require_once(DOKU_INC . 'inc/init.php');
global $INPUT;
$syntax = plugin_load('syntax', 'luxtools');
if (!$syntax) {
http_status(500);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'plugin disabled']);
exit;
}
/**
* Send a JSON response.
*
* @param int $status
* @param array $payload
* @return void
*/
function luxtools_pagelink_json(int $status, array $payload): void
{
http_status($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
echo json_encode($payload);
exit;
}
$cmd = (string)$INPUT->str('cmd');
$pageId = (string)$INPUT->str('id');
if (function_exists('cleanID')) {
$pageId = (string)cleanID($pageId);
}
if ($cmd === '' || $pageId === '') {
luxtools_pagelink_json(400, ['ok' => false, 'error' => 'missing parameters']);
}
if (!function_exists('auth_quickaclcheck')) {
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
}
$acl = auth_quickaclcheck($pageId);
if ($cmd === 'info' || $cmd === 'download') {
if (!defined('AUTH_READ') || $acl < AUTH_READ) {
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
}
} else {
if (!defined('AUTH_EDIT') || $acl < AUTH_EDIT) {
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'forbidden']);
}
}
if ($cmd === 'info') {
$depth = (int)$syntax->getConf('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
$uuid = $pageLink->getPageUuid($pageId);
if ($uuid === null) {
luxtools_pagelink_json(200, [
'ok' => true,
'uuid' => null,
'linked' => false,
'folder' => null,
'multiple' => false,
]);
}
$info = $pageLink->resolveUuid($uuid);
$folder = $info['folder'] ?? null;
$multiple = !empty($info['multiple']);
luxtools_pagelink_json(200, [
'ok' => true,
'uuid' => $uuid,
'linked' => is_string($folder) && $folder !== '',
'folder' => is_string($folder) ? $folder : null,
'multiple' => $multiple,
]);
}
if ($cmd === 'download') {
$depth = (int)$syntax->getConf('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
$uuid = $pageLink->getPageUuid($pageId);
if ($uuid === null || $uuid === '') {
http_status(404);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
echo 'not linked';
exit;
}
http_status(200);
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename=".pagelink"; filename*=UTF-8\'\'%2Epagelink');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
echo $uuid;
exit;
}
if ($cmd === 'ensure') {
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
luxtools_pagelink_json(405, ['ok' => false, 'error' => 'method not allowed']);
}
if (!checkSecurityToken()) {
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'bad token']);
}
$depth = (int)$syntax->getConf('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
$uuid = $pageLink->getPageUuid($pageId);
if ($uuid !== null) {
luxtools_pagelink_json(200, ['ok' => true, 'uuid' => $uuid, 'created' => false]);
}
$uuid = PageLink::createUuidV4();
$ok = $pageLink->setPageUuid($pageId, $uuid);
if (!$ok) {
luxtools_pagelink_json(500, ['ok' => false, 'error' => 'save failed']);
}
luxtools_pagelink_json(200, ['ok' => true, 'uuid' => $uuid, 'created' => true]);
}
if ($cmd === 'unlink') {
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
luxtools_pagelink_json(405, ['ok' => false, 'error' => 'method not allowed']);
}
if (!checkSecurityToken()) {
luxtools_pagelink_json(403, ['ok' => false, 'error' => 'bad token']);
}
$depth = (int)$syntax->getConf('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$pageLink = new PageLink((string)$syntax->getConf('paths'), $depth);
$result = $pageLink->unlinkPage($pageId);
luxtools_pagelink_json(200, [
'ok' => true,
'uuid' => $result['uuid'] ?? null,
'folder' => $result['folder'] ?? null,
]);
}
luxtools_pagelink_json(400, ['ok' => false, 'error' => 'unknown command']);

629
script.js
View File

@@ -1,629 +0,0 @@
/* global window, document */
(function () {
'use strict';
// ============================================================
// Lightbox Module
// ============================================================
var Lightbox = (function () {
var lb = null;
var img = null;
var cap = null;
var items = [];
var index = 0;
// Zoom/pan state
var scale = 1;
var panX = 0;
var panY = 0;
var minScale = 1;
var maxScale = 5;
var isPanning = false;
var panStartX = 0;
var panStartY = 0;
// History state
var pushedHistory = false;
var closingFromPopstate = false;
function ensureElement() {
if (lb) return;
lb = document.createElement('div');
lb.className = 'luxtools-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-hidden', 'true');
lb.innerHTML =
'<div class="luxtools-lightbox-backdrop" data-luxtools-action="close"></div>' +
'<div class="luxtools-lightbox-stage">' +
'<button type="button" class="luxtools-lightbox-close" data-luxtools-action="close" aria-label="Close">×</button>' +
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-prev" data-luxtools-action="prev" aria-label="Previous"></button>' +
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-next" data-luxtools-action="next" aria-label="Next"></button>' +
'<div class="luxtools-lightbox-media">' +
'<img class="luxtools-lightbox-img" alt="" />' +
'</div>' +
'<div class="luxtools-lightbox-caption"></div>' +
'</div>';
document.body.appendChild(lb);
img = lb.querySelector('img.luxtools-lightbox-img');
cap = lb.querySelector('.luxtools-lightbox-caption');
lb.addEventListener('click', onClick);
}
function clampIndex(n) {
if (n < 0) return items.length - 1;
if (n >= items.length) return 0;
return n;
}
function applyTransform() {
if (scale <= 1 && panX === 0 && panY === 0) {
img.style.transform = '';
} else {
img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)';
}
img.style.cursor = scale > 1 ? 'grab' : '';
}
function resetZoom() {
scale = 1;
panX = 0;
panY = 0;
applyTransform();
}
function render() {
var it = items[index];
img.src = it.full;
img.setAttribute('data-luxtools-index', String(index));
if (cap) cap.textContent = (it.name || '').trim();
resetZoom();
}
function next() {
index = clampIndex(index + 1);
render();
}
function prev() {
index = clampIndex(index - 1);
render();
}
// Event handlers
function onWheel(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? -0.15 : 0.15;
scale = Math.max(minScale, Math.min(maxScale, scale + delta));
if (scale <= 1) { panX = 0; panY = 0; }
applyTransform();
}
function onDblClick(e) {
e.preventDefault();
if (scale > 1) {
scale = 1;
panX = 0;
panY = 0;
} else {
scale = 2.5;
}
applyTransform();
}
function onMouseDown(e) {
if (scale > 1 && e.button === 0) {
isPanning = true;
panStartX = e.clientX - panX * scale;
panStartY = e.clientY - panY * scale;
img.style.cursor = 'grabbing';
e.preventDefault();
}
}
function onMouseMove(e) {
if (isPanning && scale > 1) {
panX = (e.clientX - panStartX) / scale;
panY = (e.clientY - panStartY) / scale;
applyTransform();
img.style.cursor = 'grabbing';
}
}
function onMouseUp() {
isPanning = false;
img.style.cursor = scale > 1 ? 'grab' : '';
}
function onKeyDown(e) {
if (!lb || !lb.classList.contains('is-open')) return;
var key = e.key || '';
if (key === 'Escape') {
e.preventDefault();
close();
} else if (key === 'ArrowRight') {
e.preventDefault();
next();
} else if (key === 'ArrowLeft') {
e.preventDefault();
prev();
}
}
function onPopState() {
if (!lb || !lb.classList.contains('is-open')) return;
closingFromPopstate = true;
try { close(); } finally { closingFromPopstate = false; }
}
function onClick(e) {
var t = e.target;
if (!t || !t.getAttribute) return;
var action = t.getAttribute('data-luxtools-action') || '';
if (action === 'close') { e.preventDefault(); close(); return; }
if (action === 'next') { e.preventDefault(); next(); return; }
if (action === 'prev') { e.preventDefault(); prev(); return; }
if (t.closest && t.closest('button.luxtools-lightbox-zone')) return;
if (t.closest && t.closest('img.luxtools-lightbox-img')) return;
e.preventDefault();
close();
}
function attachListeners() {
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('popstate', onPopState, true);
img.addEventListener('wheel', onWheel, { passive: false });
img.addEventListener('dblclick', onDblClick);
img.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function detachListeners() {
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('popstate', onPopState, true);
img.removeEventListener('wheel', onWheel);
img.removeEventListener('dblclick', onDblClick);
img.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
function open(galleryEl, startEl) {
var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]');
items = [];
links.forEach(function (a) {
var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || '';
var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || '';
if (!full) return;
items.push({ el: a, full: full, name: name });
});
if (!items.length) return;
index = 0;
for (var i = 0; i < items.length; i++) {
if (items[i].el === startEl) { index = i; break; }
}
ensureElement();
pushedHistory = false;
closingFromPopstate = false;
lb.classList.add('is-open');
lb.setAttribute('aria-hidden', 'false');
try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {}
try { document.body.style.overflow = 'hidden'; } catch (e) {}
attachListeners();
try {
if (window.history && window.history.pushState) {
window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href);
pushedHistory = true;
}
} catch (e) {}
render();
}
function close() {
if (!lb) return;
lb.classList.remove('is-open');
lb.setAttribute('aria-hidden', 'true');
try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {}
try { document.body.style.overflow = ''; } catch (e) {}
img.src = '';
resetZoom();
detachListeners();
if (pushedHistory && !closingFromPopstate) {
try {
if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) {
window.history.back();
}
} catch (e) {}
}
items = [];
}
return {
open: open,
close: close
};
})();
// ============================================================
// Gallery Thumbnails
// ============================================================
function initGalleryThumbs() {
var imgs = document.querySelectorAll('div.luxtools-gallery img[data-thumb-src]');
if (!imgs || !imgs.length) return;
function loadThumb(img) {
var src = img.getAttribute('data-thumb-src') || '';
if (!src) return;
if (img.getAttribute('data-thumb-loading') === '1') return;
img.setAttribute('data-thumb-loading', '1');
var pre = new window.Image();
pre.onload = function () {
img.src = src;
img.removeAttribute('data-thumb-src');
img.removeAttribute('data-thumb-loading');
};
pre.onerror = function () {
img.removeAttribute('data-thumb-loading');
};
pre.src = src;
}
if ('IntersectionObserver' in window) {
var io = new window.IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
loadThumb(entry.target);
io.unobserve(entry.target);
});
}, { rootMargin: '200px' });
imgs.forEach(function (img) { io.observe(img); });
} else {
// Fallback: load soon after initial render
window.setTimeout(function () {
imgs.forEach(loadThumb);
}, 0);
}
}
// ============================================================
// Open Service (file:// links)
// ============================================================
function getServiceUrl(el) {
var url = el.getAttribute('data-service-url') || '';
url = (url || '').trim();
if (!url) return '';
// strip trailing slashes
return url.replace(/\/+$/, '');
}
function pingOpenViaImage(el, rawPath) {
var baseUrl = getServiceUrl(el);
if (!baseUrl) return;
var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath);
// Fire-and-forget without CORS.
try {
var img = new window.Image();
img.src = url;
} catch (e) {
// ignore
}
}
function openViaService(el, rawPath) {
var baseUrl = getServiceUrl(el);
if (!baseUrl) return Promise.reject(new Error('No client service configured'));
var headers = {
'Content-Type': 'application/json'
};
return window.fetch(baseUrl + '/open', {
method: 'POST',
mode: 'cors',
credentials: 'omit',
headers: headers,
body: JSON.stringify({ path: rawPath })
}).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return null; }).then(function (body) {
var msg = (body && body.message) ? body.message : ('HTTP ' + res.status);
throw new Error(msg);
});
}
return res.json().catch(function () { return { ok: true }; });
});
}
function normalizeToFileUrl(path) {
if (!path) return '';
// already a file URL
if (/^file:\/\//i.test(path)) return path;
// UNC path: \\server\share\path
if (/^\\\\/.test(path)) {
var p = path.replace(/^\\\\/, '');
p = p.replace(/\\/g, '/');
return 'file://///' + p;
}
// Windows drive: C:\path\to\file
if (/^[a-zA-Z]:\\/.test(path)) {
var drive = path[0].toUpperCase();
var rest = path.slice(2).replace(/\\/g, '/');
return 'file:///' + drive + ':' + rest;
}
// POSIX absolute: /home/user/file
if (path[0] === '/') {
return 'file://' + path;
}
// Fall back to using the provided string.
return path;
}
function initScratchpads() {
var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]');
if (!pads || !pads.length) return;
function setEditMode(root, isEditing) {
if (!root || !root.classList) return;
var view = root.querySelector('.luxtools-scratchpad-view');
var editor = root.querySelector('.luxtools-scratchpad-editor');
if (isEditing) {
root.classList.add('is-editing');
if (view) view.hidden = true;
if (editor) editor.hidden = false;
} else {
root.classList.remove('is-editing');
if (view) view.hidden = false;
if (editor) editor.hidden = true;
}
}
function setStatus(root, msg) {
var el = root.querySelector('.luxtools-scratchpad-status');
if (!el) return;
el.textContent = msg || '';
}
function getSectok(root) {
// Prefer a token embedded with the rendered scratchpad.
try {
if (root && root.getAttribute) {
var t = String(root.getAttribute('data-sectok') || '').trim();
if (t) return t;
}
} catch (e) {}
// Fall back to DokuWiki's global JSINFO.
try {
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
} catch (e) {}
// Last resort: find any security token input on the page.
try {
var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]');
if (inp && inp.value) return String(inp.value);
} catch (e2) {}
return '';
}
function loadPad(root) {
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
var pad = (root.getAttribute('data-pad') || '').trim();
var pageId = (root.getAttribute('data-pageid') || '').trim();
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
var url = endpoint + '?cmd=load&pad=' + encodeURIComponent(pad) + '&id=' + encodeURIComponent(pageId);
return window.fetch(url, {
method: 'GET',
credentials: 'same-origin'
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (body) {
if (!res.ok || !body || body.ok !== true) {
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
throw new Error(msg);
}
return body.text || '';
});
});
}
function savePad(root, text) {
var endpoint = (root.getAttribute('data-endpoint') || '').trim();
var pad = (root.getAttribute('data-pad') || '').trim();
var pageId = (root.getAttribute('data-pageid') || '').trim();
if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params'));
var params = new window.URLSearchParams();
params.set('cmd', 'save');
params.set('pad', pad);
params.set('id', pageId);
params.set('text', text || '');
params.set('sectok', getSectok(root));
return window.fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: params.toString()
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (body) {
if (!res.ok || !body || body.ok !== true) {
var msg = (body && body.error) ? body.error : ('HTTP ' + res.status);
throw new Error(msg);
}
return true;
});
});
}
function openEditor(root) {
var editor = root.querySelector('.luxtools-scratchpad-editor');
var textarea = root.querySelector('textarea.luxtools-scratchpad-text');
if (!editor || !textarea) return;
setEditMode(root, true);
setStatus(root, 'Loading…');
textarea.disabled = true;
loadPad(root).then(function (text) {
textarea.value = text;
textarea.disabled = false;
setStatus(root, '');
textarea.focus();
}).catch(function (e) {
textarea.disabled = false;
setStatus(root, 'Load failed: ' + (e && e.message ? e.message : 'error'));
});
}
function closeEditor(root) {
var editor = root.querySelector('.luxtools-scratchpad-editor');
if (!editor) return;
setEditMode(root, false);
setStatus(root, '');
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t) return;
var edit = t.closest ? t.closest('a.luxtools-scratchpad-edit') : null;
if (edit) {
var root = edit.closest('div.luxtools-scratchpad');
if (!root) return;
e.preventDefault();
openEditor(root);
return;
}
var save = t.closest ? t.closest('button.luxtools-scratchpad-save') : null;
if (save) {
var rootS = save.closest('div.luxtools-scratchpad');
if (!rootS) return;
e.preventDefault();
var textareaS = rootS.querySelector('textarea.luxtools-scratchpad-text');
if (!textareaS) return;
textareaS.disabled = true;
setStatus(rootS, 'Saving…');
savePad(rootS, textareaS.value).then(function () {
setStatus(rootS, 'Saved. Reloading…');
try { window.location.reload(); } catch (err) {}
}).catch(function (err) {
textareaS.disabled = false;
setStatus(rootS, 'Save failed: ' + (err && err.message ? err.message : 'error'));
});
return;
}
var cancel = t.closest ? t.closest('button.luxtools-scratchpad-cancel') : null;
if (cancel) {
var rootC = cancel.closest('div.luxtools-scratchpad');
if (!rootC) return;
e.preventDefault();
closeEditor(rootC);
}
}, true);
}
// ============================================================
// Click Handlers
// ============================================================
function findOpenElement(target) {
var el = target;
while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-open')) return el;
el = el.parentNode;
}
return null;
}
function findGalleryItem(target) {
var el = target;
while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-gallery-item')) return el;
el = el.parentNode;
}
return null;
}
function onClick(event) {
// Image gallery lightbox: intercept clicks so we don't navigate away.
var galleryItem = findGalleryItem(event.target);
if (galleryItem) {
var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null;
if (gallery) {
event.preventDefault();
Lightbox.open(gallery, galleryItem);
return;
}
}
var el = findOpenElement(event.target);
if (!el) return;
// {{open>...}} renders as a link; avoid jumping to '#'.
if (el.tagName && el.tagName.toLowerCase() === 'a') {
event.preventDefault();
}
var raw = el.getAttribute('data-path') || '';
if (!raw) return;
// Prefer local client service.
openViaService(el, raw)
.catch(function (err) {
// If the browser blocks the request before it reaches localhost (mixed-content,
// extensions, stricter CORS handling), fall back to a no-CORS GET ping.
pingOpenViaImage(el, raw);
// Fallback to old behavior (often blocked in modern browsers).
var url = normalizeToFileUrl(raw);
if (!url) return;
console.warn('Local client service failed, falling back to file:// navigation:', err);
try {
window.open(url, '_blank', 'noopener');
} catch (e) {
try {
window.location.href = url;
} catch (e2) {
console.error('Failed to open file URL:', e2);
}
}
});
}
// ============================================================
// Initialize
// ============================================================
document.addEventListener('click', onClick, false);
document.addEventListener('DOMContentLoaded', initGalleryThumbs, false);
document.addEventListener('DOMContentLoaded', initScratchpads, false);
})();

241
src/ChronoID.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Helper for canonical chronological page IDs.
*
* Canonical structure:
* - Day: baseNs:YYYY:MM:DD
* - Month: baseNs:YYYY:MM
* - Year: baseNs:YYYY
*
* Date input format:
* - Strict ISO date only: YYYY-MM-DD
*/
class ChronoID
{
/**
* Check if a string is a strict ISO date and a valid Gregorian date.
*
* @param string $value
* @return bool
*/
public static function isIsoDate(string $value): bool
{
return self::parseIsoDate($value) !== null;
}
/**
* Convert YYYY-MM-DD to canonical day page ID.
*
* @param string $value Date in strict YYYY-MM-DD format
* @param string $baseNs Base namespace, default chronological
* @return string|null Canonical day ID or null on invalid input
*/
public static function dateToDayId(string $value, string $baseNs = 'chronological'): ?string
{
$parts = self::parseIsoDate($value);
if ($parts === null) return null;
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
[$year, $month, $day] = $parts;
return sprintf('%s:%04d:%02d:%02d', $ns, $year, $month, $day);
}
/**
* Check whether a page ID is a canonical day ID.
*
* @param string $id
* @param string $baseNs
* @return bool
*/
public static function isDayId(string $id, string $baseNs = 'chronological'): bool
{
return self::parseDayId($id, $baseNs) !== null;
}
/**
* Check whether a page ID is a canonical month ID.
*
* @param string $id
* @param string $baseNs
* @return bool
*/
public static function isMonthId(string $id, string $baseNs = 'chronological'): bool
{
return self::parseMonthId($id, $baseNs) !== null;
}
/**
* Check whether a page ID is a canonical year ID.
*
* @param string $id
* @param string $baseNs
* @return bool
*/
public static function isYearId(string $id, string $baseNs = 'chronological'): bool
{
return self::parseYearId($id, $baseNs) !== null;
}
/**
* Convert canonical day ID to canonical month ID.
*
* @param string $dayId
* @param string $baseNs
* @return string|null
*/
public static function dayIdToMonthId(string $dayId, string $baseNs = 'chronological'): ?string
{
$parts = self::parseDayId($dayId, $baseNs);
if ($parts === null) return null;
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
return sprintf('%s:%04d:%02d', $ns, $parts['year'], $parts['month']);
}
/**
* Convert canonical month ID to canonical year ID.
*
* @param string $monthId
* @param string $baseNs
* @return string|null
*/
public static function monthIdToYearId(string $monthId, string $baseNs = 'chronological'): ?string
{
$parts = self::parseMonthId($monthId, $baseNs);
if ($parts === null) return null;
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
return sprintf('%s:%04d', $ns, $parts['year']);
}
/**
* Parse canonical day ID.
*
* @param string $id
* @param string $baseNs
* @return array{year:int,month:int,day:int}|null
*/
public static function parseDayId(string $id, string $baseNs = 'chronological'): ?array
{
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
$id = trim($id);
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4}):(\d{2}):(\d{2})$/';
if (!preg_match($pattern, $id, $matches)) return null;
$year = (int)$matches[1];
$month = (int)$matches[2];
$day = (int)$matches[3];
if ($year < 1) return null;
if (!checkdate($month, $day, $year)) return null;
return [
'year' => $year,
'month' => $month,
'day' => $day,
];
}
/**
* Parse canonical month ID.
*
* @param string $id
* @param string $baseNs
* @return array{year:int,month:int}|null
*/
public static function parseMonthId(string $id, string $baseNs = 'chronological'): ?array
{
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
$id = trim($id);
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4}):(\d{2})$/';
if (!preg_match($pattern, $id, $matches)) return null;
$year = (int)$matches[1];
$month = (int)$matches[2];
if ($year < 1) return null;
if ($month < 1 || $month > 12) return null;
return [
'year' => $year,
'month' => $month,
];
}
/**
* Parse canonical year ID.
*
* @param string $id
* @param string $baseNs
* @return array{year:int}|null
*/
public static function parseYearId(string $id, string $baseNs = 'chronological'): ?array
{
$ns = self::normalizeBaseNs($baseNs);
if ($ns === null) return null;
$id = trim($id);
$pattern = '/^' . preg_quote($ns, '/') . ':(\d{4})$/';
if (!preg_match($pattern, $id, $matches)) return null;
$year = (int)$matches[1];
if ($year < 1) return null;
return ['year' => $year];
}
/**
* Parse strict ISO date YYYY-MM-DD.
*
* @param string $value
* @return int[]|null [year, month, day] or null
*/
protected static function parseIsoDate(string $value): ?array
{
$value = trim($value);
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
return null;
}
$year = (int)$matches[1];
$month = (int)$matches[2];
$day = (int)$matches[3];
if ($year < 1) return null;
if (!checkdate($month, $day, $year)) return null;
return [$year, $month, $day];
}
/**
* Normalize and validate base namespace.
*
* Allows one or more namespace segments with characters [a-z0-9_-].
*
* @param string $baseNs
* @return string|null
*/
protected static function normalizeBaseNs(string $baseNs): ?string
{
$baseNs = strtolower(trim($baseNs));
$baseNs = trim($baseNs, ':');
if ($baseNs === '') return null;
if (!preg_match('/^[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)*$/', $baseNs)) {
return null;
}
return $baseNs;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Render the chronological calendar widget markup.
*/
class ChronologicalCalendarWidget
{
/**
* Render full calendar widget HTML for one month.
*
* @param int $year
* @param int $month
* @param string $baseNs
* @return string
*/
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
{
if (!self::isValidMonth($year, $month)) return '';
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
$daysInMonth = (int)date('t', $firstDayTs);
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
$monthCursor = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
if (!($monthCursor instanceof \DateTimeImmutable)) return '';
$prevMonth = $monthCursor->sub(new \DateInterval('P1M'));
$nextMonth = $monthCursor->add(new \DateInterval('P1M'));
$monthStartDate = sprintf('%04d-%02d-01', $year, $month);
$monthDayId = ChronoID::dateToDayId($monthStartDate, $baseNs);
$monthId = $monthDayId !== null ? ChronoID::dayIdToMonthId($monthDayId, $baseNs) : null;
$yearId = $monthId !== null ? ChronoID::monthIdToYearId($monthId, $baseNs) : null;
$prevStartDate = $prevMonth->format('Y-m-d');
$prevDayId = ChronoID::dateToDayId($prevStartDate, $baseNs);
$prevMonthId = $prevDayId !== null ? ChronoID::dayIdToMonthId($prevDayId, $baseNs) : null;
$nextStartDate = $nextMonth->format('Y-m-d');
$nextDayId = ChronoID::dateToDayId($nextStartDate, $baseNs);
$nextMonthId = $nextDayId !== null ? ChronoID::dayIdToMonthId($nextDayId, $baseNs) : null;
$leadingEmpty = $firstWeekday - 1;
$totalCells = (int)ceil(($leadingEmpty + $daysInMonth) / 7) * 7;
$todayY = (int)date('Y');
$todayM = (int)date('m');
$todayD = (int)date('d');
$dayUrlTemplate = function_exists('wl') ? (string)wl('__LUXTOOLS_ID_RAW__') : '';
$monthUrlTemplate = $dayUrlTemplate;
$yearUrlTemplate = $dayUrlTemplate;
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
$html = '<div class="luxtools-plugin luxtools-calendar" data-luxtools-calendar="1"'
. ' data-base-ns="' . hsc($baseNs) . '"'
. ' data-current-year="' . hsc((string)$year) . '"'
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
. ' data-prev-month-id="' . hsc((string)$prevMonthId) . '"'
. ' data-next-month-id="' . hsc((string)$nextMonthId) . '"'
. '>';
$html .= '<div class="luxtools-calendar-nav">';
$html .= '<div class="luxtools-calendar-nav-prev">';
$html .= '<button type="button" class="luxtools-calendar-nav-button" data-luxtools-dir="-1" aria-label="Previous month">◀</button>';
$html .= '</div>';
$html .= '<div class="luxtools-calendar-title">';
$monthLabel = date('F', $firstDayTs);
if ($monthId !== null && function_exists('wl')) {
$html .= '<a class="luxtools-calendar-month-link" href="' . hsc((string)wl($monthId)) . '">' . hsc($monthLabel) . '</a>';
} else {
$html .= hsc($monthLabel);
}
$html .= ' ';
if ($yearId !== null && function_exists('wl')) {
$html .= '<a class="luxtools-calendar-year-link" href="' . hsc((string)wl($yearId)) . '">' . hsc((string)$year) . '</a>';
} else {
$html .= hsc((string)$year);
}
$html .= '</div>';
$html .= '<div class="luxtools-calendar-nav-next">';
$html .= '<button type="button" class="luxtools-calendar-nav-button" data-luxtools-dir="1" aria-label="Next month">▶</button>';
$html .= '</div>';
$html .= '</div>';
$html .= '<table class="luxtools-calendar-table">';
$html .= '<thead><tr>';
foreach (['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as $weekday) {
$html .= '<th scope="col">' . hsc($weekday) . '</th>';
}
$html .= '</tr></thead><tbody>';
for ($cell = 0; $cell < $totalCells; $cell++) {
if ($cell % 7 === 0) $html .= '<tr>';
$dayNumber = $cell - $leadingEmpty + 1;
if ($dayNumber < 1 || $dayNumber > $daysInMonth) {
$html .= '<td class="luxtools-calendar-day luxtools-calendar-day-empty"></td>';
} else {
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
$dayId = ChronoID::dateToDayId($date, $baseNs);
$classes = 'luxtools-calendar-day';
if ($year === $todayY && $month === $todayM && $dayNumber === $todayD) {
$classes .= ' luxtools-calendar-day-today';
}
$html .= '<td class="' . hsc($classes) . '">';
if ($dayId !== null && function_exists('html_wikilink')) {
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
} else {
$html .= hsc((string)$dayNumber);
}
$html .= '</td>';
}
if ($cell % 7 === 6) $html .= '</tr>';
}
$html .= '</tbody></table></div>';
return $html;
}
/**
* @param int $year
* @param int $month
* @return bool
*/
public static function isValidMonth(int $year, int $month): bool
{
if ($year < 1) return false;
if ($month < 1 || $month > 12) return false;
return true;
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Auto-links strict ISO dates in rendered XHTML fragments.
*/
class ChronologicalDateAutoLinker
{
/** @var string[] Tags where auto-linking must be disabled */
protected static $blockedTags = [
'a',
'code',
'pre',
'script',
'style',
'textarea',
];
/**
* Link valid ISO dates in HTML text nodes while skipping blocked tags.
*
* @param string $html
* @return string
*/
public static function linkHtml(string $html): string
{
$parts = preg_split('/(<[^>]+>)/u', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if (!is_array($parts)) return $html;
$blocked = [];
foreach (self::$blockedTags as $tag) {
$blocked[$tag] = 0;
}
$out = '';
foreach ($parts as $part) {
if ($part === '') {
$out .= $part;
continue;
}
if (str_starts_with($part, '<')) {
self::updateBlockedTagCounters($part, $blocked);
$out .= $part;
continue;
}
if (self::isBlockedContext($blocked)) {
$out .= $part;
continue;
}
$out .= self::linkText($part);
}
return $out;
}
/**
* Link strict ISO dates in plain text.
*
* @param string $text
* @return string
*/
protected static function linkText(string $text): string
{
$replaced = preg_replace_callback(
'/(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/',
static function (array $matches): string {
$date = (string)($matches[1] ?? '');
if ($date === '') return $matches[0];
$id = ChronoID::dateToDayId($date);
if ($id === null) return $matches[0];
if (function_exists('html_wikilink')) {
return (string)html_wikilink($id, $date);
}
if (function_exists('wl')) {
return '<a href="' . hsc((string)wl($id)) . '">' . hsc($date) . '</a>';
}
return $matches[0];
},
$text
);
return is_string($replaced) ? $replaced : $text;
}
/**
* Update blocked-tag counters while traversing HTML tokens.
*
* @param string $token
* @param array<string,int> $blocked
* @return void
*/
protected static function updateBlockedTagCounters(string $token, array &$blocked): void
{
if (!preg_match('/^<\s*(\/?)\s*([a-zA-Z0-9:_-]+)/', $token, $matches)) {
return;
}
$isClosing = $matches[1] === '/';
$tag = strtolower((string)$matches[2]);
if (!array_key_exists($tag, $blocked)) return;
if ($isClosing) {
if ($blocked[$tag] > 0) $blocked[$tag]--;
return;
}
$selfClosing = preg_match('/\/\s*>$/', $token) === 1;
if (!$selfClosing) {
$blocked[$tag]++;
}
}
/**
* Check if traversal is currently inside a blocked context.
*
* @param array<string,int> $blocked
* @return bool
*/
protected static function isBlockedContext(array $blocked): bool
{
foreach ($blocked as $count) {
if ($count > 0) return true;
}
return false;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Builds page template content for chronological day pages.
*/
class ChronologicalDayTemplate
{
/**
* Build a German date heading template for a canonical day ID.
*
* Example output:
* ====== Freitag, 13. Februar 2026 ======
*
* @param string $dayId
* @param string $baseNs
* @return string|null
*/
public static function buildForDayId(string $dayId, string $baseNs = 'chronological'): ?string
{
$parts = ChronoID::parseDayId($dayId, $baseNs);
if ($parts === null) return null;
$formatted = self::formatGermanDate($parts['year'], $parts['month'], $parts['day']);
return '====== ' . $formatted . " ======\n\n";
}
/**
* Format date with German day/month names and style:
* Freitag, 13. Februar 2026
*
* @param int $year
* @param int $month
* @param int $day
* @return string
*/
protected static function formatGermanDate(int $year, int $month, int $day): string
{
$weekdays = [
1 => 'Montag',
2 => 'Dienstag',
3 => 'Mittwoch',
4 => 'Donnerstag',
5 => 'Freitag',
6 => 'Samstag',
7 => 'Sonntag',
];
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
$weekdayIndex = (int)date('N', mktime(0, 0, 0, $month, $day, $year));
$weekday = $weekdays[$weekdayIndex] ?? '';
$monthName = $months[$month] ?? '';
return sprintf('%s, %d. %s %d', $weekday, $day, $monthName, $year);
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace dokuwiki\plugin\luxtools;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
use Throwable;
/**
* Read local ICS files using sabre/vobject and expose events for one day.
*/
class ChronologicalIcsEvents
{
/** @var array<string,array<int,array{summary:string,time:string,startIso:string,allDay:bool}>> In-request cache */
protected static $runtimeCache = [];
/**
* Return events for one day (YYYY-MM-DD) from configured local ICS files.
*
* @param string $icsConfig Multiline list of local ICS file paths
* @param string $dateIso
* @return array<int,array{summary:string,time:string,startIso:string,allDay:bool}>
*/
public static function eventsForDate(string $icsConfig, string $dateIso): array
{
if (!ChronoID::isIsoDate($dateIso)) return [];
$files = self::parseConfiguredFiles($icsConfig);
if ($files === []) return [];
$signature = self::buildSignature($files);
if ($signature === '') return [];
$cacheKey = $signature . '|' . $dateIso;
if (isset(self::$runtimeCache[$cacheKey])) {
return self::$runtimeCache[$cacheKey];
}
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
$events = [];
$seen = [];
foreach ($files as $file) {
foreach (self::readEventsFromFile($file, $dateIso, $rangeStart, $rangeEnd) as $entry) {
$dedupeKey = implode('|', [
(string)($entry['summary'] ?? ''),
(string)($entry['time'] ?? ''),
((bool)($entry['allDay'] ?? false)) ? '1' : '0',
]);
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$events[] = $entry;
}
}
usort($events, static function (array $a, array $b): int {
$aAllDay = (bool)($a['allDay'] ?? false);
$bAllDay = (bool)($b['allDay'] ?? false);
if ($aAllDay !== $bAllDay) {
return $aAllDay ? -1 : 1;
}
$timeCmp = strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? ''));
if ($timeCmp !== 0) return $timeCmp;
return strcmp((string)($a['summary'] ?? ''), (string)($b['summary'] ?? ''));
});
self::$runtimeCache[$cacheKey] = $events;
return $events;
}
/**
* @param string $icsConfig
* @return string[]
*/
protected static function parseConfiguredFiles(string $icsConfig): array
{
$files = [];
$lines = preg_split('/\r\n|\r|\n/', $icsConfig) ?: [];
foreach ($lines as $line) {
$line = trim((string)$line);
if ($line === '') continue;
if (str_starts_with($line, '#')) continue;
$path = Path::cleanPath($line, false);
if (!is_file($path) || !is_readable($path)) continue;
$files[] = $path;
}
$files = array_values(array_unique($files));
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
return $files;
}
/**
* Build signature from file path + mtime + size.
*
* @param string[] $files
* @return string
*/
protected static function buildSignature(array $files): string
{
if ($files === []) return '';
$parts = [];
foreach ($files as $file) {
$mtime = @filemtime($file) ?: 0;
$size = @filesize($file) ?: 0;
$parts[] = $file . '|' . $mtime . '|' . $size;
}
return sha1(implode("\n", $parts));
}
/**
* Parse one ICS file and return normalized events for the target day.
*
* @param string $file
* @param string $dateIso
* @param DateTimeImmutable $rangeStart
* @param DateTimeImmutable $rangeEnd
* @return array<int,array{summary:string,time:string,startIso:string,allDay:bool}>
*/
protected static function readEventsFromFile(
string $file,
string $dateIso,
DateTimeImmutable $rangeStart,
DateTimeImmutable $rangeEnd
): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
$expanded = $component->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
return self::collectEventsFromCalendar($expanded, $dateIso);
} catch (Throwable $e) {
return [];
}
}
/**
* @param VCalendar $calendar
* @param string $dateIso
* @return array<int,array{summary:string,time:string,startIso:string,allDay:bool}>
*/
protected static function collectEventsFromCalendar(
VCalendar $calendar,
string $dateIso
): array {
$result = [];
$seen = [];
foreach ($calendar->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue;
$normalized = self::normalizeEventForDay($vevent, $dateIso);
if ($normalized === null) continue;
$dedupeKey = implode('|', [
(string)($normalized['uid'] ?? ''),
(string)($normalized['rid'] ?? ''),
(string)($normalized['start'] ?? ''),
(string)($normalized['summary'] ?? ''),
(string)($normalized['time'] ?? ''),
((bool)($normalized['allDay'] ?? false)) ? '1' : '0',
]);
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$result[] = [
'summary' => (string)$normalized['summary'],
'time' => (string)$normalized['time'],
'startIso' => (string)$normalized['start'],
'allDay' => (bool)$normalized['allDay'],
];
}
return $result;
}
/**
* Convert VEVENT to output item when it intersects the target day.
*
* @param VEvent $vevent
* @param string $dateIso
* @return array<string,mixed>|null
*/
protected static function normalizeEventForDay(
VEvent $vevent,
string $dateIso
): ?array
{
if (!isset($vevent->DTSTART)) return null;
if (!ChronoID::isIsoDate($dateIso)) return null;
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
$start = self::toImmutableDateTime($vevent->DTSTART->getDateTime());
if ($start === null) return null;
$end = null;
if (isset($vevent->DTEND)) {
$end = self::toImmutableDateTime($vevent->DTEND->getDateTime());
} elseif (isset($vevent->DURATION)) {
try {
$duration = $vevent->DURATION->getDateInterval();
if ($duration instanceof DateInterval) {
$end = $start->add($duration);
}
} catch (Throwable $e) {
$end = null;
}
}
if ($end === null) {
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
}
if ($end <= $start) {
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
}
$eventTimezone = $start->getTimezone();
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
$dayEnd = $dayStart->add(new DateInterval('P1D'));
$intersects = ($start < $dayEnd) && ($end > $dayStart);
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
$intersects = true;
}
if (!$intersects) return null;
$summary = trim((string)($vevent->SUMMARY ?? ''));
if ($summary === '') $summary = '(ohne Titel)';
$uid = trim((string)($vevent->UID ?? ''));
$rid = '';
if (isset($vevent->{'RECURRENCE-ID'})) {
$rid = trim((string)$vevent->{'RECURRENCE-ID'});
}
return [
'uid' => $uid,
'rid' => $rid,
'start' => $start->format(DateTimeInterface::ATOM),
'summary' => $summary,
'time' => $isAllDay ? '' : $start->format('H:i'),
'allDay' => $isAllDay,
];
}
/**
* @param DateTimeInterface $dateTime
* @return DateTimeImmutable|null
*/
protected static function toImmutableDateTime(DateTimeInterface $dateTime): ?DateTimeImmutable
{
if ($dateTime instanceof DateTimeImmutable) return $dateTime;
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dateTime->getTimestamp());
if (!($immutable instanceof DateTimeImmutable)) return null;
return $immutable->setTimezone($dateTime->getTimezone());
}
}

View File

@@ -193,6 +193,7 @@ class Crawler
'children' => false, 'children' => false,
'treesize' => 1, 'treesize' => 1,
'isdir' => $isDir, 'isdir' => $isDir,
'childcount' => $isDir ? $this->countDirectChildren($filepath) : 0,
]; ];
$result[] = $entry; $result[] = $entry;
@@ -201,6 +202,32 @@ class Crawler
return $this->sortItems($result); return $this->sortItems($result);
} }
/**
* Count the direct children (files and subdirectories) of a directory.
*
* Hidden files (starting with '.') are excluded.
*
* @param string $dirPath
* @return int
*/
protected function countDirectChildren(string $dirPath): int
{
if (($dir = @opendir($dirPath)) === false) {
return 0;
}
$count = 0;
while (($file = readdir($dir)) !== false) {
// Skip hidden files and special entries
if ($file[0] === '.') {
continue;
}
$count++;
}
closedir($dir);
return $count;
}
/** /**
* Sort the given items by the current sortby and sortreverse settings * Sort the given items by the current sortby and sortreverse settings
* *

View File

@@ -16,25 +16,20 @@ class Output
/** @var array */ /** @var array */
protected $files; protected $files;
/** @var \DokuWiki_Plugin|null */
protected $plugin;
/** @var Path|false|null */ /** @var Path|false|null */
protected $openPathMapper = null; protected $openPathMapper = null;
public function __construct(\Doku_Renderer $renderer, $basedir, $webdir, $files) public function __construct(\Doku_Renderer $renderer, $basedir, $webdir, $files, $plugin = null)
{ {
$this->renderer = $renderer; $this->renderer = $renderer;
$this->basedir = $basedir; $this->basedir = $basedir;
$this->webdir = $webdir; $this->webdir = $webdir;
$this->files = $files; $this->files = $files;
} $this->plugin = $plugin;
public function renderAsList($params)
{
$this->openContainer($params);
$this->renderListItems($this->files, $params);
$this->closeContainer();
} }
/** /**
@@ -49,8 +44,6 @@ class Output
public function renderAsGallery($params) public function renderAsGallery($params)
{ {
if (!($this->renderer instanceof \Doku_Renderer_xhtml)) { if (!($this->renderer instanceof \Doku_Renderer_xhtml)) {
$params['style'] = 'list';
$this->renderAsList($params);
return; return;
} }
@@ -74,43 +67,35 @@ class Output
$genThumbW = (int)max(1, (int)round($thumbW * $thumbScale)); $genThumbW = (int)max(1, (int)round($thumbW * $thumbScale));
$genThumbH = (int)max(1, (int)round($thumbH * $thumbScale)); $genThumbH = (int)max(1, (int)round($thumbH * $thumbScale));
$placeholderUrl = DOKU_BASE . 'lib/images/blank.gif'; // Build placeholder URL from config (DokuWiki media ID)
$placeholderStyle = '';
$placeholderId = $syntax ? trim((string)$syntax->getConf('thumb_placeholder')) : ''; $placeholderId = $syntax ? trim((string)$syntax->getConf('thumb_placeholder')) : '';
if ($placeholderId !== '' && function_exists('ml')) { if ($placeholderId !== '' && function_exists('ml')) {
// ml() builds a fetch.php URL for a MediaManager item $placeholderUrl = ml($placeholderId, ['w' => $thumbW, 'h' => $thumbH], true, '&');
$placeholderUrl = ml($placeholderId, ['w' => $genThumbW, 'h' => $genThumbH], true, '&'); $placeholderStyle = ' style="--luxtools-placeholder: url(' . hsc($placeholderUrl) . ')"';
} }
/** @var \Doku_Renderer_xhtml $renderer */ /** @var \Doku_Renderer_xhtml $renderer */
$renderer = $this->renderer; $renderer = $this->renderer;
$renderer->doc .= '<div class="luxtools-plugin luxtools-gallery" data-luxtools-gallery="1">'; $renderer->doc .= '<div class="luxtools-plugin luxtools-gallery" data-luxtools-gallery="1"' . $placeholderStyle . '>';
foreach ($this->files as $item) { foreach ($this->files as $item) {
$url = $this->itemWebUrl($item, !empty($params['randlinks'])); $url = $this->itemWebUrl($item, !empty($params['randlinks']));
$thumbUrl = $this->withQueryParams($url, [
'thumb' => 1,
'w' => $genThumbW,
'h' => $genThumbH,
// Keep quality explicit so cache file naming stays stable.
'q' => $thumbQ,
]);
$safeUrl = hsc($url); $safeUrl = hsc($url);
$safeThumbUrl = hsc($thumbUrl);
$safePlaceholderUrl = hsc($placeholderUrl);
$label = hsc($item['name']); $label = hsc($item['name']);
$caption = hsc(basename((string)($item['name'] ?? ''))); $caption = hsc(basename((string)($item['name'] ?? '')));
if ($caption === '') { if ($caption === '') {
$caption = $label; $caption = $label;
} }
$initialSrc = $safePlaceholderUrl; // Build thumbnail URL - JavaScript will load via fetch() for cancellability
$dataThumb = ' data-thumb-src="' . $safeThumbUrl . '"'; $thumbUrl = $this->withQueryParams($url, [
$thumbCachePath = $this->thumbCachePathForItem($item, $genThumbW, $genThumbH, $thumbQ); 'thumb' => 1,
if (is_string($thumbCachePath) && $thumbCachePath !== '' && @is_file($thumbCachePath)) { 'w' => $genThumbW,
// Thumb already exists: start with it immediately (no JS swap needed) 'h' => $genThumbH,
$initialSrc = $safeThumbUrl; 'q' => $thumbQ,
$dataThumb = ''; ]);
} $thumbSrc = hsc($thumbUrl);
$renderer->doc .= '<a' $renderer->doc .= '<a'
. ' href="' . $safeUrl . '"' . ' href="' . $safeUrl . '"'
@@ -122,13 +107,10 @@ class Output
. '>'; . '>';
$renderer->doc .= '<img' $renderer->doc .= '<img'
. ' class="luxtools-thumb"' . ' class="luxtools-thumb"'
. ' src="' . $initialSrc . '"' . ' data-src="' . $thumbSrc . '"'
. $dataThumb
. ' alt=""' . ' alt=""'
. ' width="' . $thumbW . '"' . ' width="' . $thumbW . '"'
. ' height="' . $thumbH . '"' . ' height="' . $thumbH . '"'
. ' loading="lazy"'
. ' decoding="async"'
. ' />'; . ' />';
$renderer->doc .= '<span class="luxtools-gallery-caption">' . $caption . '</span>'; $renderer->doc .= '<span class="luxtools-gallery-caption">' . $caption . '</span>';
$renderer->doc .= '</a>'; $renderer->doc .= '</a>';
@@ -161,44 +143,6 @@ class Output
return $url . $glue . http_build_query($params, '', '&', PHP_QUERY_RFC3986) . $fragment; return $url . $glue . http_build_query($params, '', '&', PHP_QUERY_RFC3986) . $fragment;
} }
/**
* Compute the expected thumbnail cache path for an item.
*
* Mirrors the hashing scheme in file.php so we can detect whether a thumb
* already exists and can be used immediately.
*
* @param array $item
* @param int $w
* @param int $h
* @param int $q
* @return string|null
*/
protected function thumbCachePathForItem(array $item, int $w, int $h, int $q): ?string
{
if (!isset($item['path']) || !is_string($item['path']) || $item['path'] === '') return null;
if (!isset($item['mtime'])) return null;
$path = $item['path'];
$mtime = (int)$item['mtime'];
// Decide output format the same way file.php does.
try {
[, $mime,] = mimetype($path, false);
} catch (\Throwable $e) {
return null;
}
if (!is_string($mime) || !str_starts_with($mime, 'image/')) return null;
$dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg';
global $conf;
if (!isset($conf['cachedir']) || !is_string($conf['cachedir']) || trim($conf['cachedir']) === '') return null;
$hash = sha1($path . '|' . $mtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat);
$sub = substr($hash, 0, 2);
$cacheDir = rtrim($conf['cachedir'], '/');
return $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat;
}
/** /**
* Renders the files as a table, including details if configured that way. * Renders the files as a table, including details if configured that way.
* *
@@ -341,6 +285,19 @@ class Output
} }
$renderer->tabletbody_open(); $renderer->tabletbody_open();
if ($items === []) {
// Render a single row with an empty state message.
$renderer->tablerow_open();
$renderer->tablecell_open($columns);
if ($renderer instanceof \Doku_Renderer_xhtml) {
$renderer->doc .= '<span class="luxtools-empty">' . hsc($this->getLang('empty_files')) . '</span>';
} else {
$renderer->cdata($this->getLang('empty_files'));
}
$renderer->tablecell_close();
$renderer->tablerow_close();
} else {
foreach ($items as $item) { foreach ($items as $item) {
$renderer->tablerow_open(); $renderer->tablerow_open();
$renderer->tablecell_open(); $renderer->tablecell_open();
@@ -350,7 +307,9 @@ class Output
if ($params['showsize']) { if ($params['showsize']) {
$renderer->tablecell_open(1, 'right'); $renderer->tablecell_open(1, 'right');
if (!empty($item['isdir'])) { if (!empty($item['isdir'])) {
$renderer->cdata(''); // Show item count for directories
$childCount = $item['childcount'] ?? 0;
$renderer->cdata($childCount . ' ' . ($childCount === 1 ? 'item' : 'items'));
} else { } else {
$renderer->cdata(filesize_h($item['size'])); $renderer->cdata(filesize_h($item['size']));
} }
@@ -365,62 +324,12 @@ class Output
$renderer->tablerow_close(); $renderer->tablerow_close();
} }
}
$renderer->tabletbody_close(); $renderer->tabletbody_close();
$renderer->table_close(); $renderer->table_close();
} }
/**
* Recursively renders a tree of files as list items.
*
* @param array $items the files to render
* @param array $params the parameters of the filelist call
* @param int $level the level to render
* @return void
*/
protected function renderListItems($items, $params, $level = 1)
{
if ($params['style'] == 'olist') {
$this->renderer->listo_open();
} else {
$this->renderer->listu_open();
}
foreach ($items as $file) {
if ($file['children'] === false && $file['treesize'] === 0) continue; // empty directory
$this->renderer->listitem_open($level);
$this->renderer->listcontent_open();
if ($file['children'] !== false && $file['treesize'] > 0) {
// render the directory and its subtree
$this->renderer->cdata($file['name']);
$this->renderListItems($file['children'], $params, $level + 1);
} elseif ($file['children'] === false) {
// render the file link
$this->renderItemLink($file, $params['randlinks']);
// render filesize
if ($params['showsize']) {
$this->renderer->cdata($params['listsep'] . filesize_h($file['size']));
}
// render lastmodified
if ($params['showdate']) {
$this->renderer->cdata($params['listsep'] . dformat($file['mtime']));
}
}
$this->renderer->listcontent_close();
$this->renderer->listitem_close();
}
if ($params['style'] == 'olist') {
$this->renderer->listo_close();
} else {
$this->renderer->listu_close();
}
}
protected function renderItemLink($item, $cachebuster = false) protected function renderItemLink($item, $cachebuster = false)
{ {
if (!empty($item['isdir'])) { if (!empty($item['isdir'])) {
@@ -632,7 +541,16 @@ class Output
protected function getLang($key) protected function getLang($key)
{ {
$syntax = plugin_load('syntax', 'luxtools'); if ($this->plugin && method_exists($this->plugin, 'getLang')) {
return $this->plugin->getLang($key);
}
// Fallback: try loading any luxtools syntax component
$syntax = plugin_load('syntax', 'luxtools_directory');
if ($syntax) {
return $syntax->getLang($key); return $syntax->getLang($key);
} }
return $key; // Return key if we can't load language strings
}
} }

351
src/PageLink.php Normal file
View File

@@ -0,0 +1,351 @@
<?php
namespace dokuwiki\plugin\luxtools;
class PageLink
{
public const META_KEY = 'pagelink';
public const CACHE_FILE = 'pagelink_cache.json';
/** @var string */
protected $pathConfig;
/** @var int */
protected $maxDepth;
/** @var array|null */
protected $cache = null;
/** @var bool */
protected $cacheDirty = false;
public function __construct(string $pathConfig, int $maxDepth)
{
$this->pathConfig = $pathConfig;
$this->maxDepth = max(0, $maxDepth);
}
/**
* Generate a v4 UUID (lowercase).
*/
public static function createUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
/**
* Normalize and validate a UUID v4 string.
*
* @param string $uuid
* @return string|null
*/
public static function normalizeUuid(string $uuid): ?string
{
$uuid = strtolower(trim($uuid));
if ($uuid === '') return null;
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) {
return null;
}
return $uuid;
}
/**
* Get the page's pagelink UUID from metadata (if valid).
*/
public function getPageUuid(string $pageId): ?string
{
if ($pageId === '') return null;
if (!function_exists('p_get_metadata')) return null;
$value = p_get_metadata($pageId, self::META_KEY, METADATA_DONT_RENDER);
if (!is_string($value)) return null;
return self::normalizeUuid($value);
}
/**
* Persist a pagelink UUID in page metadata.
*/
public function setPageUuid(string $pageId, string $uuid): bool
{
if ($pageId === '') return false;
if (!function_exists('p_set_metadata')) return false;
$uuid = self::normalizeUuid($uuid);
if ($uuid === null) return false;
return (bool)p_set_metadata($pageId, [self::META_KEY => $uuid]);
}
/**
* Remove the pagelink UUID from page metadata.
*/
public function removePageUuid(string $pageId): bool
{
if ($pageId === '') return false;
if (!function_exists('p_set_metadata')) return false;
return (bool)p_set_metadata($pageId, [self::META_KEY => '']);
}
/**
* Unlink a page: remove UUID, delete linked .pagelink file if present, and clear cache.
*
* @param string $pageId
* @return array{ok: bool, uuid: string|null, folder: string|null}
*/
public function unlinkPage(string $pageId): array
{
$uuid = $this->getPageUuid($pageId);
if ($uuid === null) {
return ['ok' => true, 'uuid' => null, 'folder' => null];
}
$linkInfo = $this->resolveUuid($uuid);
$folder = $linkInfo['folder'] ?? null;
if (is_string($folder) && $folder !== '') {
$file = rtrim($folder, '/\\') . '/.pagelink';
if (is_file($file) && !is_link($file)) {
@unlink($file);
}
}
$this->removeCacheEntry($uuid);
$this->removePageUuid($pageId);
return ['ok' => true, 'uuid' => $uuid, 'folder' => is_string($folder) ? $folder : null];
}
/**
* Resolve a pagelink UUID to a linked folder (if any).
*
* @param string $uuid
* @return array{folder: string|null, multiple: bool}
*/
public function resolveUuid(string $uuid): array
{
$uuid = self::normalizeUuid($uuid);
if ($uuid === null) {
return ['folder' => null, 'multiple' => false];
}
$cache = $this->loadCache();
if (isset($cache[$uuid]) && is_string($cache[$uuid]) && $cache[$uuid] !== '') {
$cachedPath = $cache[$uuid];
if ($this->isValidLink($cachedPath, $uuid)) {
return ['folder' => $cachedPath, 'multiple' => false];
}
unset($cache[$uuid]);
$this->cacheDirty = true;
}
$matches = $this->scanRootsForUuid($uuid, 2);
if ($matches !== []) {
$cache[$uuid] = $matches[0];
$this->cacheDirty = true;
}
if ($this->cacheDirty) {
$this->writeCache($cache);
}
return [
'folder' => $matches[0] ?? null,
'multiple' => count($matches) > 1,
];
}
/**
* Read the cache file into memory.
*
* @return array
*/
protected function loadCache(): array
{
if ($this->cache !== null) return $this->cache;
$this->cache = [];
$file = $this->getCacheFile();
if (!is_file($file) || !is_readable($file)) return $this->cache;
$raw = @file_get_contents($file);
if (!is_string($raw) || $raw === '') return $this->cache;
$decoded = json_decode($raw, true);
if (!is_array($decoded)) return $this->cache;
$this->cache = $decoded;
return $this->cache;
}
/**
* Write cache to disk atomically.
*/
protected function writeCache(array $cache): void
{
$file = $this->getCacheFile();
$dir = dirname($file);
if (function_exists('io_mkdir_p')) {
io_mkdir_p($dir);
} elseif (!@is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$tmp = $file . '.tmp.' . getmypid();
$data = json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($data === false) return;
@file_put_contents($tmp, $data, LOCK_EX);
if (!@rename($tmp, $file)) {
@copy($tmp, $file);
@unlink($tmp);
}
$this->cache = $cache;
$this->cacheDirty = false;
}
/**
* Remove a specific UUID from cache.
*/
public function removeCacheEntry(string $uuid): void
{
$uuid = self::normalizeUuid($uuid);
if ($uuid === null) return;
$cache = $this->loadCache();
if (!isset($cache[$uuid])) return;
unset($cache[$uuid]);
$this->writeCache($cache);
}
/**
* Get cache file path for pagelink mappings.
*/
protected function getCacheFile(): string
{
global $conf;
$cacheDir = rtrim((string)$conf['cachedir'], '/');
return $cacheDir . '/luxtools/' . self::CACHE_FILE;
}
/**
* Check if the cached path still points to a valid .pagelink file.
*/
protected function isValidLink(string $folder, string $uuid): bool
{
if ($folder === '') return false;
if (!is_dir($folder)) return false;
if (is_link($folder)) return false;
$file = rtrim($folder, '/\\') . '/.pagelink';
if (!is_file($file) || is_link($file) || !is_readable($file)) return false;
$content = @file_get_contents($file);
if (!is_string($content)) return false;
$content = self::normalizeUuid($content);
return $content !== null && $content === $uuid;
}
/**
* Scan configured roots for matching .pagelink files.
*
* @param string $uuid
* @param int $limit Maximum number of matches to collect.
* @return string[]
*/
protected function scanRootsForUuid(string $uuid, int $limit = 2): array
{
$roots = $this->getConfiguredRoots();
if ($roots === []) return [];
$matches = [];
foreach ($roots as $root) {
$this->scanDirectory($root, 0, $uuid, $limit, $matches);
if (count($matches) >= $limit) break;
}
return $matches;
}
/**
* Recursively scan a directory for .pagelink files.
*
* @param string $dir
* @param int $depth
* @param string $uuid
* @param int $limit
* @param array $matches
*/
protected function scanDirectory(string $dir, int $depth, string $uuid, int $limit, array &$matches): void
{
if ($dir === '' || count($matches) >= $limit) return;
if (!is_dir($dir) || is_link($dir)) return;
if (!is_readable($dir)) return;
if ($depth > $this->maxDepth) return;
$file = rtrim($dir, '/\\') . '/.pagelink';
if (is_file($file) && !is_link($file) && is_readable($file)) {
$content = @file_get_contents($file);
if (is_string($content)) {
$content = self::normalizeUuid($content);
if ($content !== null && $content === $uuid) {
$matches[] = rtrim($dir, '/\\');
if (count($matches) >= $limit) return;
}
}
}
if ($depth >= $this->maxDepth) return;
$handle = @opendir($dir);
if ($handle === false) return;
$base = rtrim($dir, '/\\');
while (($entry = readdir($handle)) !== false) {
if ($entry === '.' || $entry === '..') continue;
if ($entry === '.pagelink') continue;
$path = $base . '/' . $entry;
if (!is_dir($path) || is_link($path)) continue;
$this->scanDirectory($path, $depth + 1, $uuid, $limit, $matches);
if (count($matches) >= $limit) break;
}
closedir($handle);
}
/**
* Resolve configured root paths (excluding aliases).
*
* @return string[]
*/
protected function getConfiguredRoots(): array
{
$pathConfig = trim($this->pathConfig);
if ($pathConfig === '') return [];
$helper = new Path($pathConfig);
$paths = $helper->getPaths();
$roots = [];
foreach ($paths as $key => $info) {
if (!isset($info['root']) || $key !== $info['root']) continue;
$roots[] = $info['root'];
}
return $roots;
}
}

159
src/PageLinkTrait.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Trait for pagelink-related functionality shared across syntax handlers.
*
* Provides methods for:
* - Detecting blobs alias paths
* - Resolving the blobs root folder from page metadata
* - Rendering "page not linked" messages
* - Building path configs with blobs alias support
*
* Requirements for using classes:
* - Must have getConf() method (from SyntaxPlugin)
* - Must have getLang() method (from SyntaxPlugin)
*/
trait PageLinkTrait
{
/**
* Check if the given path uses the blobs alias.
*
* @param string $path
* @return bool
*/
protected function isBlobsPath(string $path): bool
{
$trimmed = ltrim($path, '/');
return preg_match('/^blobs(\/|$)/', $trimmed) === 1;
}
/**
* Render the "Page not linked" message with copy ID affordance.
*
* @param \Doku_Renderer $renderer
*/
protected function renderPageNotLinked(\Doku_Renderer $renderer): void
{
$text = (string)$this->getLang('pagelink_unlinked');
if ($renderer instanceof \Doku_Renderer_xhtml) {
$renderer->doc .= '<span class="luxtools-pagelink-status">' . hsc($text) . '</span>';
return;
}
$renderer->cdata('[n/a: ' . $text . ']');
}
/**
* Read the current page UUID (if any).
*
* @return string The UUID or empty string
*/
protected function getPageUuidSafe(): string
{
global $ID;
$pageId = is_string($ID) ? $ID : '';
if ($pageId === '') return '';
if (function_exists('cleanID')) {
$pageId = (string)cleanID($pageId);
}
if ($pageId === '') return '';
$depth = (int)$this->getConf('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
$uuid = $pageLink->getPageUuid($pageId);
return $uuid ?? '';
}
/**
* Resolve the current page's pagelink folder for the blobs alias.
*
* Results are cached per page ID within a single request.
*
* @return string The linked folder path or empty string if not linked
*/
protected function resolveBlobsRoot(): string
{
static $cached = [];
global $ID;
$pageId = is_string($ID) ? $ID : '';
if ($pageId === '') return '';
if (function_exists('cleanID')) {
$pageId = (string)cleanID($pageId);
}
if ($pageId === '') return '';
if (isset($cached[$pageId])) {
return (string)$cached[$pageId];
}
$depth = (int)$this->getConf('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$pageLink = new PageLink((string)$this->getConf('paths'), $depth);
$uuid = $pageLink->getPageUuid($pageId);
if ($uuid === null) {
$cached[$pageId] = '';
return '';
}
$linkInfo = $pageLink->resolveUuid($uuid);
$folder = $linkInfo['folder'] ?? '';
if (!is_string($folder) || $folder === '') {
$cached[$pageId] = '';
return '';
}
$cached[$pageId] = $folder;
return $folder;
}
/**
* Build a path config string with the blobs alias appended (if available).
*
* @param string|null $blobsRoot The blobs root folder (or null to auto-resolve)
* @return string The path config string
*/
protected function buildPathConfigWithBlobs(?string $blobsRoot = null): string
{
$pathConfig = (string)$this->getConf('paths');
if ($blobsRoot === null) {
$blobsRoot = $this->resolveBlobsRoot();
}
if ($blobsRoot !== '') {
$pathConfig = rtrim($pathConfig) . "\n" . $blobsRoot . "\nA> blobs";
}
return $pathConfig;
}
/**
* Create a Path helper with blobs alias support.
*
* @param string|null $blobsRoot The blobs root folder (or null to auto-resolve)
* @return Path
*/
protected function createPathHelperWithBlobs(?string $blobsRoot = null): Path
{
return new Path($this->buildPathConfigWithBlobs($blobsRoot));
}
/**
* Create a Path helper using only the base paths config (no blobs alias).
*
* @return Path
*/
protected function createPathHelper(): Path
{
return new Path((string)$this->getConf('paths'));
}
}

View File

@@ -99,10 +99,10 @@ class Path
} }
/** /**
* Map a real filesystem path back to a configured alias, if available. * Map a real filesystem path back to an open-service alias, if available.
* *
* Example: root "/share/Datascape/" with alias "/Scape/" maps * Example: root "/share/Datascape/" with alias "/Scape/" maps
* "/share/Datascape/some/folder" -> "/Scape/some/folder". * "/share/Datascape/some/folder" -> "Scape>some/folder".
* *
* If no alias matches, the input path is returned unchanged (except for * If no alias matches, the input path is returned unchanged (except for
* normalization of slashes and dot-segments). * normalization of slashes and dot-segments).
@@ -117,12 +117,17 @@ class Path
// normalize input for matching, but do not force a trailing slash // normalize input for matching, but do not force a trailing slash
$normalized = static::cleanPath($path, false); $normalized = static::cleanPath($path, false);
// collect root->alias mappings (avoid alias keys that reference the same config) // collect root->alias mappings for open-service links
// (avoid alias keys that reference the same config)
$mappings = []; $mappings = [];
foreach ($this->paths as $key => $info) { foreach ($this->paths as $key => $info) {
if (!isset($info['root']) || $key !== $info['root']) continue; if (!isset($info['root']) || $key !== $info['root']) continue;
if (empty($info['alias'])) continue; if (empty($info['alias'])) continue;
$mappings[$info['root']] = $info['alias'];
$alias = $this->normalizeOpenAlias((string)$info['alias']);
if ($alias === '') continue;
$mappings[$info['root']] = $alias;
} }
if ($mappings === []) return $normalized; if ($mappings === []) return $normalized;
@@ -131,16 +136,34 @@ class Path
uksort($mappings, static fn($a, $b) => strlen($b) - strlen($a)); uksort($mappings, static fn($a, $b) => strlen($b) - strlen($a));
foreach ($mappings as $root => $alias) { foreach ($mappings as $root => $alias) {
$rootNoTrailingSlash = rtrim($root, '/');
if (!str_starts_with($normalized, $root) && $normalized !== $rootNoTrailingSlash) continue;
$suffix = '';
if (str_starts_with($normalized, $root)) { if (str_starts_with($normalized, $root)) {
$suffix = substr($normalized, strlen($root)); $suffix = (string)substr($normalized, strlen($root));
$alias = static::cleanPath($alias, true);
return rtrim($alias, '/') . '/' . $suffix;
} }
$suffix = ltrim($suffix, '/');
return $alias . '>' . $suffix;
} }
return $normalized; return $normalized;
} }
/**
* Convert legacy path-like aliases (e.g. /Scape/) to open aliases (Scape).
*
* @param string $alias
* @return string
*/
protected function normalizeOpenAlias($alias)
{
$alias = trim($alias);
$alias = trim($alias, '/\\');
return $alias;
}
/** /**
* Clean a path for better comparison * Clean a path for better comparison
* *

205
src/ThumbnailHelper.php Normal file
View File

@@ -0,0 +1,205 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Shared utility for thumbnail cache management and display logic.
*
* Used by both the image syntax and the gallery rendering to avoid code duplication.
*/
class ThumbnailHelper
{
/**
* Check if a string is a HTTP/HTTPS URL.
*
* @param string $url
* @return bool
*/
public static function isRemoteUrl(string $url): bool
{
if ($url === '') return false;
$parts = @parse_url($url);
if (!is_array($parts)) return false;
if (empty($parts['scheme']) || empty($parts['host'])) return false;
$scheme = strtolower((string)$parts['scheme']);
return in_array($scheme, ['http', 'https'], true);
}
/**
* Get thumbnail URL and metadata for rendering.
*
* This is the main entry point for getting thumbnails. The helper handles
* all URL construction, cache checking, and provides ready-to-use URLs.
*
* @param string $rootPath Root filesystem path (e.g., '/data/images/')
* @param string $localPath Local path relative to root (e.g., 'photo.jpg')
* @param string $pageId Page ID for ACL check
* @param int $width Desired width
* @param int $height Desired height
* @param int $quality JPEG quality (0-100)
* @param string|null $placeholderId Optional MediaManager ID for custom placeholder
* @return array [
* 'url' => string, // Always usable URL (thumbnail or placeholder)
* 'isFinal' => bool, // true if thumbnail ready, false if placeholder
* 'thumbUrl' => string // Real thumbnail URL (for lazy loading)
* ]
*/
public static function getThumbnail(
string $rootPath,
string $localPath,
string $pageId,
int $width,
int $height,
int $quality = 80,
?string $placeholderId = null
): array {
$fullPath = $rootPath . $localPath;
$thumbUrl = self::buildThumbnailUrl($rootPath, $localPath, $pageId, $width, $height, $quality);
// Check if cached
$cachePath = self::getCachePath($fullPath, $width, $height, $quality);
$isCached = $cachePath !== null && @is_file($cachePath);
if ($isCached) {
return [
'url' => $thumbUrl,
'isFinal' => true,
'thumbUrl' => $thumbUrl,
];
}
// Not cached: return placeholder
return [
'url' => self::getPlaceholderUrl($width, $height, $placeholderId),
'isFinal' => false,
'thumbUrl' => $thumbUrl,
];
}
/**
* Build the file.php URL for a thumbnail.
*
* @param string $rootPath Root filesystem path
* @param string $localPath Local path relative to root
* @param string $pageId Page ID for ACL check
* @param int $width Width
* @param int $height Height
* @param int $quality JPEG quality
* @return string Complete URL to file.php with thumbnail parameters
*/
protected static function buildThumbnailUrl(
string $rootPath,
string $localPath,
string $pageId,
int $width,
int $height,
int $quality
): string {
$params = [
'root' => $rootPath,
'file' => $localPath,
'id' => $pageId,
'thumb' => 1,
'w' => $width,
'h' => $height,
'q' => $quality,
];
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
}
/**
* Compute the expected thumbnail cache path.
*
* Mirrors the hashing scheme in file.php so we can detect whether a thumb
* already exists and can be used immediately.
*
* @param string $path Full filesystem path to the image
* @param int $w Width
* @param int $h Height
* @param int $q Quality (JPEG)
* @return string|null Path to cached thumbnail, or null if unavailable
*/
public static function getCachePath(string $path, int $w, int $h, int $q = 80): ?string
{
if ($path === '' || !is_file($path)) return null;
$mtime = @filemtime($path);
if ($mtime === false) return null;
// Decide output format the same way file.php does
try {
[, $mime,] = mimetype($path, false);
} catch (\Throwable $e) {
return null;
}
if (!is_string($mime) || !str_starts_with($mime, 'image/')) return null;
$dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg';
global $conf;
if (!isset($conf['cachedir']) || !is_string($conf['cachedir']) || trim($conf['cachedir']) === '') return null;
$hash = sha1($path . '|' . $mtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat);
$sub = substr($hash, 0, 2);
$cacheDir = rtrim($conf['cachedir'], '/');
return $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat;
}
/**
* Get placeholder image URL.
*
* @param int $width Desired width
* @param int $height Desired height
* @param string|null $placeholderId Optional MediaManager ID for custom placeholder
* @return string Placeholder URL
*/
public static function getPlaceholderUrl(int $width, int $height, ?string $placeholderId = null): string
{
$placeholderUrl = DOKU_BASE . 'lib/images/blank.gif';
if ($placeholderId !== null && $placeholderId !== '' && function_exists('ml')) {
$placeholderUrl = ml($placeholderId, ['w' => $width, 'h' => $height], true, '&');
}
return $placeholderUrl;
}
/**
* Determine the initial image source and data attributes for lazy thumbnail loading.
*
* Returns placeholder info if thumbnail needs to be loaded, or the actual
* thumbnail URL if it's already cached.
*
* @param string $imagePath Full filesystem path to the image
* @param string $thumbUrl URL to the thumbnail
* @param int $width Width
* @param int $height Height
* @param int $quality Quality (JPEG)
* @param string|null $placeholderId Optional MediaManager ID for custom placeholder
* @return array ['src' => string, 'dataThumbAttr' => string]
*/
public static function getDisplayInfo(
string $imagePath,
string $thumbUrl,
int $width,
int $height,
int $quality = 80,
?string $placeholderId = null
): array {
$thumbCachePath = self::getCachePath($imagePath, $width, $height, $quality);
if ($thumbCachePath !== null && @is_file($thumbCachePath)) {
// Thumbnail exists: display it immediately
return [
'src' => $thumbUrl,
'dataThumbAttr' => '',
];
}
// Thumbnail doesn't exist: show placeholder and lazy-load
$placeholderUrl = self::getPlaceholderUrl($width, $height, $placeholderId);
return [
'src' => $placeholderUrl,
'dataThumbAttr' => ' data-thumb-src="' . hsc($thumbUrl) . '"',
];
}
}

229
style.css
View File

@@ -34,7 +34,7 @@ div.luxtools-plugin a.luxtools-open,
div.luxtools-plugin a.luxtools-open:visited, div.luxtools-plugin a.luxtools-open:visited,
a.luxtools-open, a.luxtools-open,
a.luxtools-open:visited { a.luxtools-open:visited {
color: #b57d35 !important; color: @ini_luxtools_locationlink !important;
} }
/* Standalone {{open>...}} links are not wrapped in div.luxtools-plugin. */ /* Standalone {{open>...}} links are not wrapped in div.luxtools-plugin. */
@@ -49,6 +49,19 @@ div.luxtools-plugin .luxtools-empty {
padding: 0.25em 0; padding: 0.25em 0;
} }
/* Page link status (unlinked blobs alias) */
span.luxtools-pagelink-status {
display: inline-block;
font-size: 0.85em;
line-height: 1.3;
margin: 0.25em 0;
padding: 0.15em 0.4em;
border: 1px solid @ini_border;
border-radius: 0.2em;
background-color: @ini_background_alt;
color: inherit;
}
/* Image gallery spacing. */ /* Image gallery spacing. */
div.luxtools-gallery { div.luxtools-gallery {
padding-bottom: 0.5em; padding-bottom: 0.5em;
@@ -77,6 +90,16 @@ div.luxtools-gallery img.luxtools-thumb {
width: 150px; width: 150px;
height: 150px; height: 150px;
object-fit: cover; object-fit: cover;
/* Placeholder while lazy-loaded image is pending.
* Uses custom property from inline style if thumb_placeholder is configured,
* otherwise falls back to built-in SVG icon. */
background-color: @ini_background;
background-image: var(--luxtools-placeholder, url(images/placeholder.svg));
background-position: center;
background-repeat: no-repeat;
/* contain works well for both: configured placeholder fills the area,
* built-in SVG icon stays small and centered */
background-size: contain;
} }
/* Filename overlay (single line, muted). */ /* Filename overlay (single line, muted). */
@@ -152,12 +175,18 @@ div.luxtools-scratchpad.is-editing .luxtools-scratchpad-bar {
div.luxtools-scratchpad .luxtools-scratchpad-editor { div.luxtools-scratchpad .luxtools-scratchpad-editor {
margin-top: 0.4em; margin-top: 0.4em;
width: 100%;
max-width: 100%;
} }
div.luxtools-scratchpad textarea.luxtools-scratchpad-text { div.luxtools-scratchpad textarea.luxtools-scratchpad-text {
width: 100%; width: 100%;
max-width: 100%;
min-width: 100%;
display: block;
box-sizing: border-box; box-sizing: border-box;
min-height: 8em; min-height: 8em;
resize: vertical;
} }
div.luxtools-scratchpad .luxtools-scratchpad-actions { div.luxtools-scratchpad .luxtools-scratchpad-actions {
@@ -392,3 +421,201 @@ html.luxtools-noscroll body {
font-size: 2.4em; font-size: 2.4em;
} }
} }
/* ========================================================================
* Grouping wrapper (compact image layout container)
* ======================================================================== */
.luxtools-grouping {
display: grid;
grid-template-columns: repeat(var(--luxtools-grouping-cols, 2), minmax(0, 1fr));
gap: var(--luxtools-grouping-gap, 0);
justify-content: var(--luxtools-grouping-justify, start);
align-items: var(--luxtools-grouping-align, start);
}
.luxtools-grouping.luxtools-grouping--flex {
display: flex;
flex-wrap: wrap;
gap: var(--luxtools-grouping-gap, 0);
}
/* Let the grouping layout fully control item placement. */
.luxtools-grouping .luxtools-imagebox {
float: none;
clear: none;
margin: 0;
}
/* ========================================================================
* Imagebox (Wikipedia-style image with caption)
* ======================================================================== */
.luxtools-imagebox {
margin-bottom: 0.5em;
display: block;
}
.luxtools-imagebox.tleft {
clear: left;
float: left;
margin-right: 1.4em;
}
.luxtools-imagebox.tright {
clear: right;
float: right;
margin-left: 1.4em;
}
.luxtools-imagebox.tcenter {
clear: both;
text-align: center;
margin-left: auto;
margin-right: auto;
}
.luxtools-imagebox.tcenter .luxtools-imagebox-inner {
display: inline-block;
text-align: left;
}
.luxtools-imagebox .luxtools-imagebox-inner {
display: inline-block;
background-color: @ini_background_alt;
border: 1px solid @ini_border;
padding: 3px;
font-size: 94%;
overflow: hidden;
}
.luxtools-imagebox .luxtools-imagebox-inner > a {
display: block;
line-height: 0;
background-color: @ini_background;
}
.luxtools-imagebox .luxtools-imagebox-inner img {
display: block;
max-width: 100%;
height: auto;
}
.luxtools-imagebox .luxtools-imagebox-caption {
border: none;
font-size: 94%;
line-height: 1.4em;
padding: 3px;
text-align: left;
}
/* ========================================================================
* Calendar widget
* ======================================================================== */
div.luxtools-calendar {
width: 100%;
max-width: 100%;
font-size: 88%;
}
div.luxtools-calendar .luxtools-calendar-title {
font-weight: bold;
margin-bottom: 0.25em;
font-size: 95%;
text-align: center;
}
div.luxtools-calendar .luxtools-calendar-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-bottom: 0.25em;
}
div.luxtools-calendar .luxtools-calendar-nav-prev {
text-align: left;
}
div.luxtools-calendar .luxtools-calendar-nav-next {
text-align: right;
}
div.luxtools-calendar .luxtools-calendar-nav-prev a,
div.luxtools-calendar .luxtools-calendar-nav-next a {
text-decoration: none;
}
div.luxtools-calendar .luxtools-calendar-nav-button {
border: 1px solid @ini_border;
background-color: @ini_background_alt;
color: @ini_text;
font: inherit;
line-height: 1;
padding: 0.2em 0.45em;
cursor: pointer;
}
div.luxtools-calendar .luxtools-calendar-nav-button:hover,
div.luxtools-calendar .luxtools-calendar-nav-button:focus {
background-color: @ini_highlight;
outline: none;
}
div.luxtools-calendar table.luxtools-calendar-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
div.luxtools-calendar table.luxtools-calendar-table th,
div.luxtools-calendar table.luxtools-calendar-table td {
border: 1px solid @ini_border;
padding: 0;
text-align: center;
vertical-align: middle;
}
div.luxtools-calendar table.luxtools-calendar-table th {
background-color: @ini_background_alt;
font-size: 85%;
font-weight: normal;
}
div.luxtools-calendar td.luxtools-calendar-day-empty {
background-color: @ini_background_alt;
}
div.luxtools-calendar td.luxtools-calendar-day-today {
background-color: @ini_highlight;
}
div.luxtools-calendar td.luxtools-calendar-day a {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 1.9em;
background: transparent;
text-decoration: none;
border-bottom: 0;
box-shadow: none;
padding: 0.1em 0;
}
div.luxtools-calendar td.luxtools-calendar-day a:hover,
div.luxtools-calendar td.luxtools-calendar-day a:focus,
div.luxtools-calendar td.luxtools-calendar-day a:active,
div.luxtools-calendar td.luxtools-calendar-day a:visited {
text-decoration: none;
border-bottom: 0;
box-shadow: none;
}
div.luxtools-calendar td.luxtools-calendar-day:hover {
background-color: @ini_background_alt;
}
div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
background-color: @ini_highlight;
}

View File

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

View File

@@ -4,6 +4,7 @@ use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Crawler; use dokuwiki\plugin\luxtools\Crawler;
use dokuwiki\plugin\luxtools\Output; use dokuwiki\plugin\luxtools\Output;
use dokuwiki\plugin\luxtools\Path; use dokuwiki\plugin\luxtools\Path;
use dokuwiki\plugin\luxtools\PageLinkTrait;
require_once(__DIR__ . '/../autoload.php'); require_once(__DIR__ . '/../autoload.php');
@@ -15,6 +16,7 @@ require_once(__DIR__ . '/../autoload.php');
if (!class_exists('syntax_plugin_luxtools_abstract', false)) { if (!class_exists('syntax_plugin_luxtools_abstract', false)) {
abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
{ {
use PageLinkTrait;
/** /**
* Returns the syntax keyword (e.g., 'files', 'directory', 'images'). * Returns the syntax keyword (e.g., 'files', 'directory', 'images').
* Used for pattern matching and plugin registration. * Used for pattern matching and plugin registration.
@@ -82,13 +84,6 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
/** @inheritdoc */ /** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler) public function handle($match, $state, $pos, Doku_Handler $handler)
{ {
global $INPUT;
// Do not allow the syntax in discussion plugin comments
if (!$this->getConf('allow_in_comments') && $INPUT->has('comment')) {
return false;
}
$keyword = $this->getSyntaxKeyword(); $keyword = $this->getSyntaxKeyword();
$match = substr($match, strlen('{{' . $keyword . '>'), -2); $match = substr($match, strlen('{{' . $keyword . '>'), -2);
[$path, $flags] = array_pad(explode('&', $match, 2), 2, ''); [$path, $flags] = array_pad(explode('&', $match, 2), 2, '');
@@ -161,20 +156,24 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
*/ */
protected function parseFlags(string $flags): array protected function parseFlags(string $flags): array
{ {
// Parse default table columns setting.
// Format: comma-separated list of column names (name, size, date).
$tableColumns = strtolower(trim((string)$this->getConf('default_tablecolumns')));
$defaultShowSize = str_contains($tableColumns, 'size') ? 1 : (int)$this->getConf('default_showsize');
$defaultShowDate = str_contains($tableColumns, 'date') ? 1 : (int)$this->getConf('default_showdate');
// Base defaults shared by all handlers // Base defaults shared by all handlers
$baseDefaults = [ $baseDefaults = [
'sort' => (string)$this->getConf('default_sort'), 'sort' => (string)$this->getConf('default_sort'),
'order' => (string)$this->getConf('default_order'), 'order' => (string)$this->getConf('default_order'),
'style' => (string)$this->getConf('default_style'),
'tableheader' => (int)$this->getConf('default_tableheader'), 'tableheader' => (int)$this->getConf('default_tableheader'),
'foldersfirst' => (int)$this->getConf('default_foldersfirst'), 'foldersfirst' => (int)$this->getConf('default_foldersfirst'),
'recursive' => (int)$this->getConf('default_recursive'), 'recursive' => (int)$this->getConf('default_recursive'),
'titlefile' => (string)$this->getConf('default_titlefile'), 'titlefile' => (string)$this->getConf('default_titlefile'),
'cache' => (int)$this->getConf('default_cache'), 'cache' => (int)$this->getConf('default_cache'),
'randlinks' => (int)$this->getConf('default_randlinks'), 'randlinks' => (int)$this->getConf('default_randlinks'),
'showsize' => (int)$this->getConf('default_showsize'), 'showsize' => $defaultShowSize,
'showdate' => (int)$this->getConf('default_showdate'), 'showdate' => $defaultShowDate,
'listsep' => (string)$this->getConf('default_listsep'),
'maxheight' => (int)$this->getConf('default_maxheight'), 'maxheight' => (int)$this->getConf('default_maxheight'),
]; ];
@@ -211,7 +210,12 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer) protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer)
{ {
try { try {
$pathHelper = new Path($this->getConf('paths')); $blobsRoot = $this->resolveBlobsRoot();
if ($blobsRoot === '' && $this->isBlobsPath($basePath)) {
$this->renderPageNotLinked($renderer);
return false;
}
$pathHelper = $this->createPathHelperWithBlobs($blobsRoot);
return $pathHelper->getPathInfo($basePath); return $pathHelper->getPathInfo($basePath);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->renderError($renderer, 'error_outsidejail'); $this->renderError($renderer, 'error_outsidejail');

147
syntax/calendar.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\ChronologicalCalendarWidget;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Calendar widget syntax.
*
* Syntax:
* - {{calendar>}} current month
* - {{calendar>YYYY-MM}} specific month
* - {{calendar>YYYY-MM&base=chronological}} custom base namespace (optional)
*/
class syntax_plugin_luxtools_calendar extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 224;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern('\{\{calendar>.*?\}\}', $mode, 'plugin_luxtools_calendar');
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
$match = substr($match, strlen('{{calendar>'), -2);
[$target, $flags] = array_pad(explode('&', $match, 2), 2, '');
$target = trim((string)$target);
$params = $this->parseFlags($flags);
$baseNs = $params['base'] ?? 'chronological';
$resolved = $this->resolveTargetMonth($target);
if ($resolved === null) {
return [
'ok' => false,
'error' => 'calendar_err_badmonth',
];
}
return [
'ok' => true,
'year' => $resolved['year'],
'month' => $resolved['month'],
'base' => $baseNs,
];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
if ($data === false || !is_array($data)) return false;
if ($format !== 'xhtml') return false;
if (!($renderer instanceof Doku_Renderer_xhtml)) return false;
if (!($data['ok'] ?? false)) {
$message = (string)$this->getLang((string)($data['error'] ?? 'calendar_err_badmonth'));
if ($message === '') $message = 'Invalid calendar month. Use YYYY-MM.';
$renderer->doc .= '<div class="luxtools-plugin luxtools-calendar"><div class="luxtools-empty">' . hsc($message) . '</div></div>';
return true;
}
$year = (int)$data['year'];
$month = (int)$data['month'];
$baseNs = (string)$data['base'];
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs);
return true;
}
/**
* @param string $flags
* @return array<string,string>
*/
protected function parseFlags(string $flags): array
{
$params = [];
foreach (explode('&', $flags) as $flag) {
if (trim($flag) === '') continue;
[$name, $value] = array_pad(explode('=', $flag, 2), 2, '');
$name = strtolower(trim($name));
$value = trim($value);
if ($name === '') continue;
$params[$name] = $value;
}
if (!isset($params['base']) || trim($params['base']) === '') {
$params['base'] = 'chronological';
}
return $params;
}
/**
* Resolve target string to year/month.
*
* Accepted formats:
* - '' (current month)
* - YYYY-MM
*
* @param string $target
* @return array{year:int,month:int}|null
*/
protected function resolveTargetMonth(string $target): ?array
{
if ($target === '') {
return [
'year' => (int)date('Y'),
'month' => (int)date('m'),
];
}
if (!preg_match('/^(\d{4})-(\d{2})$/', $target, $matches)) {
return null;
}
$year = (int)$matches[1];
$month = (int)$matches[2];
if ($year < 1) return null;
if ($month < 1 || $month > 12) return null;
return [
'year' => $year,
'month' => $month,
];
}
}

View File

@@ -10,6 +10,7 @@ require_once(__DIR__ . '/AbstractSyntax.php');
* *
* Lists the direct children (folders and files) of a given path. * Lists the direct children (folders and files) of a given path.
* Always renders as a table. * Always renders as a table.
* Also accepts the 'files' keyword for backwards compatibility with glob patterns.
*/ */
class syntax_plugin_luxtools_directory extends syntax_plugin_luxtools_abstract class syntax_plugin_luxtools_directory extends syntax_plugin_luxtools_abstract
{ {
@@ -28,17 +29,81 @@ class syntax_plugin_luxtools_directory extends syntax_plugin_luxtools_abstract
return 'directory'; return 'directory';
} }
/** @inheritdoc */
public function connectTo($mode)
{
// Accept both {{directory>...}} and {{files>...}} for backwards compatibility
$this->Lexer->addSpecialPattern('\{\{directory>.+?\}\}', $mode, 'plugin_luxtools_directory');
$this->Lexer->addSpecialPattern('\{\{files>.+?\}\}', $mode, 'plugin_luxtools_directory');
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
// Detect which keyword was used
$keyword = 'directory';
if (str_starts_with($match, '{{files>')) {
$keyword = 'files';
}
$match = substr($match, strlen('{{' . $keyword . '>'), -2);
[$path, $flags] = array_pad(explode('&', $match, 2), 2, '');
$params = $this->parseFlags($flags);
$pathData = $this->processPath($path);
// Store the original keyword to determine processing mode
$pathData['isGlobPattern'] = ($keyword === 'files');
return ['pathData' => $pathData, 'params' => $params];
}
/** @inheritdoc */ /** @inheritdoc */
protected function processPath(string $path): array protected function processPath(string $path): array
{ {
// Directory path (no glob/pattern) // Check if path contains glob characters (*, ?, [, ])
$hasGlob = (str_contains($path, '*') || str_contains($path, '?') ||
str_contains($path, '[') || str_contains($path, ']'));
if ($hasGlob) {
// Process as glob pattern (old files syntax)
[$base, $pattern] = $this->separatePathAndPattern($path);
return ['base' => $base, 'pattern' => $pattern, 'isGlobPattern' => true];
} else {
// Process as directory path
$path = Path::cleanPath($path, true); $path = Path::cleanPath($path, true);
return ['path' => $path]; return ['path' => $path, 'isGlobPattern' => false];
}
} }
/** @inheritdoc */ /** @inheritdoc */
protected function doRender(string $format, \Doku_Renderer $renderer, array $pathData, array $params): bool protected function doRender(string $format, \Doku_Renderer $renderer, array $pathData, array $params): bool
{ {
$isGlobPattern = $pathData['isGlobPattern'] ?? false;
if ($isGlobPattern && isset($pathData['base'], $pathData['pattern'])) {
// Old files syntax behavior: crawl with glob pattern
$pathInfo = $this->getPathInfoSafe($pathData['base'], $renderer);
if ($pathInfo === false) {
return true;
}
$crawler = $this->createCrawler($params);
$result = $crawler->crawl(
$pathInfo['root'],
$pathInfo['local'],
$pathData['pattern'],
$params['recursive'],
$params['titlefile']
);
// Pass the base directory as openlocation so the "Open Location" link is displayed.
$params['openlocation'] = $pathInfo['root'] . $pathInfo['local'];
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $result, $this);
$output->renderAsTable($params);
} else {
// Normal directory listing behavior
$pathInfo = $this->getPathInfoSafe($pathData['path'], $renderer); $pathInfo = $this->getPathInfoSafe($pathData['path'], $renderer);
if ($pathInfo === false) { if ($pathInfo === false) {
return true; return true;
@@ -54,16 +119,11 @@ class syntax_plugin_luxtools_directory extends syntax_plugin_luxtools_abstract
$params['titlefile'] $params['titlefile']
); );
if ($items == []) { // Render the table even if empty so the "Open Location" link is displayed.
$this->renderEmptyState($renderer, 'empty_files'); $output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items, $this);
return true; $output->renderAsFlatTable($params);
} }
// Always render as table style
$params['style'] = 'table';
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items);
$output->renderAsFlatTable($params);
return true; return true;
} }
} }

View File

@@ -1,62 +0,0 @@
<?php
use dokuwiki\plugin\luxtools\Output;
require_once(__DIR__ . '/AbstractSyntax.php');
/**
* luxtools Plugin: Files syntax.
*
* Lists files matching a given glob pattern.
*/
class syntax_plugin_luxtools_files extends syntax_plugin_luxtools_abstract
{
/** @inheritdoc */
protected function getSyntaxKeyword(): string
{
return 'files';
}
/** @inheritdoc */
protected function processPath(string $path): array
{
[$base, $pattern] = $this->separatePathAndPattern($path);
return ['base' => $base, 'pattern' => $pattern];
}
/** @inheritdoc */
protected function doRender(string $format, \Doku_Renderer $renderer, array $pathData, array $params): bool
{
$pathInfo = $this->getPathInfoSafe($pathData['base'], $renderer);
if ($pathInfo === false) {
return true;
}
$crawler = $this->createCrawler($params);
$result = $crawler->crawl(
$pathInfo['root'],
$pathInfo['local'],
$pathData['pattern'],
$params['recursive'],
$params['titlefile']
);
if ($result == []) {
$this->renderEmptyState($renderer, 'empty_files');
return true;
}
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $result);
switch ($params['style']) {
case 'list':
case 'olist':
$output->renderAsList($params);
break;
case 'table':
$output->renderAsTable($params);
break;
}
return true;
}
}

282
syntax/grouping.php Normal file
View File

@@ -0,0 +1,282 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Grouping wrapper syntax.
*
* Wraps multiple blocks (typically {{image>...}}) and applies compact layout
* without adding visual box styling of its own.
*
* Syntax:
* <grouping layout="flex" gap="0" justify="start" align="start">
* {{image>...}}
* {{image>...}}
* </grouping>
*/
class syntax_plugin_luxtools_grouping extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'container';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
// Slightly after image syntax
return 316;
}
/** @inheritdoc */
public function getAllowedTypes()
{
return ['container', 'substition', 'protected', 'disabled', 'formatting'];
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addEntryPattern('<grouping(?:\s+[^>]*)?>(?=.*</grouping>)', $mode, 'plugin_luxtools_grouping');
}
/** @inheritdoc */
public function postConnect()
{
$this->Lexer->addExitPattern('</grouping>', 'plugin_luxtools_grouping');
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
if ($state === DOKU_LEXER_ENTER) {
$parsed = $this->parseOpeningTag($match);
return [
'state' => $state,
'params' => $parsed['params'],
'unknown' => $parsed['unknown'],
];
}
if ($state === DOKU_LEXER_UNMATCHED) {
return [
'state' => $state,
'text' => $match,
];
}
return [
'state' => $state,
];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
if (!is_array($data) || !isset($data['state'])) {
return false;
}
$state = (int)$data['state'];
if ($format !== 'xhtml') {
if ($state === DOKU_LEXER_UNMATCHED && isset($data['text'])) {
$renderer->cdata((string)$data['text']);
}
return true;
}
if (!($renderer instanceof Doku_Renderer_xhtml)) {
return true;
}
if ($state === DOKU_LEXER_ENTER) {
$params = isset($data['params']) && is_array($data['params']) ? $data['params'] : $this->getDefaultParams();
$unknown = isset($data['unknown']) && is_array($data['unknown']) ? $data['unknown'] : [];
$layout = ($params['layout'] === 'flex') ? 'flex' : 'grid';
$cols = (int)$params['cols'];
if ($cols < 1) {
$cols = 2;
}
$gap = (string)$params['gap'];
if (!$this->isValidCssLength($gap)) {
$gap = '0';
}
$justify = (string)$params['justify'];
if (!$this->isValidJustify($justify)) {
$justify = 'start';
}
$align = (string)$params['align'];
if (!$this->isValidAlign($align)) {
$align = 'start';
}
$renderer->doc .= '<div class="luxtools-grouping luxtools-grouping--' . hsc($layout) . '"'
. ' style="--luxtools-grouping-cols: ' . $cols
. '; --luxtools-grouping-gap: ' . hsc($gap)
. '; --luxtools-grouping-justify: ' . hsc($justify)
. '; --luxtools-grouping-align: ' . hsc($align)
. ';">';
if ($unknown !== []) {
$renderer->doc .= '<span class="luxtools-grouping-warning">'
. hsc('[grouping: unknown option(s): ' . implode(', ', $unknown) . ']')
. '</span>';
}
return true;
}
if ($state === DOKU_LEXER_UNMATCHED) {
if (isset($data['text'])) {
$renderer->cdata((string)$data['text']);
}
return true;
}
if ($state === DOKU_LEXER_EXIT) {
$renderer->doc .= '</div>';
return true;
}
return true;
}
/**
* Parse opening <grouping ...> tag attributes.
*
* Supports attribute style only, e.g.
* layout="grid" cols="3" gap="8px" justify="center" align="stretch".
* Unknown or invalid values are ignored and defaults are used.
*
* @param string $match
* @return array{params:array{layout:string,cols:int,gap:string,justify:string,align:string},unknown:array<int,string>}
*/
protected function parseOpeningTag(string $match): array
{
$params = $this->getDefaultParams();
$unknown = [];
if (!preg_match('/^<grouping\b(.*?)>$/is', $match, $tagMatch)) {
return ['params' => $params, 'unknown' => $unknown];
}
$attrPart = (string)$tagMatch[1];
if ($attrPart === '') {
return ['params' => $params, 'unknown' => $unknown];
}
if (preg_match_all('/([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*(["\'])(.*?)\2/s', $attrPart, $attrMatches, PREG_SET_ORDER)) {
foreach ($attrMatches as $item) {
$name = strtolower(trim((string)$item[1]));
$value = trim((string)$item[3]);
if ($name === 'layout') {
$value = strtolower($value);
if (in_array($value, ['grid', 'flex'], true)) {
$params['layout'] = $value;
}
continue;
}
if ($name === 'cols') {
if (preg_match('/^\d+$/', $value)) {
$cols = (int)$value;
if ($cols > 0) {
$params['cols'] = min($cols, 12);
}
}
continue;
}
if ($name === 'gap') {
if ($this->isValidCssLength($value)) {
$params['gap'] = $value;
}
continue;
}
if ($name === 'justify') {
$value = strtolower($value);
if ($this->isValidJustify($value)) {
$params['justify'] = $value;
}
continue;
}
if ($name === 'align') {
$value = strtolower($value);
if ($this->isValidAlign($value)) {
$params['align'] = $value;
}
continue;
}
$unknown[] = $name;
}
}
if ($unknown !== []) {
$unknown = array_values(array_unique($unknown));
}
return ['params' => $params, 'unknown' => $unknown];
}
/**
* @return array{layout:string,cols:int,gap:string,justify:string,align:string}
*/
protected function getDefaultParams(): array
{
return [
'layout' => 'flex',
'cols' => 2,
'gap' => '0',
'justify' => 'start',
'align' => 'start',
];
}
/**
* Validate a simple CSS length token.
*
* Allows "0" and common explicit units used in docs/examples.
*/
protected function isValidCssLength(string $value): bool
{
$value = trim($value);
if ($value === '0') {
return true;
}
return (bool)preg_match('/^(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|%|vw|vh)$/', $value);
}
/**
* Validate justify-content compatible values.
*/
protected function isValidJustify(string $value): bool
{
return in_array($value, ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], true);
}
/**
* Validate align-items compatible values.
*/
protected function isValidAlign(string $value): bool
{
return in_array($value, ['start', 'center', 'end', 'stretch', 'baseline'], true);
}
}

293
syntax/image.php Normal file
View File

@@ -0,0 +1,293 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Path;
use dokuwiki\plugin\luxtools\PageLinkTrait;
use dokuwiki\plugin\luxtools\ThumbnailHelper;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Image syntax.
*
* Renders a single image in an imagebox (similar to Wikipedia-style image boxes).
* Syntax: {{image>/path/to/image.jpg|Caption text}}
*
*/
class syntax_plugin_luxtools_image extends SyntaxPlugin
{
use PageLinkTrait;
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 315; // Same as imagebox plugin
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern('\{\{image>.+?\}\}', $mode, 'plugin_luxtools_image');
}
/** @inheritdoc */
public function handle($match, $state, $pos, \Doku_Handler $handler)
{
// Remove the leading {{image> and trailing }}
$match = substr($match, strlen('{{image>'), -2);
// Split by | into: path, caption, options
// Format: {{image>path|caption|options}}
$parts = explode('|', $match, 3);
$pathPart = trim($parts[0]);
$caption = isset($parts[1]) ? trim($parts[1]) : '';
$optionStr = isset($parts[2]) ? trim($parts[2]) : '';
// Parse options from third part (e.g., "200x150&right")
$width = null;
$height = null;
$align = null;
if ($optionStr !== '') {
$optionParts = explode('&', $optionStr);
foreach ($optionParts as $param) {
$param = trim($param);
if ($param === '') continue;
if (in_array($param, ['left', 'right', 'center'], true)) {
$align = $param;
} elseif (preg_match('/^(\d+)(?:x(\d+))?$/', $param, $m)) {
$width = (int)$m[1];
if (isset($m[2]) && $m[2] !== '') {
$height = (int)$m[2];
}
}
}
}
$isRemote = ThumbnailHelper::isRemoteUrl($pathPart);
$path = $isRemote ? $pathPart : Path::cleanPath($pathPart, false);
return [
'path' => $path,
'is_remote' => $isRemote,
'caption' => $caption,
'align' => $align,
'width' => $width,
'height' => $height,
];
}
/** @inheritdoc */
public function render($format, \Doku_Renderer $renderer, $data)
{
if ($data === false || !is_array($data)) {
return false;
}
if ($format !== 'xhtml') {
// For non-XHTML formats, render caption as text if available.
if (!empty($data['caption'])) {
$renderer->cdata('[Image: ' . $data['caption'] . ']');
}
return true;
}
// Apply default settings if not explicitly specified
if ($data['width'] === null) {
$data['width'] = (int)$this->getConf('default_image_width');
if ($data['width'] <= 0) $data['width'] = 250;
}
if ($data['align'] === null) {
$data['align'] = (string)$this->getConf('default_image_align');
if (!in_array($data['align'], ['left', 'right', 'center'], true)) {
$data['align'] = 'right';
}
}
if (!empty($data['is_remote'])) {
if (empty($data['path']) || !ThumbnailHelper::isRemoteUrl($data['path'])) {
$renderer->cdata('[n/a: Invalid URL]');
return true;
}
// Remote images: link directly, no proxying or thumbnailing
$thumb = [
'url' => $data['path'],
'isFinal' => true,
'thumbUrl' => $data['path'],
];
$this->renderImageBox($renderer, $thumb, $data['path'], $data);
return true;
}
try {
$blobsRoot = $this->resolveBlobsRoot();
if ($blobsRoot === '' && $this->isBlobsPath($data['path'] ?? '')) {
$this->renderPageNotLinked($renderer);
return true;
}
$pathHelper = $this->createPathHelperWithBlobs($blobsRoot);
// Use addTrailingSlash=false since this is a file path, not a directory
$pathInfo = $pathHelper->getPathInfo($data['path'], false);
} catch (\Exception $e) {
$renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']');
return true;
}
$fullPath = $pathInfo['root'] . $pathInfo['local'];
// Verify the file exists and is an image
if (!is_file($fullPath)) {
$renderer->cdata('[n/a: File not found]');
return true;
}
// Check if it's an image
try {
[, $mime,] = mimetype($fullPath, false);
} catch (\Throwable $e) {
$mime = null;
}
if (!is_string($mime) || !str_starts_with($mime, 'image/')) {
$renderer->cdata('[n/a: Not an image]');
return true;
}
// Get thumbnail from helper - it handles everything
global $ID;
$placeholderId = trim((string)$this->getConf('thumb_placeholder'));
$thumb = ThumbnailHelper::getThumbnail(
$pathInfo['root'],
$pathInfo['local'],
$ID,
$data['width'] ?? 250,
$data['height'] ?? ($data['width'] ?? 250),
80,
$placeholderId !== '' ? $placeholderId : null
);
// Build full-size URL for linking
$fullUrl = $this->buildImageUrl($pathInfo, null, null, false);
$this->renderImageBox($renderer, $thumb, $fullUrl, $data);
return true;
}
/**
* Build the file.php URL for a local image.
*
* @param array $pathInfo Path info array from Path helper
* @param int|null $width Optional width
* @param int|null $height Optional height
* @param bool $thumbnail Whether to generate a thumbnail
* @return string
*/
protected function buildImageUrl(array $pathInfo, ?int $width, ?int $height, bool $thumbnail): string
{
global $ID;
$params = [
'root' => $pathInfo['root'],
'file' => $pathInfo['local'],
'id' => $ID,
];
if ($thumbnail && ($width !== null || $height !== null)) {
$params['thumb'] = 1;
$params['q'] = 80;
}
if ($width !== null) {
$params['w'] = $width;
}
if ($height !== null) {
$params['h'] = $height;
}
return DOKU_BASE . 'lib/plugins/luxtools/file.php?' . http_build_query($params, '', '&');
}
/**
* Render the imagebox HTML.
*
* @param \Doku_Renderer $renderer
* @param array $thumb Thumbnail info from ThumbnailHelper::getThumbnail()
* @param string $fullUrl URL for the full-size image (on click)
* @param array $data Parsed data from handle()
*/
protected function renderImageBox(\Doku_Renderer $renderer, array $thumb, string $fullUrl, array $data): void
{
$align = $data['align'] ?? 'right';
$caption = $data['caption'] ?? '';
$width = $data['width'];
$height = $data['height'];
// Alignment class
$alignClass = 'tright'; // default
if ($align === 'left') {
$alignClass = 'tleft';
} elseif ($align === 'center') {
$alignClass = 'tcenter';
}
// Build width style for the outer container
$outerStyle = '';
if ($width !== null) {
// Add a few pixels for border/padding
$outerStyle = ' style="width: ' . ($width + 10) . 'px;"';
}
// Use thumbnail metadata from helper.
// JS loader expects data-src (same convention as gallery thumbnails).
$dataThumbAttr = $thumb['isFinal'] ? '' : ' data-src="' . hsc($thumb['thumbUrl']) . '"';
// Build image attributes
$imgAttrs = 'class="media luxtools-thumb" loading="lazy" decoding="async"';
if ($width !== null) {
$imgAttrs .= ' width="' . (int)$width . '"';
}
if ($height !== null) {
$imgAttrs .= ' height="' . (int)$height . '"';
}
$imgAttrs .= ' alt="' . hsc($caption) . '"';
/** @var \Doku_Renderer_xhtml $renderer */
$renderer->doc .= '<div class="luxtools-imagebox ' . $alignClass . '"' . $outerStyle . '>';
$renderer->doc .= '<div class="luxtools-imagebox-inner">';
// Image with link to full size
$renderer->doc .= '<a href="' . hsc($fullUrl) . '" class="media" target="_blank">';
$renderer->doc .= '<img src="' . hsc($thumb['url']) . '" ' . $imgAttrs . $dataThumbAttr . ' />';
$renderer->doc .= '</a>';
// Caption
if ($caption !== '') {
// Calculate max caption width
$captionStyle = '';
if ($width !== null) {
$captionStyle = ' style="max-width: ' . ($width - 6) . 'px;"';
}
$renderer->doc .= '<div class="luxtools-imagebox-caption"' . $captionStyle . '>';
$renderer->doc .= hsc($caption);
$renderer->doc .= '</div>';
}
$renderer->doc .= '</div>';
$renderer->doc .= '</div>';
}
}

View File

@@ -22,11 +22,9 @@ class syntax_plugin_luxtools_images extends syntax_plugin_luxtools_abstract
{ {
// Images syntax doesn't use some of the common params // Images syntax doesn't use some of the common params
return [ return [
'style' => null,
'tableheader' => null, 'tableheader' => null,
'showsize' => null, 'showsize' => null,
'showdate' => null, 'showdate' => null,
'listsep' => null,
]; ];
} }
@@ -62,19 +60,13 @@ class syntax_plugin_luxtools_images extends syntax_plugin_luxtools_abstract
return true; return true;
} }
$output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items); // Images syntax only supports XHTML format (gallery rendering)
if ($format !== 'xhtml') {
if ($format == 'xhtml') { return false;
$output->renderAsGallery($params);
return true;
} }
// Fallback for non-XHTML formats: render as a list of links $output = new Output($renderer, $pathInfo['root'], $pathInfo['web'], $items, $this);
$params['style'] = 'list'; $output->renderAsGallery($params);
$params['showsize'] = 0;
$params['showdate'] = 0;
$params['listsep'] = ', ';
$output->renderAsList($params);
return true; return true;
} }

View File

@@ -1,6 +1,10 @@
<?php <?php
use dokuwiki\Extension\SyntaxPlugin; use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\PageLinkTrait;
use dokuwiki\plugin\luxtools\Path;
require_once(__DIR__ . '/../autoload.php');
/** /**
* luxtools Plugin: Open local path syntax. * luxtools Plugin: Open local path syntax.
@@ -10,6 +14,7 @@ use dokuwiki\Extension\SyntaxPlugin;
*/ */
class syntax_plugin_luxtools_open extends SyntaxPlugin class syntax_plugin_luxtools_open extends SyntaxPlugin
{ {
use PageLinkTrait;
/** @inheritdoc */ /** @inheritdoc */
public function getType() public function getType()
{ {
@@ -73,6 +78,53 @@ class syntax_plugin_luxtools_open extends SyntaxPlugin
return true; return true;
} }
// Resolve blobs alias to the linked folder (if available)
if ($this->isBlobsPath($path)) {
$blobsRoot = $this->resolveBlobsRoot();
if ($blobsRoot === '') {
$this->renderPageNotLinked($renderer);
return true;
}
try {
$pathHelper = $this->createPathHelperWithBlobs($blobsRoot);
$resolvedPath = $path;
$isBlobsRoot = (rtrim($resolvedPath, '/') === 'blobs');
if ($isBlobsRoot) {
$resolvedPath = rtrim($resolvedPath, '/') . '/';
}
$pathInfo = $pathHelper->getPathInfo($resolvedPath, $isBlobsRoot);
$path = $pathInfo['path'];
} catch (\Exception $e) {
$renderer->cdata('[n/a: ' . $this->getLang('error_outsidejail') . ']');
return true;
}
}
// Map local paths back to their configured aliases before opening.
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)) {
try {
$pathHelper = $this->createPathHelper();
// If the input itself uses a configured path alias (legacy syntax
// like "alias/sub/path"), resolve it first so the emitted open
// path uses the new client-side alias format "ALIAS>relative".
$resolvedPath = $path;
try {
$pathInfo = $pathHelper->getPathInfo($path, false);
if (isset($pathInfo['path']) && is_string($pathInfo['path']) && $pathInfo['path'] !== '') {
$resolvedPath = $pathInfo['path'];
}
} catch (\Exception $e) {
// keep original path as-is when it is not in configured roots
}
$path = $pathHelper->mapToAliasPath($resolvedPath);
} catch (\Exception $e) {
// ignore mapping failures
}
}
$serviceUrl = trim((string)$this->getConf('open_service_url')); $serviceUrl = trim((string)$this->getConf('open_service_url'));
$serviceToken = trim((string)$this->getConf('open_service_token')); $serviceToken = trim((string)$this->getConf('open_service_token'));

View File

@@ -42,13 +42,6 @@ class syntax_plugin_luxtools_scratchpad extends SyntaxPlugin
/** @inheritdoc */ /** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler) public function handle($match, $state, $pos, Doku_Handler $handler)
{ {
global $INPUT;
// Do not allow the syntax in discussion plugin comments
if (!$this->getConf('allow_in_comments') && $INPUT->has('comment')) {
return false;
}
$match = substr($match, strlen('{{scratchpad>'), -2); $match = substr($match, strlen('{{scratchpad>'), -2);
[$path,] = array_pad(explode('&', $match, 2), 2, ''); [$path,] = array_pad(explode('&', $match, 2), 2, '');

22
temp-input-colors.css Normal file
View File

@@ -0,0 +1,22 @@
/* TEMPORARY FIX
* Apply template color placeholders to all form controls.
* Remove this file once the template provides proper input styling.
*/
input,
textarea,
select,
button {
color: @ini_text;
background-color: @ini_background;
border: 1px solid @ini_border;
}
button:not(.toolbutton) {
background: @ini_background;
}
select option {
color: @ini_text;
background-color: @ini_background;
}

22
vendor/autoload.php vendored Normal file
View File

@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc::getLoader();

119
vendor/bin/generate_vcards vendored Executable file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../sabre/vobject/bin/generate_vcards)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/sabre/vobject/bin/generate_vcards');
}
}
return include __DIR__ . '/..'.'/sabre/vobject/bin/generate_vcards';

119
vendor/bin/vobject vendored Executable file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../sabre/vobject/bin/vobject)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/sabre/vobject/bin/vobject');
}
}
return include __DIR__ . '/..'.'/sabre/vobject/bin/vobject';

579
vendor/composer/ClassLoader.php vendored Normal file
View File

@@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}

396
vendor/composer/InstalledVersions.php vendored Normal file
View File

@@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = self::getSelfDir();
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}

21
vendor/composer/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

10
vendor/composer/autoload_classmap.php vendored Normal file
View File

@@ -0,0 +1,10 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
);

12
vendor/composer/autoload_files.php vendored Normal file
View File

@@ -0,0 +1,12 @@
<?php
// autoload_files.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'383eaff206634a77a1be54e64e6459c7' => $vendorDir . '/sabre/uri/lib/functions.php',
'3569eecfeed3bcf0bad3c998a494ecb8' => $vendorDir . '/sabre/xml/lib/Deserializer/functions.php',
'93aa591bc4ca510c520999e34229ee79' => $vendorDir . '/sabre/xml/lib/Serializer/functions.php',
);

View File

@@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);

12
vendor/composer/autoload_psr4.php vendored Normal file
View File

@@ -0,0 +1,12 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Sabre\\Xml\\' => array($vendorDir . '/sabre/xml/lib'),
'Sabre\\VObject\\' => array($vendorDir . '/sabre/vobject/lib'),
'Sabre\\Uri\\' => array($vendorDir . '/sabre/uri/lib'),
);

50
vendor/composer/autoload_real.php vendored Normal file
View File

@@ -0,0 +1,50 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit440454aa6bd2975652e94f60998e9adc', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInit440454aa6bd2975652e94f60998e9adc::getInitializer($loader));
$loader->register(true);
$filesToLoad = \Composer\Autoload\ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$files;
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
require $file;
}
}, null, null);
foreach ($filesToLoad as $fileIdentifier => $file) {
$requireFile($fileIdentifier, $file);
}
return $loader;
}
}

52
vendor/composer/autoload_static.php vendored Normal file
View File

@@ -0,0 +1,52 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInit440454aa6bd2975652e94f60998e9adc
{
public static $files = array (
'383eaff206634a77a1be54e64e6459c7' => __DIR__ . '/..' . '/sabre/uri/lib/functions.php',
'3569eecfeed3bcf0bad3c998a494ecb8' => __DIR__ . '/..' . '/sabre/xml/lib/Deserializer/functions.php',
'93aa591bc4ca510c520999e34229ee79' => __DIR__ . '/..' . '/sabre/xml/lib/Serializer/functions.php',
);
public static $prefixLengthsPsr4 = array (
'S' =>
array (
'Sabre\\Xml\\' => 10,
'Sabre\\VObject\\' => 14,
'Sabre\\Uri\\' => 10,
),
);
public static $prefixDirsPsr4 = array (
'Sabre\\Xml\\' =>
array (
0 => __DIR__ . '/..' . '/sabre/xml/lib',
),
'Sabre\\VObject\\' =>
array (
0 => __DIR__ . '/..' . '/sabre/vobject/lib',
),
'Sabre\\Uri\\' =>
array (
0 => __DIR__ . '/..' . '/sabre/uri/lib',
),
);
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInit440454aa6bd2975652e94f60998e9adc::$classMap;
}, null, ClassLoader::class);
}
}

248
vendor/composer/installed.json vendored Normal file
View File

@@ -0,0 +1,248 @@
{
"packages": [
{
"name": "sabre/uri",
"version": "3.0.2",
"version_normalized": "3.0.2.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^9.6"
},
"time": "2024-09-04T15:30:08+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"install-path": "../sabre/uri"
},
{
"name": "sabre/vobject",
"version": "4.5.8",
"version_normalized": "4.5.8.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"time": "2026-01-12T10:45:19+00:00",
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"install-path": "../sabre/vobject"
},
{
"name": "sabre/xml",
"version": "4.0.6",
"version_normalized": "4.0.6.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.4 || ^8.0",
"sabre/uri": ">=2.0,<4.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^9.6"
},
"time": "2024-09-06T08:00:55+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"install-path": "../sabre/xml"
}
],
"dev": true,
"dev-package-names": []
}

50
vendor/composer/installed.php vendored Normal file
View File

@@ -0,0 +1,50 @@
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'b70aae837708d2d4458a68e4dbc5801ca173048d',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev' => true,
),
'versions' => array(
'__root__' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'b70aae837708d2d4458a68e4dbc5801ca173048d',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'sabre/uri' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
'reference' => '38eeab6ed9eec435a2188db489d4649c56272c51',
'type' => 'library',
'install_path' => __DIR__ . '/../sabre/uri',
'aliases' => array(),
'dev_requirement' => false,
),
'sabre/vobject' => array(
'pretty_version' => '4.5.8',
'version' => '4.5.8.0',
'reference' => 'd554eb24d64232922e1eab5896cc2f84b3b9ffb1',
'type' => 'library',
'install_path' => __DIR__ . '/../sabre/vobject',
'aliases' => array(),
'dev_requirement' => false,
),
'sabre/xml' => array(
'pretty_version' => '4.0.6',
'version' => '4.0.6.0',
'reference' => 'a89257fd188ce30e456b841b6915f27905dfdbe3',
'type' => 'library',
'install_path' => __DIR__ . '/../sabre/xml',
'aliases' => array(),
'dev_requirement' => false,
),
),
);

25
vendor/composer/platform_check.php vendored Normal file
View File

@@ -0,0 +1,25 @@
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 70400)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.4.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
} elseif (!headers_sent()) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
}
}
throw new \RuntimeException(
'Composer detected issues in your platform: ' . implode(' ', $issues)
);
}

27
vendor/sabre/uri/LICENSE vendored Normal file
View File

@@ -0,0 +1,27 @@
Copyright (C) 2014-2019 fruux GmbH (https://fruux.com/)
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Sabre nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

68
vendor/sabre/uri/composer.json vendored Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "sabre/uri",
"description": "Functions for making sense out of URIs.",
"keywords": [
"URI",
"URL",
"rfc3986"
],
"homepage": "http://sabre.io/uri/",
"license": "BSD-3-Clause",
"require": {
"php": "^7.4 || ^8.0"
},
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"source": "https://github.com/fruux/sabre-uri"
},
"autoload": {
"files" : [
"lib/functions.php"
],
"psr-4" : {
"Sabre\\Uri\\" : "lib/"
}
},
"autoload-dev": {
"psr-4": {
"Sabre\\Uri\\": "tests/Uri"
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpstan/extension-installer": "^1.4",
"phpunit/phpunit" : "^9.6"
},
"scripts": {
"phpstan": [
"phpstan analyse lib tests"
],
"cs-fixer": [
"php-cs-fixer fix"
],
"phpunit": [
"phpunit --configuration tests/phpunit.xml"
],
"test": [
"composer phpstan",
"composer cs-fixer",
"composer phpunit"
]
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Sabre\Uri;
/**
* Invalid Uri.
*
* This is thrown when an attempt was made to use Sabre\Uri parse a uri that
* it could not.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (https://evertpot.com/)
* @license http://sabre.io/license/
*/
class InvalidUriException extends \Exception
{
}

20
vendor/sabre/uri/lib/Version.php vendored Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Sabre\Uri;
/**
* This class contains the version number for this package.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/
*/
class Version
{
/**
* Full version number.
*/
public const VERSION = '3.0.2';
}

425
vendor/sabre/uri/lib/functions.php vendored Normal file
View File

@@ -0,0 +1,425 @@
<?php
declare(strict_types=1);
namespace Sabre\Uri;
/**
* This file contains all the uri handling functions.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/
*/
/**
* Resolves relative urls, like a browser would.
*
* This function takes a basePath, which itself _may_ also be relative, and
* then applies the relative path on top of it.
*
* @throws InvalidUriException
*/
function resolve(string $basePath, string $newPath): string
{
$delta = parse($newPath);
// If the new path defines a scheme, it's absolute and we can just return
// that.
if (null !== $delta['scheme']) {
return build($delta);
}
$base = parse($basePath);
$pick = function ($part) use ($base, $delta) {
if (null !== $delta[$part]) {
return $delta[$part];
} elseif (null !== $base[$part]) {
return $base[$part];
}
return null;
};
$newParts = [];
$newParts['scheme'] = $pick('scheme');
$newParts['host'] = $pick('host');
$newParts['port'] = $pick('port');
if (is_string($delta['path']) and strlen($delta['path']) > 0) {
// If the path starts with a slash
if ('/' === $delta['path'][0]) {
$path = $delta['path'];
} else {
// Removing last component from base path.
$path = (string) $base['path'];
$length = strrpos($path, '/');
if (false !== $length) {
$path = substr($path, 0, $length);
}
$path .= '/'.$delta['path'];
}
} else {
$path = $base['path'] ?? '/';
if ('' === $path) {
$path = '/';
}
}
// Removing .. and .
$pathParts = explode('/', $path);
$newPathParts = [];
foreach ($pathParts as $pathPart) {
switch ($pathPart) {
// case '' :
case '.':
break;
case '..':
array_pop($newPathParts);
break;
default:
$newPathParts[] = $pathPart;
break;
}
}
$path = implode('/', $newPathParts);
// If the source url ended with a /, we want to preserve that.
$newParts['path'] = 0 === strpos($path, '/') ? $path : '/'.$path;
// From PHP 8, no "?" query at all causes 'query' to be null.
// An empty query "http://example.com/foo?" causes 'query' to be the empty string
if (null !== $delta['query'] && '' !== $delta['query']) {
$newParts['query'] = $delta['query'];
} elseif (isset($base['query']) && null === $delta['host'] && null === $delta['path']) {
// Keep the old query if host and path didn't change
$newParts['query'] = $base['query'];
}
// From PHP 8, no "#" fragment at all causes 'fragment' to be null.
// An empty fragment "http://example.com/foo#" causes 'fragment' to be the empty string
if (null !== $delta['fragment'] && '' !== $delta['fragment']) {
$newParts['fragment'] = $delta['fragment'];
}
return build($newParts);
}
/**
* Takes a URI or partial URI as its argument, and normalizes it.
*
* After normalizing a URI, you can safely compare it to other URIs.
* This function will for instance convert a %7E into a tilde, according to
* rfc3986.
*
* It will also change a %3a into a %3A.
*
* @throws InvalidUriException
*/
function normalize(string $uri): string
{
$parts = parse($uri);
if (null !== $parts['path']) {
$pathParts = explode('/', ltrim($parts['path'], '/'));
$newPathParts = [];
foreach ($pathParts as $pathPart) {
switch ($pathPart) {
case '.':
// skip
break;
case '..':
// One level up in the hierarchy
array_pop($newPathParts);
break;
default:
// Ensuring that everything is correctly percent-encoded.
$newPathParts[] = rawurlencode(rawurldecode($pathPart));
break;
}
}
$parts['path'] = '/'.implode('/', $newPathParts);
}
if (null !== $parts['scheme']) {
$parts['scheme'] = strtolower($parts['scheme']);
$defaultPorts = [
'http' => '80',
'https' => '443',
];
if (null !== $parts['port'] && isset($defaultPorts[$parts['scheme']]) && $defaultPorts[$parts['scheme']] == $parts['port']) {
// Removing default ports.
unset($parts['port']);
}
// A few HTTP specific rules.
switch ($parts['scheme']) {
case 'http':
case 'https':
if (null === $parts['path']) {
// An empty path is equivalent to / in http.
$parts['path'] = '/';
}
break;
}
}
if (null !== $parts['host']) {
$parts['host'] = strtolower($parts['host']);
}
return build($parts);
}
/**
* Parses a URI and returns its individual components.
*
* This method largely behaves the same as PHP's parse_url, except that it will
* return an array with all the array keys, including the ones that are not
* set by parse_url, which makes it a bit easier to work with.
*
* Unlike PHP's parse_url, it will also convert any non-ascii characters to
* percent-encoded strings. PHP's parse_url corrupts these characters on OS X.
*
* In the return array, key "port" is an int value. Other keys have a string value.
* "Unused" keys have value null.
*
* @return array{scheme: string|null, host: string|null, path: string|null, port: positive-int|null, user: string|null, query: string|null, fragment: string|null}
*
* @throws InvalidUriException
*/
function parse(string $uri): array
{
// Normally a URI must be ASCII. However, often it's not and
// parse_url might corrupt these strings.
//
// For that reason we take any non-ascii characters from the uri and
// uriencode them first.
$uri = preg_replace_callback(
'/[^[:ascii:]]/u',
function ($matches) {
return rawurlencode($matches[0]);
},
$uri
);
if (null === $uri) {
throw new InvalidUriException('Invalid, or could not parse URI');
}
$result = parse_url($uri);
if (false === $result) {
$result = _parse_fallback($uri);
} else {
// Add empty host and leading slash to Windows file paths
// file:///C:/path or file:///C:\path
// Note: the regex fragment [a-zA-Z]:[\/\\\\].* end up being
// [a-zA-Z]:[\/\\].*
// The 4 backslash in a row are the way to get 2 backslash into the actual string
// that is used as the regex. The 2 backslash are then the way to get 1 backslash
// character into the character set "a forward slash or a backslash"
if (isset($result['scheme']) && 'file' === $result['scheme'] && isset($result['path'])
&& 1 === preg_match('/^(?<windows_path> [a-zA-Z]:([\/\\\\].*)?)$/x', $result['path'])) {
$result['path'] = '/'.$result['path'];
$result['host'] = '';
}
}
/*
* phpstan is not able to process all the things that happen while this function
* constructs the result array. It only understands the $result is
* non-empty-array<string, mixed>
*
* But the detail of the returned array is correctly specified in the PHPdoc
* above the function call.
*
* @phpstan-ignore-next-line
*/
return
$result + [
'scheme' => null,
'host' => null,
'path' => null,
'port' => null,
'user' => null,
'query' => null,
'fragment' => null,
];
}
/**
* This function takes the components returned from PHP's parse_url, and uses
* it to generate a new uri.
*
* @param array<string, int|string|null> $parts
*/
function build(array $parts): string
{
$uri = '';
$authority = '';
if (isset($parts['host'])) {
$authority = $parts['host'];
if (isset($parts['user'])) {
$authority = $parts['user'].'@'.$authority;
}
if (isset($parts['port'])) {
$authority = $authority.':'.$parts['port'];
}
}
if (isset($parts['scheme'])) {
// If there's a scheme, there's also a host.
$uri = $parts['scheme'].':';
}
if ('' !== $authority || (isset($parts['scheme']) && 'file' === $parts['scheme'])) {
// No scheme, but there is a host.
$uri .= '//'.$authority;
}
if (isset($parts['path'])) {
$uri .= $parts['path'];
}
if (isset($parts['query'])) {
$uri .= '?'.$parts['query'];
}
if (isset($parts['fragment'])) {
$uri .= '#'.$parts['fragment'];
}
return $uri;
}
/**
* Returns the 'dirname' and 'basename' for a path.
*
* The reason there is a custom function for this purpose, is because
* basename() is locale aware (behaviour changes if C locale or a UTF-8 locale
* is used) and we need a method that just operates on UTF-8 characters.
*
* In addition basename and dirname are platform aware, and will treat
* backslash (\) as a directory separator on Windows.
*
* This method returns the 2 components as an array.
*
* If there is no dirname, it will return an empty string. Any / appearing at
* the end of the string is stripped off.
*
* @return list<mixed>
*/
function split(string $path): array
{
$matches = [];
if (1 === preg_match('/^(?:(?:(.*)(?:\/+))?([^\/]+))(?:\/?)$/u', $path, $matches)) {
return [$matches[1], $matches[2]];
}
return [null, null];
}
/**
* This function is another implementation of parse_url, except this one is
* fully written in PHP.
*
* The reason is that the PHP bug team is not willing to admit that there are
* bugs in the parse_url implementation.
*
* This function is only called if the main parse method fails. It's pretty
* crude and probably slow, so the original parse_url is usually preferred.
*
* @return array{scheme: string|null, host: string|null, path: string|null, port: positive-int|null, user: string|null, query: string|null, fragment: string|null}
*
* @throws InvalidUriException
*/
function _parse_fallback(string $uri): array
{
// Normally a URI must be ASCII, however. However, often it's not and
// parse_url might corrupt these strings.
//
// For that reason we take any non-ascii characters from the uri and
// uriencode them first.
$uri = preg_replace_callback(
'/[^[:ascii:]]/u',
function ($matches) {
return rawurlencode($matches[0]);
},
$uri
);
if (null === $uri) {
throw new InvalidUriException('Invalid, or could not parse URI');
}
$result = [
'scheme' => null,
'host' => null,
'port' => null,
'user' => null,
'path' => null,
'fragment' => null,
'query' => null,
];
if (1 === preg_match('% ^([A-Za-z][A-Za-z0-9+-\.]+): %x', $uri, $matches)) {
$result['scheme'] = $matches[1];
// Take what's left.
$uri = substr($uri, strlen($result['scheme']) + 1);
if (false === $uri) {
// There was nothing left.
$uri = '';
}
}
// Taking off a fragment part
if (false !== strpos($uri, '#')) {
list($uri, $result['fragment']) = explode('#', $uri, 2);
}
// Taking off the query part
if (false !== strpos($uri, '?')) {
list($uri, $result['query']) = explode('?', $uri, 2);
}
if ('///' === substr($uri, 0, 3)) {
// The triple slash uris are a bit unusual, but we have special handling
// for them.
$path = substr($uri, 2);
if (false === $path) {
throw new \RuntimeException('The string cannot be false');
}
$result['path'] = $path;
$result['host'] = '';
} elseif ('//' === substr($uri, 0, 2)) {
// Uris that have an authority part.
$regex = '%^
//
(?: (?<user> [^:@]+) (: (?<pass> [^@]+)) @)?
(?<host> ( [^:/]* | \[ [^\]]+ \] ))
(?: : (?<port> [0-9]+))?
(?<path> / .*)?
$%x';
if (1 !== preg_match($regex, $uri, $matches)) {
throw new InvalidUriException('Invalid, or could not parse URI');
}
if (isset($matches['host']) && '' !== $matches['host']) {
$result['host'] = $matches['host'];
}
if (isset($matches['port'])) {
$port = (int) $matches['port'];
if ($port > 0) {
$result['port'] = $port;
}
}
if (isset($matches['path'])) {
$result['path'] = $matches['path'];
}
if (isset($matches['user']) && '' !== $matches['user']) {
$result['user'] = $matches['user'];
}
if (isset($matches['pass']) && '' !== $matches['pass']) {
$result['pass'] = $matches['pass'];
}
} else {
$result['path'] = $uri;
}
return $result;
}

27
vendor/sabre/vobject/LICENSE vendored Normal file
View File

@@ -0,0 +1,27 @@
Copyright (C) 2011-2016 fruux GmbH (https://fruux.com/)
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Sabre nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

55
vendor/sabre/vobject/README.md vendored Normal file
View File

@@ -0,0 +1,55 @@
sabre/vobject
=============
The VObject library allows you to easily parse and manipulate [iCalendar](https://tools.ietf.org/html/rfc5545)
and [vCard](https://tools.ietf.org/html/rfc6350) objects using PHP.
The goal of the VObject library is to create a very complete library, with an easy-to-use API.
Installation
------------
Make sure you have [Composer][1] installed, and then run:
composer require sabre/vobject "^4.0"
This package requires PHP 5.5. If you need the PHP 5.3/5.4 version of this package instead, use:
composer require sabre/vobject "^3.4"
Usage
-----
* [Working with vCards](http://sabre.io/vobject/vcard/)
* [Working with iCalendar](http://sabre.io/vobject/icalendar/)
Build status
------------
| branch | status |
| ------ | ------ |
| master | [![Build Status](https://travis-ci.org/sabre-io/vobject.svg?branch=master)](https://travis-ci.org/sabre-io/vobject) |
| 3.5 | [![Build Status](https://travis-ci.org/sabre-io/vobject.svg?branch=3.5)](https://travis-ci.org/sabre-io/vobject) |
| 3.4 | [![Build Status](https://travis-ci.org/sabre-io/vobject.svg?branch=3.4)](https://travis-ci.org/sabre-io/vobject) |
| 3.1 | [![Build Status](https://travis-ci.org/sabre-io/vobject.svg?branch=3.1)](https://travis-ci.org/sabre-io/vobject) |
| 2.1 | [![Build Status](https://travis-ci.org/sabre-io/vobject.svg?branch=2.1)](https://travis-ci.org/sabre-io/vobject) |
| 2.0 | [![Build Status](https://travis-ci.org/sabre-io/vobject.svg?branch=2.0)](https://travis-ci.org/sabre-io/vobject) |
Support
-------
Head over to the [SabreDAV mailing list](http://groups.google.com/group/sabredav-discuss) for any questions.
Made at fruux
-------------
This library is being developed by [fruux](https://fruux.com/). Drop us a line for commercial services or enterprise support.
[1]: https://getcomposer.org/

12
vendor/sabre/vobject/bin/bench.php vendored Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env php
<?php
include __DIR__.'/../vendor/autoload.php';
$data = stream_get_contents(STDIN);
$start = microtime(true);
$lol = Sabre\VObject\Reader::read($data);
echo 'time: '.(microtime(true) - $start)."\n";

View File

@@ -0,0 +1,53 @@
<?php
include __DIR__.'/../vendor/autoload.php';
if ($argc < 2) {
echo 'sabre/vobject ', Sabre\VObject\Version::VERSION, " freebusy benchmark\n";
echo "\n";
echo "This script can be used to measure the speed of generating a\n";
echo "free-busy report based on a calendar.\n";
echo "\n";
echo "The process will be repeated 100 times to get accurate stats\n";
echo "\n";
echo 'Usage: '.$argv[0]." inputfile.ics\n";
exit();
}
list(, $inputFile) = $argv;
$bench = new Hoa\Bench\Bench();
$bench->parse->start();
$vcal = Sabre\VObject\Reader::read(fopen($inputFile, 'r'));
$bench->parse->stop();
$repeat = 100;
$start = new \DateTime('2000-01-01');
$end = new \DateTime('2020-01-01');
$timeZone = new \DateTimeZone('America/Toronto');
$bench->fb->start();
for ($i = 0; $i < $repeat; ++$i) {
$fb = new Sabre\VObject\FreeBusyGenerator($start, $end, $vcal, $timeZone);
$results = $fb->getResult();
}
$bench->fb->stop();
echo $bench,"\n";
function formatMemory($input)
{
if (strlen($input) > 6) {
return round($input / (1024 * 1024)).'M';
} elseif (strlen($input) > 3) {
return round($input / 1024).'K';
}
}
unset($input, $splitter);
echo 'peak memory usage: '.formatMemory(memory_get_peak_usage()), "\n";
echo 'current memory usage: '.formatMemory(memory_get_usage()), "\n";

View File

@@ -0,0 +1,64 @@
<?php
include __DIR__.'/../vendor/autoload.php';
if ($argc < 2) {
echo 'sabre/vobject ', Sabre\VObject\Version::VERSION, " manipulation benchmark\n";
echo "\n";
echo "This script can be used to measure the speed of opening a large amount of\n";
echo "vcards, making a few alterations and serializing them again.\n";
echo 'system.';
echo "\n";
echo 'Usage: '.$argv[0]." inputfile.vcf\n";
exit();
}
list(, $inputFile) = $argv;
$input = file_get_contents($inputFile);
$splitter = new Sabre\VObject\Splitter\VCard($input);
$bench = new Hoa\Bench\Bench();
while (true) {
$bench->parse->start();
$vcard = $splitter->getNext();
$bench->parse->pause();
if (!$vcard) {
break;
}
$bench->manipulate->start();
$vcard->{'X-FOO'} = 'Random new value!';
$emails = [];
if (isset($vcard->EMAIL)) {
foreach ($vcard->EMAIL as $email) {
$emails[] = (string) $email;
}
}
$bench->manipulate->pause();
$bench->serialize->start();
$vcard2 = $vcard->serialize();
$bench->serialize->pause();
$vcard->destroy();
}
echo $bench,"\n";
function formatMemory($input)
{
if (strlen($input) > 6) {
return round($input / (1024 * 1024)).'M';
} elseif (strlen($input) > 3) {
return round($input / 1024).'K';
}
}
unset($input, $splitter);
echo 'peak memory usage: '.formatMemory(memory_get_peak_usage()), "\n";
echo 'current memory usage: '.formatMemory(memory_get_usage()), "\n";

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env php
<?php
$windowsZonesUrl = 'https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml';
$outputFile = __DIR__.'/../lib/timezonedata/windowszones.php';
echo 'Fetching timezone map from: '.$windowsZonesUrl, "\n";
$data = file_get_contents($windowsZonesUrl);
$xml = simplexml_load_string($data);
$map = [];
foreach ($xml->xpath('//mapZone') as $mapZone) {
$from = (string) $mapZone['other'];
$to = (string) $mapZone['type'];
list($to) = explode(' ', $to, 2);
if (!isset($map[$from])) {
$map[$from] = $to;
}
}
ksort($map);
echo "Writing to: $outputFile\n";
$f = fopen($outputFile, 'w');
fwrite($f, "<?php\n\n");
fwrite($f, "/**\n");
fwrite($f, " * Automatically generated timezone file\n");
fwrite($f, " *\n");
fwrite($f, ' * Last update: '.date(DATE_W3C)."\n");
fwrite($f, ' * Source: '.$windowsZonesUrl."\n");
fwrite($f, " *\n");
fwrite($f, " * @copyright Copyright (C) fruux GmbH (https://fruux.com/).\n");
fwrite($f, " * @license http://sabre.io/license/ Modified BSD License\n");
fwrite($f, " */\n");
fwrite($f, "\n");
fwrite($f, 'return ');
fwrite($f, var_export($map, true).';');
fclose($f);
echo "Formatting\n";
exec(__DIR__.'/../vendor/bin/php-cs-fixer fix '.escapeshellarg($outputFile));
echo "Done\n";

241
vendor/sabre/vobject/bin/generate_vcards vendored Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env php
<?php
namespace Sabre\VObject;
// This sucks.. we have to try to find the composer autoloader. But chances
// are, we can't find it this way. So we'll do our bestest
$paths = [
__DIR__ . '/../vendor/autoload.php', // In case vobject is cloned directly
__DIR__ . '/../../../autoload.php', // In case vobject is a composer dependency.
];
foreach($paths as $path) {
if (file_exists($path)) {
include $path;
break;
}
}
if (!class_exists('Sabre\\VObject\\Version')) {
fwrite(STDERR, "Composer autoloader could not be properly loaded.\n");
die(1);
}
if ($argc < 2) {
$version = Version::VERSION;
$help = <<<HI
sabre/vobject $version
Usage:
generate_vcards [count]
Options:
count The number of random vcards to generate
Examples:
generate_vcards 1000 > testdata.vcf
HI;
fwrite(STDERR, $help);
exit(2);
}
$count = (int)$argv[1];
if ($count < 1) {
fwrite(STDERR, "Count must be at least 1\n");
exit(2);
}
fwrite(STDERR, "sabre/vobject " . Version::VERSION . "\n");
fwrite(STDERR, "Generating " . $count . " vcards in vCard 4.0 format\n");
/**
* The following list is just some random data we compiled from various
* sources online.
*
* Very little thought went into compiling this list, and certainly nothing
* political or ethical.
*
* We would _love_ more additions to this to add more variation to this list.
*
* Send us PR's and don't be shy adding your own first and last name for fun.
*/
$sets = array(
"nl" => array(
"country" => "Netherlands",
"boys" => array(
"Anno",
"Bram",
"Daan",
"Evert",
"Finn",
"Jayden",
"Jens",
"Jesse",
"Levi",
"Lucas",
"Luuk",
"Milan",
"René",
"Sem",
"Sibrand",
"Willem",
),
"girls" => array(
"Celia",
"Emma",
"Fenna",
"Geke",
"Inge",
"Julia",
"Lisa",
"Lotte",
"Mila",
"Sara",
"Sophie",
"Tess",
"Zoë",
),
"last" => array(
"Bakker",
"Bos",
"De Boer",
"De Groot",
"De Jong",
"De Vries",
"Jansen",
"Janssen",
"Meyer",
"Mulder",
"Peters",
"Smit",
"Van Dijk",
"Van den Berg",
"Visser",
"Vos",
),
),
"us" => array(
"country" => "United States",
"boys" => array(
"Aiden",
"Alexander",
"Charles",
"David",
"Ethan",
"Jacob",
"James",
"Jayden",
"John",
"Joseph",
"Liam",
"Mason",
"Michael",
"Noah",
"Richard",
"Robert",
"Thomas",
"William",
),
"girls" => array(
"Ava",
"Barbara",
"Chloe",
"Dorothy",
"Elizabeth",
"Emily",
"Emma",
"Isabella",
"Jennifer",
"Lily",
"Linda",
"Margaret",
"Maria",
"Mary",
"Mia",
"Olivia",
"Patricia",
"Roxy",
"Sophia",
"Susan",
"Zoe",
),
"last" => array(
"Smith",
"Johnson",
"Williams",
"Jones",
"Brown",
"Davis",
"Miller",
"Wilson",
"Moore",
"Taylor",
"Anderson",
"Thomas",
"Jackson",
"White",
"Harris",
"Martin",
"Thompson",
"Garcia",
"Martinez",
"Robinson",
),
),
);
$current = 0;
$r = function($arr) {
return $arr[mt_rand(0,count($arr)-1)];
};
$bdayStart = strtotime('-85 years');
$bdayEnd = strtotime('-20 years');
while($current < $count) {
$current++;
fwrite(STDERR, "\033[100D$current/$count");
$country = array_rand($sets);
$gender = mt_rand(0,1)?'girls':'boys';
$vcard = new Component\VCard(array(
'VERSION' => '4.0',
'FN' => $r($sets[$country][$gender]) . ' ' . $r($sets[$country]['last']),
'UID' => UUIDUtil::getUUID(),
));
$bdayRatio = mt_rand(0,9);
if($bdayRatio < 2) {
// 20% has a birthday property with a full date
$dt = new \DateTime('@' . mt_rand($bdayStart, $bdayEnd));
$vcard->add('BDAY', $dt->format('Ymd'));
} elseif ($bdayRatio < 3) {
// 10% we only know the month and date of
$dt = new \DateTime('@' . mt_rand($bdayStart, $bdayEnd));
$vcard->add('BDAY', '--' . $dt->format('md'));
}
if ($result = $vcard->validate()) {
ob_start();
echo "\nWe produced an invalid vcard somehow!\n";
foreach($result as $message) {
echo " " . $message['message'] . "\n";
}
fwrite(STDERR, ob_get_clean());
}
echo $vcard->serialize();
}
fwrite(STDERR,"\nDone.\n");

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env php
<?php
use Sabre\VObject;
if ($argc < 2) {
$cmd = $argv[0];
fwrite(STDERR, <<<HI
Fruux test data generator
This script generates a lot of test data. This is used for profiling and stuff.
Currently it just generates events in a single calendar.
The iCalendar output goes to stdout. Other messages to stderr.
{$cmd} [events]
HI
);
exit();
}
$events = 100;
if (isset($argv[1])) {
$events = (int) $argv[1];
}
include __DIR__.'/../vendor/autoload.php';
fwrite(STDERR, 'Generating '.$events." events\n");
$currentDate = new DateTime('-'.round($events / 2).' days');
$calendar = new VObject\Component\VCalendar();
$ii = 0;
while ($ii < $events) {
++$ii;
$event = $calendar->add('VEVENT');
$event->DTSTART = 'bla';
$event->SUMMARY = 'Event #'.$ii;
$event->UID = md5(microtime(true));
$doctorRandom = mt_rand(1, 1000);
switch ($doctorRandom) {
// All-day event
case 1:
$event->DTEND = 'bla';
$dtStart = clone $currentDate;
$dtEnd = clone $currentDate;
$dtEnd->modify('+'.mt_rand(1, 3).' days');
$event->DTSTART->setDateTime($dtStart);
$event->DTSTART['VALUE'] = 'DATE';
$event->DTEND->setDateTime($dtEnd);
break;
case 2:
$event->RRULE = 'FREQ=DAILY;COUNT='.mt_rand(1, 10);
// no break intentional
default:
$dtStart = clone $currentDate;
$dtStart->setTime(mt_rand(1, 23), mt_rand(0, 59), mt_rand(0, 59));
$event->DTSTART->setDateTime($dtStart);
$event->DURATION = 'PT'.mt_rand(1, 3).'H';
break;
}
$currentDate->modify('+ '.mt_rand(0, 3).' days');
}
fwrite(STDERR, "Validating\n");
$result = $calendar->validate();
if ($result) {
fwrite(STDERR, "Errors!\n");
fwrite(STDERR, print_r($result, true));
exit(-1);
}
fwrite(STDERR, "Serializing this beast\n");
echo $calendar->serialize();
fwrite(STDERR, "done.\n");

160
vendor/sabre/vobject/bin/mergeduplicates.php vendored Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env php
<?php
namespace Sabre\VObject;
// This sucks.. we have to try to find the composer autoloader. But chances
// are, we can't find it this way. So we'll do our bestest
$paths = [
__DIR__.'/../vendor/autoload.php', // In case vobject is cloned directly
__DIR__.'/../../../autoload.php', // In case vobject is a composer dependency.
];
foreach ($paths as $path) {
if (file_exists($path)) {
include $path;
break;
}
}
if (!class_exists('Sabre\\VObject\\Version')) {
fwrite(STDERR, "Composer autoloader could not be loaded.\n");
exit(1);
}
echo 'sabre/vobject ', Version::VERSION, " duplicate contact merge tool\n";
if ($argc < 3) {
echo "\n";
echo 'Usage: ', $argv[0], " input.vcf output.vcf [debug.log]\n";
exit(1);
}
$input = fopen($argv[1], 'r');
$output = fopen($argv[2], 'w');
$debug = isset($argv[3]) ? fopen($argv[3], 'w') : null;
$splitter = new Splitter\VCard($input);
// The following properties are ignored. If they appear in some vcards
// but not in others, we don't consider them for the sake of finding
// differences.
$ignoredProperties = [
'PRODID',
'VERSION',
'REV',
'UID',
'X-ABLABEL',
];
$collectedNames = [];
$stats = [
'Total vcards' => 0,
'No FN property' => 0,
'Ignored duplicates' => 0,
'Merged values' => 0,
'Error' => 0,
'Unique cards' => 0,
'Total written' => 0,
];
function writeStats()
{
global $stats;
foreach ($stats as $name => $value) {
echo str_pad($name, 23, ' ', STR_PAD_RIGHT), str_pad($value, 6, ' ', STR_PAD_LEFT), "\n";
}
// Moving cursor back a few lines.
echo "\033[".count($stats).'A';
}
function write($vcard)
{
global $stats, $output;
++$stats['Total written'];
fwrite($output, $vcard->serialize()."\n");
}
while ($vcard = $splitter->getNext()) {
++$stats['Total vcards'];
writeStats();
$fn = isset($vcard->FN) ? (string) $vcard->FN : null;
if (empty($fn)) {
// Immediately write this vcard, we don't compare it.
++$stats['No FN property'];
++$stats['Unique cards'];
write($vcard);
$vcard->destroy();
continue;
}
if (!isset($collectedNames[$fn])) {
$collectedNames[$fn] = $vcard;
++$stats['Unique cards'];
continue;
} else {
// Starting comparison for all properties. We only check if properties
// in the current vcard exactly appear in the earlier vcard as well.
foreach ($vcard->children() as $newProp) {
if (in_array($newProp->name, $ignoredProperties)) {
// We don't care about properties such as UID and REV.
continue;
}
$ok = false;
foreach ($collectedNames[$fn]->select($newProp->name) as $compareProp) {
if ($compareProp->serialize() === $newProp->serialize()) {
$ok = true;
break;
}
}
if (!$ok) {
if ('EMAIL' === $newProp->name || 'TEL' === $newProp->name) {
// We're going to make another attempt to find this
// property, this time just by value. If we find it, we
// consider it a success.
foreach ($collectedNames[$fn]->select($newProp->name) as $compareProp) {
if ($compareProp->getValue() === $newProp->getValue()) {
$ok = true;
break;
}
}
if (!$ok) {
// Merging the new value in the old vcard.
$collectedNames[$fn]->add(clone $newProp);
$ok = true;
++$stats['Merged values'];
}
}
}
if (!$ok) {
// echo $newProp->serialize() . " does not appear in earlier vcard!\n";
++$stats['Error'];
if ($debug) {
fwrite($debug, "Missing '".$newProp->name."' property in duplicate. Earlier vcard:\n".$collectedNames[$fn]->serialize()."\n\nLater:\n".$vcard->serialize()."\n\n");
}
$vcard->destroy();
continue 2;
}
}
}
$vcard->destroy();
++$stats['Ignored duplicates'];
}
foreach ($collectedNames as $vcard) {
// Overwriting any old PRODID
$vcard->PRODID = '-//Sabre//Sabre VObject '.Version::VERSION.'//EN';
write($vcard);
writeStats();
}
echo str_repeat("\n", count($stats)), "\nDone.\n";

32
vendor/sabre/vobject/bin/rrulebench.php vendored Normal file
View File

@@ -0,0 +1,32 @@
<?php
include __DIR__.'/../vendor/autoload.php';
if ($argc < 4) {
echo 'sabre/vobject ', Sabre\VObject\Version::VERSION, " RRULE benchmark\n";
echo "\n";
echo "This script can be used to measure the speed of the 'recurrence expansion'\n";
echo 'system.';
echo "\n";
echo 'Usage: '.$argv[0]." inputfile.ics startdate enddate\n";
exit();
}
list(, $inputFile, $startDate, $endDate) = $argv;
$bench = new Hoa\Bench\Bench();
$bench->parse->start();
echo "Parsing.\n";
$vobj = Sabre\VObject\Reader::read(fopen($inputFile, 'r'));
$bench->parse->stop();
echo "Expanding.\n";
$bench->expand->start();
$vobj->expand(new DateTime($startDate), new DateTime($endDate));
$bench->expand->stop();
echo $bench,"\n";

27
vendor/sabre/vobject/bin/vobject vendored Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env php
<?php
namespace Sabre\VObject;
// This sucks.. we have to try to find the composer autoloader. But chances
// are, we can't find it this way. So we'll do our bestest
$paths = [
__DIR__ . '/../vendor/autoload.php', // In case vobject is cloned directly
__DIR__ . '/../../../autoload.php', // In case vobject is a composer dependency.
];
foreach($paths as $path) {
if (file_exists($path)) {
include $path;
break;
}
}
if (!class_exists('Sabre\\VObject\\Version')) {
fwrite(STDERR, "Composer autoloader could not be loaded.\n");
die(1);
}
$cli = new Cli();
exit($cli->main($argv));

107
vendor/sabre/vobject/composer.json vendored Normal file
View File

@@ -0,0 +1,107 @@
{
"name": "sabre/vobject",
"description" : "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"keywords" : [
"iCalendar",
"iCal",
"vCalendar",
"vCard",
"jCard",
"jCal",
"ics",
"vcf",
"xCard",
"xCal",
"freebusy",
"recurrence",
"availability",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868"
],
"homepage" : "http://sabre.io/vobject/",
"license" : "BSD-3-Clause",
"require" : {
"php" : "^7.1 || ^8.0",
"ext-mbstring" : "*",
"sabre/xml" : "^2.1 || ^3.0 || ^4.0"
},
"require-dev" : {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpunit/phpunit" : "^7.5 || ^8.5 || ^9.6",
"phpunit/php-invoker" : "^2.0 || ^3.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0"
},
"suggest" : {
"hoa/bench" : "If you would like to run the benchmark scripts"
},
"authors" : [
{
"name" : "Evert Pot",
"email" : "me@evertpot.com",
"homepage" : "http://evertpot.com/",
"role" : "Developer"
},
{
"name" : "Dominik Tobschall",
"email" : "dominik@fruux.com",
"homepage" : "http://tobschall.de/",
"role" : "Developer"
},
{
"name" : "Ivan Enderlin",
"email" : "ivan.enderlin@hoa-project.net",
"homepage" : "http://mnt.io/",
"role" : "Developer"
}
],
"support" : {
"forum" : "https://groups.google.com/group/sabredav-discuss",
"source" : "https://github.com/fruux/sabre-vobject"
},
"autoload" : {
"psr-4" : {
"Sabre\\VObject\\" : "lib/"
}
},
"autoload-dev" : {
"psr-4" : {
"Sabre\\VObject\\" : "tests/VObject"
}
},
"bin" : [
"bin/vobject",
"bin/generate_vcards"
],
"extra" : {
"branch-alias" : {
"dev-master" : "4.0.x-dev"
}
},
"scripts": {
"phpstan": [
"phpstan analyse lib tests"
],
"cs-fixer": [
"php-cs-fixer fix"
],
"phpunit": [
"phpunit --configuration tests/phpunit.xml"
],
"test": [
"composer phpstan",
"composer cs-fixer",
"composer phpunit"
]
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Sabre\VObject;
use Sabre\VObject\Component\VCalendar;
/**
* This class generates birthday calendars.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Dominik Tobschall (http://tobschall.de/)
* @license http://sabre.io/license/ Modified BSD License
*/
class BirthdayCalendarGenerator
{
/**
* Input objects.
*
* @var array
*/
protected $objects = [];
/**
* Default year.
* Used for dates without a year.
*/
const DEFAULT_YEAR = 2000;
/**
* Output format for the SUMMARY.
*
* @var string
*/
protected $format = '%1$s\'s Birthday';
/**
* Creates the generator.
*
* Check the setTimeRange and setObjects methods for details about the
* arguments.
*
* @param mixed $objects
*/
public function __construct($objects = null)
{
if ($objects) {
$this->setObjects($objects);
}
}
/**
* Sets the input objects.
*
* You must either supply a vCard as a string or as a Component/VCard object.
* It's also possible to supply an array of strings or objects.
*
* @param mixed $objects
*/
public function setObjects($objects)
{
if (!is_array($objects)) {
$objects = [$objects];
}
$this->objects = [];
foreach ($objects as $object) {
if (is_string($object)) {
$vObj = Reader::read($object);
if (!$vObj instanceof Component\VCard) {
throw new \InvalidArgumentException('String could not be parsed as \\Sabre\\VObject\\Component\\VCard by setObjects');
}
$this->objects[] = $vObj;
} elseif ($object instanceof Component\VCard) {
$this->objects[] = $object;
} else {
throw new \InvalidArgumentException('You can only pass strings or \\Sabre\\VObject\\Component\\VCard arguments to setObjects');
}
}
}
/**
* Sets the output format for the SUMMARY.
*
* @param string $format
*/
public function setFormat($format)
{
$this->format = $format;
}
/**
* Parses the input data and returns a VCALENDAR.
*
* @return Component/VCalendar
*/
public function getResult()
{
$calendar = new VCalendar();
foreach ($this->objects as $object) {
// Skip if there is no BDAY property.
if (!$object->select('BDAY')) {
continue;
}
// We've seen clients (ez-vcard) putting "BDAY:" properties
// without a value into vCards. If we come across those, we'll
// skip them.
if (empty($object->BDAY->getValue())) {
continue;
}
// We're always converting to vCard 4.0 so we can rely on the
// VCardConverter handling the X-APPLE-OMIT-YEAR property for us.
$object = $object->convert(Document::VCARD40);
// Skip if the card has no FN property.
if (!isset($object->FN)) {
continue;
}
// Skip if the BDAY property is not of the right type.
if (!$object->BDAY instanceof Property\VCard\DateAndOrTime) {
continue;
}
// Skip if we can't parse the BDAY value.
try {
$dateParts = DateTimeParser::parseVCardDateTime($object->BDAY->getValue());
} catch (InvalidDataException $e) {
continue;
}
// Set a year if it's not set.
$unknownYear = false;
if (!$dateParts['year']) {
$object->BDAY = self::DEFAULT_YEAR.'-'.$dateParts['month'].'-'.$dateParts['date'];
$unknownYear = true;
}
// Create event.
$event = $calendar->add('VEVENT', [
'SUMMARY' => sprintf($this->format, $object->FN->getValue()),
'DTSTART' => new \DateTime($object->BDAY->getValue()),
'RRULE' => 'FREQ=YEARLY',
'TRANSP' => 'TRANSPARENT',
]);
// add VALUE=date
$event->DTSTART['VALUE'] = 'DATE';
// Add X-SABRE-BDAY property.
if ($unknownYear) {
$event->add('X-SABRE-BDAY', 'BDAY', [
'X-SABRE-VCARD-UID' => $object->UID->getValue(),
'X-SABRE-VCARD-FN' => $object->FN->getValue(),
'X-SABRE-OMIT-YEAR' => self::DEFAULT_YEAR,
]);
} else {
$event->add('X-SABRE-BDAY', 'BDAY', [
'X-SABRE-VCARD-UID' => $object->UID->getValue(),
'X-SABRE-VCARD-FN' => $object->FN->getValue(),
]);
}
}
return $calendar;
}
}

705
vendor/sabre/vobject/lib/Cli.php vendored Normal file
View File

@@ -0,0 +1,705 @@
<?php
namespace Sabre\VObject;
use InvalidArgumentException;
/**
* This is the CLI interface for sabre-vobject.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Cli
{
/**
* No output.
*
* @var bool
*/
protected $quiet = false;
/**
* Help display.
*
* @var bool
*/
protected $showHelp = false;
/**
* Whether to spit out 'mimedir' or 'json' format.
*
* @var string
*/
protected $format;
/**
* JSON pretty print.
*
* @var bool
*/
protected $pretty;
/**
* Source file.
*
* @var string
*/
protected $inputPath;
/**
* Destination file.
*
* @var string
*/
protected $outputPath;
/**
* output stream.
*
* @var resource
*/
protected $stdout;
/**
* stdin.
*
* @var resource
*/
protected $stdin;
/**
* stderr.
*
* @var resource
*/
protected $stderr;
/**
* Input format (one of json or mimedir).
*
* @var string
*/
protected $inputFormat;
/**
* Makes the parser less strict.
*
* @var bool
*/
protected $forgiving = false;
/**
* Main function.
*
* @return int
*/
public function main(array $argv)
{
// @codeCoverageIgnoreStart
// We cannot easily test this, so we'll skip it. Pretty basic anyway.
if (!$this->stderr) {
$this->stderr = fopen('php://stderr', 'w');
}
if (!$this->stdout) {
$this->stdout = fopen('php://stdout', 'w');
}
if (!$this->stdin) {
$this->stdin = fopen('php://stdin', 'r');
}
// @codeCoverageIgnoreEnd
try {
list($options, $positional) = $this->parseArguments($argv);
if (isset($options['q'])) {
$this->quiet = true;
}
$this->log($this->colorize('green', 'sabre/vobject ').$this->colorize('yellow', Version::VERSION));
foreach ($options as $name => $value) {
switch ($name) {
case 'q':
// Already handled earlier.
break;
case 'h':
case 'help':
$this->showHelp();
return 0;
break;
case 'format':
switch ($value) {
// jcard/jcal documents
case 'jcard':
case 'jcal':
// specific document versions
case 'vcard21':
case 'vcard30':
case 'vcard40':
case 'icalendar20':
// specific formats
case 'json':
case 'mimedir':
// icalendar/vcad
case 'icalendar':
case 'vcard':
$this->format = $value;
break;
default:
throw new InvalidArgumentException('Unknown format: '.$value);
}
break;
case 'pretty':
if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
$this->pretty = true;
}
break;
case 'forgiving':
$this->forgiving = true;
break;
case 'inputformat':
switch ($value) {
// json formats
case 'jcard':
case 'jcal':
case 'json':
$this->inputFormat = 'json';
break;
// mimedir formats
case 'mimedir':
case 'icalendar':
case 'vcard':
case 'vcard21':
case 'vcard30':
case 'vcard40':
case 'icalendar20':
$this->inputFormat = 'mimedir';
break;
default:
throw new InvalidArgumentException('Unknown format: '.$value);
}
break;
default:
throw new InvalidArgumentException('Unknown option: '.$name);
}
}
if (0 === count($positional)) {
$this->showHelp();
return 1;
}
if (1 === count($positional)) {
throw new InvalidArgumentException('Inputfile is a required argument');
}
if (count($positional) > 3) {
throw new InvalidArgumentException('Too many arguments');
}
if (!in_array($positional[0], ['validate', 'repair', 'convert', 'color'])) {
throw new InvalidArgumentException('Unknown command: '.$positional[0]);
}
} catch (InvalidArgumentException $e) {
$this->showHelp();
$this->log('Error: '.$e->getMessage(), 'red');
return 1;
}
$command = $positional[0];
$this->inputPath = $positional[1];
$this->outputPath = isset($positional[2]) ? $positional[2] : '-';
if ('-' !== $this->outputPath) {
$this->stdout = fopen($this->outputPath, 'w');
}
if (!$this->inputFormat) {
if ('.json' === substr($this->inputPath, -5)) {
$this->inputFormat = 'json';
} else {
$this->inputFormat = 'mimedir';
}
}
if (!$this->format) {
if ('.json' === substr($this->outputPath, -5)) {
$this->format = 'json';
} else {
$this->format = 'mimedir';
}
}
$realCode = 0;
try {
while ($input = $this->readInput()) {
$returnCode = $this->$command($input);
if (0 !== $returnCode) {
$realCode = $returnCode;
}
}
} catch (EofException $e) {
// end of file
} catch (\Exception $e) {
$this->log('Error: '.$e->getMessage(), 'red');
return 2;
}
return $realCode;
}
/**
* Shows the help message.
*/
protected function showHelp()
{
$this->log('Usage:', 'yellow');
$this->log(' vobject [options] command [arguments]');
$this->log('');
$this->log('Options:', 'yellow');
$this->log($this->colorize('green', ' -q ')."Don't output anything.");
$this->log($this->colorize('green', ' -help -h ').'Display this help message.');
$this->log($this->colorize('green', ' --format ').'Convert to a specific format. Must be one of: vcard, vcard21,');
$this->log($this->colorize('green', ' --forgiving ').'Makes the parser less strict.');
$this->log(' vcard30, vcard40, icalendar20, jcal, jcard, json, mimedir.');
$this->log($this->colorize('green', ' --inputformat ').'If the input format cannot be guessed from the extension, it');
$this->log(' must be specified here.');
// Only PHP 5.4 and up
if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
$this->log($this->colorize('green', ' --pretty ').'json pretty-print.');
}
$this->log('');
$this->log('Commands:', 'yellow');
$this->log($this->colorize('green', ' validate').' source_file Validates a file for correctness.');
$this->log($this->colorize('green', ' repair').' source_file [output_file] Repairs a file.');
$this->log($this->colorize('green', ' convert').' source_file [output_file] Converts a file.');
$this->log($this->colorize('green', ' color').' source_file Colorize a file, useful for debugging.');
$this->log(
<<<HELP
If source_file is set as '-', STDIN will be used.
If output_file is omitted, STDOUT will be used.
All other output is sent to STDERR.
HELP
);
$this->log('Examples:', 'yellow');
$this->log(' vobject convert contact.vcf contact.json');
$this->log(' vobject convert --format=vcard40 old.vcf new.vcf');
$this->log(' vobject convert --inputformat=json --format=mimedir - -');
$this->log(' vobject color calendar.ics');
$this->log('');
$this->log('https://github.com/fruux/sabre-vobject', 'purple');
}
/**
* Validates a VObject file.
*
* @return int
*/
protected function validate(Component $vObj)
{
$returnCode = 0;
switch ($vObj->name) {
case 'VCALENDAR':
$this->log('iCalendar: '.(string) $vObj->VERSION);
break;
case 'VCARD':
$this->log('vCard: '.(string) $vObj->VERSION);
break;
}
$warnings = $vObj->validate();
if (!count($warnings)) {
$this->log(' No warnings!');
} else {
$levels = [
1 => 'REPAIRED',
2 => 'WARNING',
3 => 'ERROR',
];
$returnCode = 2;
foreach ($warnings as $warn) {
$extra = '';
if ($warn['node'] instanceof Property) {
$extra = ' (property: "'.$warn['node']->name.'")';
}
$this->log(' ['.$levels[$warn['level']].'] '.$warn['message'].$extra);
}
}
return $returnCode;
}
/**
* Repairs a VObject file.
*
* @return int
*/
protected function repair(Component $vObj)
{
$returnCode = 0;
switch ($vObj->name) {
case 'VCALENDAR':
$this->log('iCalendar: '.(string) $vObj->VERSION);
break;
case 'VCARD':
$this->log('vCard: '.(string) $vObj->VERSION);
break;
}
$warnings = $vObj->validate(Node::REPAIR);
if (!count($warnings)) {
$this->log(' No warnings!');
} else {
$levels = [
1 => 'REPAIRED',
2 => 'WARNING',
3 => 'ERROR',
];
$returnCode = 2;
foreach ($warnings as $warn) {
$extra = '';
if ($warn['node'] instanceof Property) {
$extra = ' (property: "'.$warn['node']->name.'")';
}
$this->log(' ['.$levels[$warn['level']].'] '.$warn['message'].$extra);
}
}
fwrite($this->stdout, $vObj->serialize());
return $returnCode;
}
/**
* Converts a vObject file to a new format.
*
* @param Component $vObj
*
* @return int
*/
protected function convert($vObj)
{
$json = false;
$convertVersion = null;
$forceInput = null;
switch ($this->format) {
case 'json':
$json = true;
if ('VCARD' === $vObj->name) {
$convertVersion = Document::VCARD40;
}
break;
case 'jcard':
$json = true;
$forceInput = 'VCARD';
$convertVersion = Document::VCARD40;
break;
case 'jcal':
$json = true;
$forceInput = 'VCALENDAR';
break;
case 'mimedir':
case 'icalendar':
case 'icalendar20':
case 'vcard':
break;
case 'vcard21':
$convertVersion = Document::VCARD21;
break;
case 'vcard30':
$convertVersion = Document::VCARD30;
break;
case 'vcard40':
$convertVersion = Document::VCARD40;
break;
}
if ($forceInput && $vObj->name !== $forceInput) {
throw new \Exception('You cannot convert a '.strtolower($vObj->name).' to '.$this->format);
}
if ($convertVersion) {
$vObj = $vObj->convert($convertVersion);
}
if ($json) {
$jsonOptions = 0;
if ($this->pretty) {
$jsonOptions = JSON_PRETTY_PRINT;
}
fwrite($this->stdout, json_encode($vObj->jsonSerialize(), $jsonOptions));
} else {
fwrite($this->stdout, $vObj->serialize());
}
return 0;
}
/**
* Colorizes a file.
*
* @param Component $vObj
*/
protected function color($vObj)
{
$this->serializeComponent($vObj);
}
/**
* Returns an ansi color string for a color name.
*
* @param string $color
*
* @return string
*/
protected function colorize($color, $str, $resetTo = 'default')
{
$colors = [
'cyan' => '1;36',
'red' => '1;31',
'yellow' => '1;33',
'blue' => '0;34',
'green' => '0;32',
'default' => '0',
'purple' => '0;35',
];
return "\033[".$colors[$color].'m'.$str."\033[".$colors[$resetTo].'m';
}
/**
* Writes out a string in specific color.
*
* @param string $color
* @param string $str
*/
protected function cWrite($color, $str)
{
fwrite($this->stdout, $this->colorize($color, $str));
}
protected function serializeComponent(Component $vObj)
{
$this->cWrite('cyan', 'BEGIN');
$this->cWrite('red', ':');
$this->cWrite('yellow', $vObj->name."\n");
/**
* Gives a component a 'score' for sorting purposes.
*
* This is solely used by the childrenSort method.
*
* A higher score means the item will be lower in the list.
* To avoid score collisions, each "score category" has a reasonable
* space to accommodate elements. The $key is added to the $score to
* preserve the original relative order of elements.
*
* @param int $key
* @param array $array
*
* @return int
*/
$sortScore = function ($key, $array) {
if ($array[$key] instanceof Component) {
// We want to encode VTIMEZONE first, this is a personal
// preference.
if ('VTIMEZONE' === $array[$key]->name) {
$score = 300000000;
return $score + $key;
} else {
$score = 400000000;
return $score + $key;
}
} else {
// Properties get encoded first
// VCARD version 4.0 wants the VERSION property to appear first
if ($array[$key] instanceof Property) {
if ('VERSION' === $array[$key]->name) {
$score = 100000000;
return $score + $key;
} else {
// All other properties
$score = 200000000;
return $score + $key;
}
}
}
};
$children = $vObj->children();
$tmp = $children;
uksort(
$children,
function ($a, $b) use ($sortScore, $tmp) {
$sA = $sortScore($a, $tmp);
$sB = $sortScore($b, $tmp);
return $sA - $sB;
}
);
foreach ($children as $child) {
if ($child instanceof Component) {
$this->serializeComponent($child);
} else {
$this->serializeProperty($child);
}
}
$this->cWrite('cyan', 'END');
$this->cWrite('red', ':');
$this->cWrite('yellow', $vObj->name."\n");
}
/**
* Colorizes a property.
*/
protected function serializeProperty(Property $property)
{
if ($property->group) {
$this->cWrite('default', $property->group);
$this->cWrite('red', '.');
}
$this->cWrite('yellow', $property->name);
foreach ($property->parameters as $param) {
$this->cWrite('red', ';');
$this->cWrite('blue', $param->serialize());
}
$this->cWrite('red', ':');
if ($property instanceof Property\Binary) {
$this->cWrite('default', 'embedded binary stripped. ('.strlen($property->getValue()).' bytes)');
} else {
$parts = $property->getParts();
$first1 = true;
// Looping through property values
foreach ($parts as $part) {
if ($first1) {
$first1 = false;
} else {
$this->cWrite('red', $property->delimiter);
}
$first2 = true;
// Looping through property sub-values
foreach ((array) $part as $subPart) {
if ($first2) {
$first2 = false;
} else {
// The sub-value delimiter is always comma
$this->cWrite('red', ',');
}
$subPart = strtr(
$subPart,
[
'\\' => $this->colorize('purple', '\\\\', 'green'),
';' => $this->colorize('purple', '\;', 'green'),
',' => $this->colorize('purple', '\,', 'green'),
"\n" => $this->colorize('purple', "\\n\n\t", 'green'),
"\r" => '',
]
);
$this->cWrite('green', $subPart);
}
}
}
$this->cWrite('default', "\n");
}
/**
* Parses the list of arguments.
*/
protected function parseArguments(array $argv)
{
$positional = [];
$options = [];
for ($ii = 0; $ii < count($argv); ++$ii) {
// Skipping the first argument.
if (0 === $ii) {
continue;
}
$v = $argv[$ii];
if ('--' === substr($v, 0, 2)) {
// This is a long-form option.
$optionName = substr($v, 2);
$optionValue = true;
if (strpos($optionName, '=')) {
list($optionName, $optionValue) = explode('=', $optionName);
}
$options[$optionName] = $optionValue;
} elseif ('-' === substr($v, 0, 1) && strlen($v) > 1) {
// This is a short-form option.
foreach (str_split(substr($v, 1)) as $option) {
$options[$option] = true;
}
} else {
$positional[] = $v;
}
}
return [$options, $positional];
}
protected $parser;
/**
* Reads the input file.
*
* @return Component
*/
protected function readInput()
{
if (!$this->parser) {
if ('-' !== $this->inputPath) {
$this->stdin = fopen($this->inputPath, 'r');
}
if ('mimedir' === $this->inputFormat) {
$this->parser = new Parser\MimeDir($this->stdin, ($this->forgiving ? Reader::OPTION_FORGIVING : 0));
} else {
$this->parser = new Parser\Json($this->stdin, ($this->forgiving ? Reader::OPTION_FORGIVING : 0));
}
}
return $this->parser->parse();
}
/**
* Sends a message to STDERR.
*
* @param string $msg
*/
protected function log($msg, $color = 'default')
{
if (!$this->quiet) {
if ('default' !== $color) {
$msg = $this->colorize($color, $msg);
}
fwrite($this->stderr, $msg."\n");
}
}
}

672
vendor/sabre/vobject/lib/Component.php vendored Normal file
View File

@@ -0,0 +1,672 @@
<?php
namespace Sabre\VObject;
use Sabre\Xml;
/**
* Component.
*
* A component represents a group of properties, such as VCALENDAR, VEVENT, or
* VCARD.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Component extends Node
{
/**
* Component name.
*
* This will contain a string such as VEVENT, VTODO, VCALENDAR, VCARD.
*
* @var string
*/
public $name;
/**
* A list of properties and/or sub-components.
*
* @var array<string, Component|Property>
*/
protected $children = [];
/**
* Creates a new component.
*
* You can specify the children either in key=>value syntax, in which case
* properties will automatically be created, or you can just pass a list of
* Component and Property object.
*
* By default, a set of sensible values will be added to the component. For
* an iCalendar object, this may be something like CALSCALE:GREGORIAN. To
* ensure that this does not happen, set $defaults to false.
*
* @param string|null $name such as VCALENDAR, VEVENT
* @param bool $defaults
*/
public function __construct(Document $root, $name, array $children = [], $defaults = true)
{
$this->name = isset($name) ? strtoupper($name) : '';
$this->root = $root;
if ($defaults) {
// This is a terribly convoluted way to do this, but this ensures
// that the order of properties as they are specified in both
// defaults and the childrens list, are inserted in the object in a
// natural way.
$list = $this->getDefaults();
$nodes = [];
foreach ($children as $key => $value) {
if ($value instanceof Node) {
if (isset($list[$value->name])) {
unset($list[$value->name]);
}
$nodes[] = $value;
} else {
$list[$key] = $value;
}
}
foreach ($list as $key => $value) {
$this->add($key, $value);
}
foreach ($nodes as $node) {
$this->add($node);
}
} else {
foreach ($children as $k => $child) {
if ($child instanceof Node) {
// Component or Property
$this->add($child);
} else {
// Property key=>value
$this->add($k, $child);
}
}
}
}
/**
* Adds a new property or component, and returns the new item.
*
* This method has 3 possible signatures:
*
* add(Component $comp) // Adds a new component
* add(Property $prop) // Adds a new property
* add($name, $value, array $parameters = []) // Adds a new property
* add($name, array $children = []) // Adds a new component
* by name.
*
* @return Node
*/
public function add()
{
$arguments = func_get_args();
if ($arguments[0] instanceof Node) {
if (isset($arguments[1])) {
throw new \InvalidArgumentException('The second argument must not be specified, when passing a VObject Node');
}
$arguments[0]->parent = $this;
$newNode = $arguments[0];
} elseif (is_string($arguments[0])) {
$newNode = call_user_func_array([$this->root, 'create'], $arguments);
} else {
throw new \InvalidArgumentException('The first argument must either be a \\Sabre\\VObject\\Node or a string');
}
$name = $newNode->name;
if (isset($this->children[$name])) {
$this->children[$name][] = $newNode;
} else {
$this->children[$name] = [$newNode];
}
return $newNode;
}
/**
* This method removes a component or property from this component.
*
* You can either specify the item by name (like DTSTART), in which case
* all properties/components with that name will be removed, or you can
* pass an instance of a property or component, in which case only that
* exact item will be removed.
*
* @param string|Property|Component $item
*/
public function remove($item)
{
if (is_string($item)) {
// If there's no dot in the name, it's an exact property name and
// we can just wipe out all those properties.
//
if (false === strpos($item, '.')) {
unset($this->children[strtoupper($item)]);
return;
}
// If there was a dot, we need to ask select() to help us out and
// then we just call remove recursively.
foreach ($this->select($item) as $child) {
$this->remove($child);
}
} else {
foreach ($this->select($item->name) as $k => $child) {
if ($child === $item) {
unset($this->children[$item->name][$k]);
return;
}
}
throw new \InvalidArgumentException('The item you passed to remove() was not a child of this component');
}
}
/**
* Returns a flat list of all the properties and components in this
* component.
*
* @return array
*/
public function children()
{
$result = [];
foreach ($this->children as $childGroup) {
$result = array_merge($result, $childGroup);
}
return $result;
}
/**
* This method only returns a list of sub-components. Properties are
* ignored.
*
* @return array
*/
public function getComponents()
{
$result = [];
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
if ($child instanceof self) {
$result[] = $child;
}
}
}
return $result;
}
/**
* Returns an array with elements that match the specified name.
*
* This function is also aware of MIME-Directory groups (as they appear in
* vcards). This means that if a property is grouped as "HOME.EMAIL", it
* will also be returned when searching for just "EMAIL". If you want to
* search for a property in a specific group, you can select on the entire
* string ("HOME.EMAIL"). If you want to search on a specific property that
* has not been assigned a group, specify ".EMAIL".
*
* @param string $name
*
* @return array
*/
public function select($name)
{
$group = null;
$name = strtoupper($name);
if (false !== strpos($name, '.')) {
list($group, $name) = explode('.', $name, 2);
}
if ('' === $name) {
$name = null;
}
if (!is_null($name)) {
$result = isset($this->children[$name]) ? $this->children[$name] : [];
if (is_null($group)) {
return $result;
} else {
// If we have a group filter as well, we need to narrow it down
// more.
return array_filter(
$result,
function ($child) use ($group) {
return $child instanceof Property && (null !== $child->group ? strtoupper($child->group) : '') === $group;
}
);
}
}
// If we got to this point, it means there was no 'name' specified for
// searching, implying that this is a group-only search.
$result = [];
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
if ($child instanceof Property && (null !== $child->group ? strtoupper($child->group) : '') === $group) {
$result[] = $child;
}
}
}
return $result;
}
/**
* Turns the object back into a serialized blob.
*
* @return string
*/
public function serialize()
{
$str = 'BEGIN:'.$this->name."\r\n";
/**
* Gives a component a 'score' for sorting purposes.
*
* This is solely used by the childrenSort method.
*
* A higher score means the item will be lower in the list.
* To avoid score collisions, each "score category" has a reasonable
* space to accommodate elements. The $key is added to the $score to
* preserve the original relative order of elements.
*
* @param int $key
* @param array $array
*
* @return int
*/
$sortScore = function ($key, $array) {
if ($array[$key] instanceof Component) {
// We want to encode VTIMEZONE first, this is a personal
// preference.
if ('VTIMEZONE' === $array[$key]->name) {
$score = 300000000;
return $score + $key;
} else {
$score = 400000000;
return $score + $key;
}
} else {
// Properties get encoded first
// VCARD version 4.0 wants the VERSION property to appear first
if ($array[$key] instanceof Property) {
if ('VERSION' === $array[$key]->name) {
$score = 100000000;
return $score + $key;
} else {
// All other properties
$score = 200000000;
return $score + $key;
}
}
}
};
$children = $this->children();
$tmp = $children;
uksort(
$children,
function ($a, $b) use ($sortScore, $tmp) {
$sA = $sortScore($a, $tmp);
$sB = $sortScore($b, $tmp);
return $sA - $sB;
}
);
foreach ($children as $child) {
$str .= $child->serialize();
}
$str .= 'END:'.$this->name."\r\n";
return $str;
}
/**
* This method returns an array, with the representation as it should be
* encoded in JSON. This is used to create jCard or jCal documents.
*
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$components = [];
$properties = [];
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
if ($child instanceof self) {
$components[] = $child->jsonSerialize();
} else {
$properties[] = $child->jsonSerialize();
}
}
}
return [
strtolower($this->name),
$properties,
$components,
];
}
/**
* This method serializes the data into XML. This is used to create xCard or
* xCal documents.
*
* @param Xml\Writer $writer XML writer
*/
public function xmlSerialize(Xml\Writer $writer): void
{
$components = [];
$properties = [];
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
if ($child instanceof self) {
$components[] = $child;
} else {
$properties[] = $child;
}
}
}
$writer->startElement(strtolower($this->name));
if (!empty($properties)) {
$writer->startElement('properties');
foreach ($properties as $property) {
$property->xmlSerialize($writer);
}
$writer->endElement();
}
if (!empty($components)) {
$writer->startElement('components');
foreach ($components as $component) {
$component->xmlSerialize($writer);
}
$writer->endElement();
}
$writer->endElement();
}
/**
* This method should return a list of default property values.
*
* @return array
*/
protected function getDefaults()
{
return [];
}
/* Magic property accessors {{{ */
/**
* Using 'get' you will either get a property or component.
*
* If there were no child-elements found with the specified name,
* null is returned.
*
* To use this, this may look something like this:
*
* $event = $calendar->VEVENT;
*
* @param string $name
*
* @return Property|null
*/
public function __get($name)
{
if ('children' === $name) {
throw new \RuntimeException('Starting sabre/vobject 4.0 the children property is now protected. You should use the children() method instead');
}
$matches = $this->select($name);
if (0 === count($matches)) {
return;
} else {
$firstMatch = current($matches);
/* @var $firstMatch Property */
$firstMatch->setIterator(new ElementList(array_values($matches)));
return $firstMatch;
}
}
/**
* This method checks if a sub-element with the specified name exists.
*
* @param string $name
*
* @return bool
*/
public function __isset($name)
{
$matches = $this->select($name);
return count($matches) > 0;
}
/**
* Using the setter method you can add properties or subcomponents.
*
* You can either pass a Component, Property
* object, or a string to automatically create a Property.
*
* If the item already exists, it will be removed. If you want to add
* a new item with the same name, always use the add() method.
*
* @param string $name
* @param mixed $value
*/
public function __set($name, $value)
{
$name = strtoupper($name);
$this->remove($name);
if ($value instanceof self || $value instanceof Property) {
$this->add($value);
} else {
$this->add($name, $value);
}
}
/**
* Removes all properties and components within this component with the
* specified name.
*
* @param string $name
*/
public function __unset($name)
{
$this->remove($name);
}
/* }}} */
/**
* This method is automatically called when the object is cloned.
* Specifically, this will ensure all child elements are also cloned.
*/
public function __clone()
{
foreach ($this->children as $childName => $childGroup) {
foreach ($childGroup as $key => $child) {
$clonedChild = clone $child;
$clonedChild->parent = $this;
$clonedChild->root = $this->root;
$this->children[$childName][$key] = $clonedChild;
}
}
}
/**
* A simple list of validation rules.
*
* This is simply a list of properties, and how many times they either
* must or must not appear.
*
* Possible values per property:
* * 0 - Must not appear.
* * 1 - Must appear exactly once.
* * + - Must appear at least once.
* * * - Can appear any number of times.
* * ? - May appear, but not more than once.
*
* It is also possible to specify defaults and severity levels for
* violating the rule.
*
* See the VEVENT implementation for getValidationRules for a more complex
* example.
*
* @var array
*/
public function getValidationRules()
{
return [];
}
/**
* Validates the node for correctness.
*
* The following options are supported:
* Node::REPAIR - May attempt to automatically repair the problem.
* Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes.
* Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes.
*
* This method returns an array with detected problems.
* Every element has the following properties:
*
* * level - problem level.
* * message - A human-readable string describing the issue.
* * node - A reference to the problematic node.
*
* The level means:
* 1 - The issue was repaired (only happens if REPAIR was turned on).
* 2 - A warning.
* 3 - An error.
*
* @param int $options
*
* @return array
*/
public function validate($options = 0)
{
$rules = $this->getValidationRules();
$defaults = $this->getDefaults();
$propertyCounters = [];
$messages = [];
foreach ($this->children() as $child) {
$name = strtoupper($child->name);
if (!isset($propertyCounters[$name])) {
$propertyCounters[$name] = 1;
} else {
++$propertyCounters[$name];
}
$messages = array_merge($messages, $child->validate($options));
}
foreach ($rules as $propName => $rule) {
switch ($rule) {
case '0':
if (isset($propertyCounters[$propName])) {
$messages[] = [
'level' => 3,
'message' => $propName.' MUST NOT appear in a '.$this->name.' component',
'node' => $this,
];
}
break;
case '1':
if (!isset($propertyCounters[$propName]) || 1 !== $propertyCounters[$propName]) {
$repaired = false;
if ($options & self::REPAIR && isset($defaults[$propName])) {
$this->add($propName, $defaults[$propName]);
$repaired = true;
}
$messages[] = [
'level' => $repaired ? 1 : 3,
'message' => $propName.' MUST appear exactly once in a '.$this->name.' component',
'node' => $this,
];
}
break;
case '+':
if (!isset($propertyCounters[$propName]) || $propertyCounters[$propName] < 1) {
$messages[] = [
'level' => 3,
'message' => $propName.' MUST appear at least once in a '.$this->name.' component',
'node' => $this,
];
}
break;
case '*':
break;
case '?':
if (isset($propertyCounters[$propName]) && $propertyCounters[$propName] > 1) {
$level = 3;
// We try to repair the same property appearing multiple times with the exact same value
// by removing the duplicates and keeping only one property
if ($options & self::REPAIR) {
$properties = array_unique($this->select($propName), SORT_REGULAR);
if (1 === count($properties)) {
$this->remove($propName);
$this->add($properties[0]);
$level = 1;
}
}
$messages[] = [
'level' => $level,
'message' => $propName.' MUST NOT appear more than once in a '.$this->name.' component',
'node' => $this,
];
}
break;
}
}
return $messages;
}
/**
* Call this method on a document if you're done using it.
*
* It's intended to remove all circular references, so PHP can easily clean
* it up.
*/
public function destroy()
{
parent::destroy();
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
$child->destroy();
}
}
$this->children = [];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Sabre\VObject\Component;
use Sabre\VObject;
/**
* The Available sub-component.
*
* This component adds functionality to a component, specific for AVAILABLE
* components.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Ivan Enderlin
* @license http://sabre.io/license/ Modified BSD License
*/
class Available extends VObject\Component
{
/**
* Returns the 'effective start' and 'effective end' of this VAVAILABILITY
* component.
*
* We use the DTSTART and DTEND or DURATION to determine this.
*
* The returned value is an array containing DateTimeImmutable instances.
* If either the start or end is 'unbounded' its value will be null
* instead.
*
* @return array
*/
public function getEffectiveStartEnd()
{
$effectiveStart = $this->DTSTART->getDateTime();
if (isset($this->DTEND)) {
$effectiveEnd = $this->DTEND->getDateTime();
} else {
$effectiveEnd = $effectiveStart->add(VObject\DateTimeParser::parseDuration($this->DURATION));
}
return [$effectiveStart, $effectiveEnd];
}
/**
* A simple list of validation rules.
*
* This is simply a list of properties, and how many times they either
* must or must not appear.
*
* Possible values per property:
* * 0 - Must not appear.
* * 1 - Must appear exactly once.
* * + - Must appear at least once.
* * * - Can appear any number of times.
* * ? - May appear, but not more than once.
*
* @var array
*/
public function getValidationRules()
{
return [
'UID' => 1,
'DTSTART' => 1,
'DTSTAMP' => 1,
'DTEND' => '?',
'DURATION' => '?',
'CREATED' => '?',
'DESCRIPTION' => '?',
'LAST-MODIFIED' => '?',
'RECURRENCE-ID' => '?',
'RRULE' => '?',
'SUMMARY' => '?',
'CATEGORIES' => '*',
'COMMENT' => '*',
'CONTACT' => '*',
'EXDATE' => '*',
'RDATE' => '*',
'AVAILABLE' => '*',
];
}
/**
* Validates the node for correctness.
*
* The following options are supported:
* Node::REPAIR - May attempt to automatically repair the problem.
* Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes.
* Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes.
*
* This method returns an array with detected problems.
* Every element has the following properties:
*
* * level - problem level.
* * message - A human-readable string describing the issue.
* * node - A reference to the problematic node.
*
* The level means:
* 1 - The issue was repaired (only happens if REPAIR was turned on).
* 2 - A warning.
* 3 - An error.
*
* @param int $options
*
* @return array
*/
public function validate($options = 0)
{
$result = parent::validate($options);
if (isset($this->DTEND) && isset($this->DURATION)) {
$result[] = [
'level' => 3,
'message' => 'DTEND and DURATION cannot both be present',
'node' => $this,
];
}
return $result;
}
}

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